Compare commits

..
Author SHA1 Message Date
Vito Sansevero 7b09aa8a26 fix(workspace): start no sidebar count read while the page is leaving (#494)
The Favorites count is re-read 300 ms after a star changes. A reload
inside that window started the read on the leaving page, which WebKit
fails ("access control checks") and reports as a page error. After
beforeunload, the workspace clears that timer, aborts count reads in
flight and defers new ones, as well as a finished restore's reload. A
page that stays reads them on its next input or when it becomes visible,
as the legacy admin page does.
2026-10-29 12:44:27 -07:00
Vito dd247e3b6f ci(portability): cut the Windows Python suite time at its causes (#487) (#490)
* test: list the slowest tests on request and stop waiting 30 s on a held WAL (#487)

PM_TEST_SLOWEST=N makes tests/run_suite.py list the N slowest tests.
Three history tests restored while their own read held a snapshot, so
each publish sat out the 30 s WAL-truncate wait; they now use a short wait.
Temporary diagnostics for the Windows timings ride along in this commit.

* test(lora): check Windows folder rules without asking the network (#487)

On Windows ntpath.realpath looks up \\server\share and waited about 2.8 s
per test for a host that does not exist. The guard tests now use the
lexical NT path rules, which is what they already ran on Linux and macOS.

* ci(portability): keep Windows test files on the work disk (#487)

The Windows runner's TEMP is on C:, where a flush took about 6 ms and a test
database's create-to-delete about 111 ms; on D: the same took 0.5 ms and
23 ms. The job now points TEMP/TMP at RUNNER_TEMP and lists the 40 slowest
tests. The fake HTTPS handlers get a TLS context that reads no certificate
store, and the missing-share tests name localhost instead of an unknown
server. The temporary diagnostics are removed.

* ci(portability): spell the Windows test TEMP by its short name (#487)

The runner's own TEMP was C:\Users\RUNNER~1\..., so tests met 8.3 short
names in every temp path. The folder on D: keeps that when the disk has
short names, and the step logs which spelling it got. TESTING.md notes
that the slowest list is not printed when a job is cancelled.

* ci(portability): switch on 8.3 names for the Windows test TEMP (#487)

D: made no short names (run 40922502209 logged the long spelling), so
the step now switches them on for D: before it creates the folder, and
warns when the folder still has no short name.
2026-10-29 18:28:47 +00:00
Vito c416c84347 test(collections): change sources only after the page's selected-file read is answered (#491)
The details panel reads its selected file as soon as the page lists. When the
test's source change landed before that read was answered, the page found the
file again on its own (409 -> rescope -> relist), so the card under the old
numbering the test was about to click was gone. Every test in the spec now
waits for that read before changing sources.

Closes #484
2026-10-29 18:26:13 +00:00
Vito 599dd828e6 fix(inspect): keep a viewer whose video is still starting until it settles (#483) (#489)
A new selection closed the shared viewer at once, resetting and removing
its video even while that video was still starting. That is the WebKit
(GStreamer) deadlock D62 guards the inline preview against, and it hung
merge-queue run 40903238387. The viewer now stays open, untouched, until
its video reports metadata or an error (or 15 s, or the user closes it),
and the URL is revoked after that. An open asked for meanwhile runs once,
latest wins, only while its selection is still current.
2026-10-29 17:50:51 +00:00
17 changed files with 505 additions and 230 deletions
+22
View File
@@ -51,5 +51,27 @@ jobs:
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
# The runner's TEMP is on C:, the OS disk, where a flush took about 6 ms
# and a test database's create-to-delete about 111 ms; on the D: work
# disk the same took 0.5 ms and 23 ms (macOS: 19 ms). Tests write their
# databases under TEMP, so it moves to a folder on D: (measured in #487,
# run 40915866444). The runner's TEMP was an 8.3 short name
# (C:\Users\RUNNER~1\...), so the new one is spelled by its short name too
# and paths still have to be resolved. D: makes no short names by default
# (run 40922502209), so they are switched on for it first.
- name: Keep test files on the work disk (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
fsutil 8dot3name set D: 0
$folder = New-Item -ItemType Directory -Force -Path (Join-Path $env:RUNNER_TEMP "PromptManagerTests")
$short = (New-Object -ComObject Scripting.FileSystemObject).GetFolder($folder.FullName).ShortPath
Write-Output "test TEMP: $short (long name $($folder.FullName))"
if ($short -eq $folder.FullName) { Write-Output "::warning::D: gave the test TEMP no 8.3 short name" }
"TEMP=$short" >> $env:GITHUB_ENV
"TMP=$short" >> $env:GITHUB_ENV
- name: Run tests
env:
PM_TEST_SLOWEST: "40"
run: python -m tests.run_suite -v
+21
View File
@@ -251,6 +251,21 @@ the process exits non-zero.
stacks.
- When the watchdog fires under coverage, that run's coverage data is lost; the
job fails anyway and the dump is what matters.
- `PM_TEST_SLOWEST=N` (off by default) lists the N slowest tests after the
summary, each timed from its `start:` line to the next one like the hang
limit. The Windows and macOS jobs (`portability.yml`) set it to 40, so every
run shows where its time went (#487). The list prints only when the run
ends: a job GitHub cancels or a watchdog exit prints none, so read the
timestamps between `start:` lines instead.
- On the Windows runners `TEMP` sits on the OS disk C:, where a flush took about
6 ms and a test database's create-to-delete about 111 ms (D: 0.5 ms and 23 ms,
macOS 19 ms). `portability.yml` points `TEMP`/`TMP` at a folder under
`RUNNER_TEMP` on D: before the suite. It switches 8.3 short names on for D:
and spells the folder by its short name, as the runner's own TEMP was
(`C:\Users\RUNNER~1\...`), logs it, and warns when there is none.
A test that waits on a fixed busy timeout, a network name (`\\server\share`:
use `\\localhost\<missing share>`) or a default TLS context (it reads the
Windows certificate store) costs seconds there; the slowest list shows it.
Every job in `test.yml`, `code-quality.yml` and `viewerjs-check.yml` has
`timeout-minutes` (`tests/vendor/ci-workflow.test.mjs` checks it). The rule is
@@ -2869,6 +2884,12 @@ mapped differences"):
change within the workspace waits the same way: the shell renders the next route once a
video still starting on Inspect settles (`leaveGuard`; the latest route wins). Route
changes at `loadstart` + 0–7 ms hung 4–5 of 120 pages before and 0 of 120 after.
The shared viewer's own video gets the same guard (#483): a new selection while the
viewer D155 opened is still starting its video leaves the viewer open, untouched, until
that video reports metadata or an error (or 15 s), then closes it; the URL is revoked
after that, and a viewer asked for meanwhile (the new file's D155 open) opens then. The
merge queue hung once without it (run 40903238387: the PNG selection 12 ms after the
inline video showed, with the viewer's new video still starting).
A real page leave cannot wait. With no page code releasing anything, navigating
away hung 8 of 120 pages and reload 22 of 120 (3 and 8 of 120 with the page's own
release), so that WebKit teardown stays a known limitation; dispose still releases at
+13 -26
View File
@@ -40,7 +40,6 @@ from aiohttp import web
from PIL import Image
from .prompts import PromptRoutesMixin
from .revalidation import revalidated_response
from .pairs import PairRoutesMixin
from .library import LibraryRoutesMixin
from .vocabulary import VocabularyRoutesMixin
@@ -654,39 +653,35 @@ class PromptManagerAPI(
@routes.get("/prompt_manager/workspace")
async def serve_workspace_ui(request: web.Request) -> web.Response:
return self._serve_html_page(request, "workspace.html", "Workspace")
return self._serve_html_page("workspace.html", "Workspace")
@routes.get("/prompt_manager/web")
async def serve_web_ui(request):
return self._serve_html_page(request, "index.html", "Web UI", cache=False)
return self._serve_html_page("index.html", "Web UI", cache=False)
@routes.get("/prompt_manager/gallery.html")
async def serve_gallery_ui(request):
return self._serve_html_page(request, "metadata.html", "Gallery")
return self._serve_html_page("metadata.html", "Gallery")
@routes.get("/prompt_manager/admin")
async def serve_admin_ui(request):
return self._serve_html_page(request, "admin.html", "Admin UI")
return self._serve_html_page("admin.html", "Admin UI")
@routes.get("/prompt_manager/gallery")
async def serve_gallery_admin_ui(request):
return self._serve_html_page(request, "gallery.html", "Gallery")
return self._serve_html_page("gallery.html", "Gallery")
# ── Static file serving ───────────────────────────────────────
@routes.get("/prompt_manager/lib/{filepath:.*}")
async def serve_lib_static(request):
"""Serve static library files (JS, CSS) from web/lib directory."""
return self._serve_static_file(
request, "lib", request.match_info.get("filepath")
)
return self._serve_static_file("lib", request.match_info.get("filepath"))
@routes.get("/prompt_manager/js/{filepath:.*}")
async def serve_js_static(request):
"""Serve static JavaScript files from web/js directory."""
return self._serve_static_file(
request, "js", request.match_info.get("filepath")
)
return self._serve_static_file("js", request.match_info.get("filepath"))
# ── Register domain-specific routes from mixins ───────────────
@@ -731,11 +726,8 @@ class PromptManagerAPI(
self.logger.info("All routes registered with decorator pattern")
def _serve_static_file(self, request, subdir, filepath):
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing.
A served file is revalidated on every reuse (#478, ``revalidation``).
"""
def _serve_static_file(self, subdir, filepath):
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing."""
root = os.path.join(_get_project_root(), "web", subdir)
exact = STATIC_EXACT_FILES.get((subdir, filepath))
# An exact file passes the same containment check, its extension only.
@@ -750,13 +742,10 @@ class PromptManagerAPI(
return web.Response(text="Not Found", status=404)
with open(file_path, "rb") as f:
content = f.read()
return revalidated_response(request, content, content_type)
return web.Response(body=content, content_type=content_type)
def _serve_html_page(self, request, filename, title, cache=True):
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable.
A served page is revalidated on every reuse (#478, ``revalidation``).
"""
def _serve_html_page(self, filename, title, cache=True):
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable."""
html_path = os.path.join(_get_project_root(), "web", filename)
try:
if cache and html_path in self._html_cache:
@@ -773,9 +762,7 @@ class PromptManagerAPI(
content_type="text/html",
status=404,
)
return revalidated_response(
request, content.encode("utf-8"), "text/html", charset="utf-8"
)
return web.Response(text=content, content_type="text/html", charset="utf-8")
except Exception:
self.logger.exception(f"Failed to load {title}")
return web.Response(
-47
View File
@@ -1,47 +0,0 @@
"""Revalidated responses for the pages and static files PromptManager serves.
``Cache-Control: no-cache`` lets the browser keep a copy but makes it ask the
server before every reuse, so a PromptManager update reaches the browser on a
normal reload (#478). The ETag lets an unchanged file answer 304 without a
body, which keeps that reload cheap.
The tag hashes the bytes rather than the file's mtime and size: an update can
keep both (an archive extract, a same-length edit within one clock tick), and
the handlers read the whole file anyway. It is weak because the gzip
middleware may send the same content in a different encoding.
"""
import hashlib
from aiohttp import web
REVALIDATE = "no-cache"
def entity_tag(body: bytes) -> str:
"""Weak ETag for ``body``."""
return f'W/"{hashlib.sha256(body).hexdigest()[:32]}"'
def etag_matches(if_none_match, etag: str) -> bool:
"""True when an If-None-Match header names ``etag`` (weak comparison)."""
if not if_none_match:
return False
if if_none_match.strip() == "*":
return True
opaque = etag.removeprefix("W/")
return any(
candidate.strip().removeprefix("W/") == opaque
for candidate in if_none_match.split(",")
)
def revalidated_response(request, body: bytes, content_type: str, charset=None):
"""200 with ``body``, or 304 when the request already holds this version."""
etag = entity_tag(body)
headers = {"Cache-Control": REVALIDATE, "ETag": etag}
if etag_matches(request.headers.get("If-None-Match"), etag):
return web.Response(status=304, headers=headers)
return web.Response(
body=body, content_type=content_type, charset=charset, headers=headers
)
+55
View File
@@ -1376,6 +1376,61 @@ async function expectStar(button, favorite) {
await expect(button).toHaveAttribute('aria-pressed', String(favorite));
}
// #494: the Favorites count is re-read 300 ms after a star changes. A page that has begun to leave starts no count read
// (WebKit fails it with "Fetch API cannot load … due to access control checks.", reported as a page error; queue run
// 40934368716). A leave that is called off counts again on the next input.
for (const leave of ['beforeunload', 'pagehide']) {
test(`a star changed just before ${leave} starts no Favorites count read while the page leaves`, async ({ page }) => {
await page.clock.install();
const countReads = [];
page.on('request', item => {
const url = new URL(item.url());
if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('favorites') === '1' && url.searchParams.get('limit') === '1') countReads.push(url.search);
});
await page.goto(workspace);
const card = page.locator('article[data-image-key="0:shared/same.png"]');
await expectStar(star(card, 'same.png'), false);
await expect.poll(() => countReads.length).toBe(1);
await star(card, 'same.png').click();
await expectStar(star(card, 'same.png'), true);
await expect(toast(page)).toHaveText('Added to favorites.');
await page.evaluate(type => window.dispatchEvent(new Event(type)), leave);
await page.clock.runFor(1000);
// A read made after the clock ran is listed, so the listener saw every count read.
await page.evaluate(() => fetch('/prompt_manager/images/output?mode=workspace&favorites=1&limit=1&probe=1'));
await expect.poll(() => countReads.length).toBe(2);
expect(countReads.at(-1)).toContain('probe=1');
if (leave === 'beforeunload') {
// The leave was called off (the page is still here): the next input counts again, once, and the badge shows it.
await page.keyboard.press('Shift');
await expect.poll(() => countReads.length).toBe(3);
expect(countReads.at(-1)).not.toContain('probe=1');
await expect(page.locator('nav [data-route="favorites"] .nav-count')).toHaveText('1');
await page.clock.runFor(1000);
expect(countReads).toHaveLength(3);
}
});
}
test('a count read in flight when the page starts to leave is dropped, then read again if the page stays (#494)', async ({ page }) => {
const held = [];
// The page's first Library count read is held at the server boundary while the page starts to leave.
await page.route('**/prompt_manager/library?limit=1', route => { held.push(route); });
await page.goto(workspace);
await expect.poll(() => held.length).toBe(1);
const failed = page.waitForEvent('requestfailed', item => item.url().includes('/prompt_manager/library?limit=1'));
await page.evaluate(() => window.dispatchEvent(new Event('beforeunload')));
await failed;
await page.unroute('**/prompt_manager/library?limit=1');
await held[0].continue().catch(() => {});
await expect(page.locator('nav [data-route="library"] .nav-count')).toHaveCount(0);
// The page stayed: the next input reads the dropped count again.
const reread = page.waitForRequest(item => item.url().includes('/prompt_manager/library?limit=1'));
await page.keyboard.press('Shift');
await reread;
await expect(page.locator('nav [data-route="library"] .nav-count')).toHaveText(/^\d+$/);
});
test('favorite persists across reload', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
const sent = [];
+141
View File
@@ -704,6 +704,147 @@ test('a video replaced while still starting is reset once it settles', async ({
}
});
test('a new selection while the viewer\'s own video is still starting leaves it until it settles (#483)', async ({ context, browserName }) => {
test.setTimeout(180000);
// D155 opens the viewer once the inline preview has settled; the viewer then starts a video of its own. A new
// selection from that video's own `loadstart` (readyState HAVE_NOTHING), or a few milliseconds after it, must not
// reset it or take it off the page while it starts (WebKit, GStreamer: TESTING.md D62; merge-queue run
// 40903238387). The viewer stays open until it reports metadata or an error, then closes; the URL goes last.
for (let attempt = 0; attempt < 10; attempt++) {
const page = await context.newPage();
try {
if (attempt === 0) await startCoverage(page, browserName);
await page.addInitScript(() => {
const load = HTMLMediaElement.prototype.load;
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) { this.__resets = (this.__resets ?? 0) + 1; this.__resetReadyState ??= this.readyState; } return load.call(this); };
const revoke = URL.revokeObjectURL;
URL.revokeObjectURL = url => {
const starting = window.__starting;
if (url === starting?.url) starting.revoked = { src: starting.node.hasAttribute('src'), connected: starting.node.isConnected, resets: starting.node.__resets ?? 0 };
return revoke(url);
};
});
await openInspect(page);
await page.evaluate(() => {
const input = document.querySelector('#local-file');
new MutationObserver((records, observer) => {
const video = document.querySelector('#viewer-dialog video');
if (!video) return;
observer.disconnect();
const replace = () => {
window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState };
input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true }));
window.__starting.replaced = { connected: video.isConnected, src: video.hasAttribute('src'), resets: video.__resets ?? 0 };
};
video.addEventListener('loadstart', () => { if (window.__delay) setTimeout(replace, window.__delay); else replace(); }, { once: true });
}).observe(document.body, { childList: true, subtree: true });
});
const png = parityPng('f02.png');
await page.evaluate(bytes => {
const transfer = new DataTransfer();
transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' }));
window.__pngFiles = transfer.files;
}, [...png.buffer]);
await page.evaluate(delay => { window.__delay = delay; }, attempt % 5);
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
await expect.poll(() => page.evaluate(() => window.__starting?.revoked ?? null)).toEqual({ src: false, connected: false, resets: 1 });
const { readyState, replaced } = await page.evaluate(() => ({ readyState: window.__starting.readyState, replaced: window.__starting.replaced }));
if (attempt % 5 === 0) expect(readyState).toBe(0);
// Starting when replaced: still on the page with its URL, never reset; settled: released at once.
expect(replaced).toEqual(readyState === 0 ? { connected: true, src: true, resets: 0 } : { connected: false, src: false, resets: 1 });
expect(await page.evaluate(() => window.__starting.node.__resets)).toBe(1);
// Its one reset came after it had metadata, never while it was starting.
expect(await page.evaluate(() => window.__starting.node.__resetReadyState)).toBeGreaterThanOrEqual(1);
// The new file's viewer opens after the old one closed, and the page still answers.
await expect(viewer(page).getByRole('img', { name: 'f02.png', exact: true })).toBeVisible({ timeout: 8000 });
await expect(viewer(page).locator('video')).toHaveCount(0);
if (attempt === 0) await saveCoverage(page, browserName);
} finally {
await page.close();
}
}
});
/** #483: holds the viewer's video in HAVE_NOTHING (its `loadedmetadata` never fires) so a hold can be driven by hand. */
async function stallViewerVideo(page) {
await page.addInitScript(() => {
const describe = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'readyState');
Object.defineProperty(HTMLMediaElement.prototype, 'readyState', { configurable: true, get() {
return this.closest?.('#viewer-dialog') && window.__stallViewer ? 0 : describe.get.call(this);
} });
window.addEventListener('loadedmetadata', event => {
if (window.__stallViewer && event.target.closest?.('#viewer-dialog')) event.stopImmediatePropagation();
}, true);
});
}
test('a held viewer closed by hand, or replaced twice, opens only the latest file\'s viewer at once (#483)', async ({ page }) => {
await stallViewerVideo(page);
await openInspect(page);
await page.evaluate(() => { window.__stallViewer = true; });
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(viewer(page).locator('video')).toBeAttached();
// Two selections while the viewer's video is (made to look) still starting: the viewer stays, untouched.
await chooser(page).setInputFiles(parityPng('f02.png', 'first.png'));
await chooser(page).setInputFiles(parityPng('f02.png', 'second.png'));
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
await expect(viewer(page).locator('video')).toBeAttached();
// Open file metadata pressed meanwhile (the page behind the viewer is inert, so by script): the latest request.
await action(page).evaluate(node => node.click());
// Closing it by hand ends the hold at once: the latest file's viewer opens, once, well before D62's 15 s.
await page.keyboard.press('Escape');
await expect(viewer(page).getByRole('img', { name: 'second.png', exact: true })).toBeVisible({ timeout: 5000 });
await expect(viewer(page).getByRole('img', { name: 'first.png', exact: true })).toHaveCount(0);
await expect(viewer(page).locator('video')).toHaveCount(0);
await expect(page.locator('dialog[open]')).toHaveCount(1);
});
test('a held viewer revokes its URL only once it is gone, drops a stale open, and ends on page leave (#483)', async ({ page }) => {
await stallViewerVideo(page);
await openInspect(page);
await trackReleases(page);
await page.evaluate(() => { window.__stallViewer = true; });
const videoUrl = () => page.evaluate(() => document.querySelector('#viewer-dialog video')?.getAttribute('src') ?? null);
const released = url => page.evaluate(url => window.__released.filter(item => item.url === url), url);
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(viewer(page).locator('video')).toBeAttached();
const first = await videoUrl();
await chooser(page).setInputFiles(parityPng('f02.png', 'held.png'));
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
// The session asked to revoke the video's URL; it waits while the held viewer still plays it.
expect(await released(first)).toEqual([]);
// An Open asked for during the hold, then an AVI (which never opens the viewer by itself): the open for the earlier
// file is stale and is dropped when the hold ends.
await action(page).evaluate(node => node.click());
const avi = { name: 'clip.avi', mimeType: 'video/x-msvideo', buffer: Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('AVI LIST'), Buffer.alloc(64)]) };
await chooser(page).setInputFiles(avi);
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
await page.keyboard.press('Escape');
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect.poll(() => released(first)).toEqual([{ url: first, live: 0 }]);
// Page leave while held: the viewer goes and the deferred URL is revoked with nothing holding it.
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(viewer(page).locator('video')).toBeAttached();
const second = await videoUrl();
await chooser(page).setInputFiles(parityPng('f02.png', 'leave.png'));
await expect(viewer(page).locator('video')).toBeAttached();
expect(await released(second)).toEqual([]);
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect.poll(() => released(second)).toEqual([{ url: second, live: 0 }]);
// Another route while held: the page's dispose ends the hold and revokes the URL with nothing holding it.
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(viewer(page).locator('video')).toBeAttached();
const third = await videoUrl();
await chooser(page).setInputFiles(parityPng('f02.png', 'route.png'));
await expect(viewer(page).locator('video')).toBeAttached();
await page.evaluate(() => { window.location.hash = '#/library'; });
await expect(main(page).getByRole('heading', { level: 1, name: 'Prompt library', exact: true })).toBeVisible();
await expect.poll(() => released(third)).toEqual([{ url: third, live: 0 }]);
});
test('leaving for another route while a video starts waits for it to settle', async ({ context, browserName }) => {
test.setTimeout(180000);
// A WebKit (GStreamer) video detached before its pipeline had started deadlocked the page (TESTING.md D62); a
+16 -7
View File
@@ -37,6 +37,15 @@ test.afterEach(async ({ page, request, browserName }) => {
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
});
async function openSettled(page, url) {
// The page reads its selected file's details as soon as it lists. A source change before that read is answered
// makes the page find the file again on its own, so the test's old-numbering locators are gone before they are
// used (#484): every test changes sources only after the read was answered.
const inspected = page.waitForResponse(item => new URL(item.url()).pathname.startsWith('/prompt_manager/images/inspect/') &&
item.request().method() === 'GET');
await page.goto(url);
expect((await inspected).status()).toBe(200);
}
async function openTag(page, path = FIRST, root = 0) {
const observed = answered(page, '/image-annotations/observe');
await tagButton(page, path, root).click();
@@ -53,7 +62,7 @@ async function savedTags(request, path, root) {
}
test('renumbered sources under an open Tag dialog: it finds the file again, keeps the draft and Generate works', async ({ page, request }) => {
await page.goto(images);
await openSettled(page, images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
expect((await openTag(page)).status()).toBe(200);
await manual(page).fill('kept_draft');
@@ -91,7 +100,7 @@ test('renumbered sources under an open Tag dialog: it finds the file again, keep
});
test('the inspector finds a renumbered file again; checked files from the old numbering are cleared', async ({ page, request }) => {
await page.goto(images);
await openSettled(page, images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await card(page, FIRST).getByRole('checkbox', { name: /^Select / }).check();
await expect(page.locator('.bulkbar')).toBeVisible();
@@ -113,7 +122,7 @@ test('the inspector finds a renumbered file again; checked files from the old nu
});
test('a scope that cannot be mapped: Generate and Save are visibly disabled, the reason and Refresh sit beside them', async ({ page }) => {
await page.goto(images);
await openSettled(page, images);
await expect(card(page, FIRST)).toBeVisible();
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
const rescoped = answered(page, '/images/rescope');
@@ -157,7 +166,7 @@ test('a scope that cannot be mapped: Generate and Save are visibly disabled, the
});
test('an unmappable inspector read keeps its visible Refresh, and Refresh recovers the panel', async ({ page }) => {
await page.goto(images);
await openSettled(page, images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
const rescoped = answered(page, '/images/rescope');
@@ -173,7 +182,7 @@ test('an unmappable inspector read keeps its visible Refresh, and Refresh recove
test('a Load more cursor the server cannot verify offers Refresh instead of a dead Retry', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 60 } })).toBeOK();
await page.goto(images);
await openSettled(page, images);
await expect(cards(page).first()).toBeVisible();
await expect(page.locator('#gallery-continuation')).toContainText('more load as you scroll');
await expect(await request.post('/__test__/scope-key/rotate')).toBeOK();
@@ -191,7 +200,7 @@ test('a Load more cursor the server cannot verify offers Refresh instead of a de
});
test('a viewer whose media source was renumbered closes onto the file, kept selected', async ({ page, request }) => {
await page.goto(images);
await openSettled(page, images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const rescoped = answered(page, '/images/rescope');
@@ -217,7 +226,7 @@ test('a collection page finds a renumbered member again and stays on its members
await expect(added).toBeOK();
const collections = (await (await request.get('/prompt_manager/collections')).json()).collections;
const { id } = collections.find(item => item.name === name);
await page.goto(`/prompt_manager/workspace#/collection/${id}`);
await openSettled(page, `/prompt_manager/workspace#/collection/${id}`);
await expect(cards(page)).toHaveCount(1);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
+44 -2
View File
@@ -28,6 +28,10 @@ stack shows which). A dump after the summary is a hang at interpreter exit
A limit is a measurement, not a dial: change it only with new measurements
written next to it (TESTING.md "Time limits and hangs").
PM_TEST_SLOWEST=N (opt-in, #487) lists the N slowest tests after the summary.
A test's time runs from its ``start:`` line to the next one, so it includes
the class and module fixtures in between, like the hang limit.
"""
import argparse
@@ -36,6 +40,7 @@ import os
import shutil
import sys
import tempfile
import time
import unittest
from pathlib import Path
@@ -63,23 +68,57 @@ def read_limit(name, default):
return int(text)
def make_result_class(stream, per_test_limit, shutdown_limit):
class TestClock:
"""Seconds from each test's start to the next start (or the run's end)."""
def __init__(self, clock=time.perf_counter):
self._clock = clock
self._current = None
self._started = 0.0
self.durations = []
def start(self, test_id):
self.stop()
self._current = test_id
self._started = self._clock()
def stop(self):
if self._current is not None:
self.durations.append((self._clock() - self._started, self._current))
self._current = None
def report(self, count):
"""Return the lines that list the ``count`` slowest tests."""
slowest = sorted(self.durations, key=lambda item: item[0], reverse=True)
lines = [f"slowest {min(count, len(slowest))} of {len(slowest)} tests:"]
lines += [f"{seconds:8.2f}s {test_id}" for seconds, test_id in slowest[:count]]
return lines
def make_result_class(stream, per_test_limit, shutdown_limit, slowest=0):
"""Build a TextTestResult that names each test and re-arms the watchdog."""
def arm(seconds):
faulthandler.dump_traceback_later(seconds, exit=True, file=stream)
clock = TestClock()
class WatchdogResult(unittest.TextTestResult):
def startTest(self, test):
# At default verbosity the progress dots share a line; start ours.
prefix = "\n" if self.dots else ""
stream.write(f"{prefix}start: {test.id()}\n")
stream.flush()
clock.start(test.id())
super().startTest(test)
arm(per_test_limit)
def stopTestRun(self):
clock.stop()
super().stopTestRun()
if slowest:
stream.write("\n".join(["", *clock.report(slowest), ""]))
stream.flush()
arm(shutdown_limit)
return WatchdogResult
@@ -138,6 +177,7 @@ def main(argv=None):
try:
per_test_limit = read_limit("PM_TEST_HANG_LIMIT", DEFAULT_PER_TEST_LIMIT)
shutdown_limit = read_limit("PM_TEST_SHUTDOWN_LIMIT", DEFAULT_SHUTDOWN_LIMIT)
slowest = read_limit("PM_TEST_SLOWEST", 0)
except LimitError as error:
stream.write(f"{error}\n")
return USAGE_EXIT
@@ -152,7 +192,9 @@ def main(argv=None):
verbosity=args.verbosity,
# As unittest.main does: show warnings unless -W/PYTHONWARNINGS set them.
warnings=None if sys.warnoptions else "default",
resultclass=make_result_class(stream, per_test_limit, shutdown_limit),
resultclass=make_result_class(
stream, per_test_limit, shutdown_limit, slowest
),
)
result = runner.run(suite)
finally:
-126
View File
@@ -684,132 +684,6 @@ class TestStaticRoutes(CoreRoutesTestCase):
self.assertEqual(resp.status, 403)
class TestRevalidatedCaching(CoreRoutesTestCase):
"""Every served page and static file is revalidated on reload (#478).
``Cache-Control: no-cache`` makes the browser ask before it reuses a copy,
and the ETag lets an unchanged file answer 304 without a body, so an update
reaches the browser on a normal reload and an unchanged reload stays cheap.
"""
STATIC_PATHS = (
"/prompt_manager/js/workspace/index.mjs",
"/prompt_manager/js/admin.js",
"/prompt_manager/js/inspection-semantics.json",
"/prompt_manager/lib/viewerjs/viewer.min.css",
)
async def _assert_revalidated(self, path):
resp = await self.client.request("GET", path)
self.assertEqual(resp.status, 200, path)
self.assertEqual(resp.headers.get("Cache-Control"), "no-cache", path)
etag = resp.headers.get("ETag")
self.assertTrue(etag, path)
body = await resp.read()
self.assertTrue(body, path)
again = await self.client.request("GET", path, headers={"If-None-Match": etag})
self.assertEqual(again.status, 304, path)
self.assertEqual(again.headers.get("ETag"), etag, path)
self.assertEqual(again.headers.get("Cache-Control"), "no-cache", path)
self.assertEqual(await again.read(), b"", path)
stale = await self.client.request(
"GET", path, headers={"If-None-Match": 'W/"stale"'}
)
self.assertEqual(stale.status, 200, path)
self.assertEqual(await stale.read(), body, path)
return etag
async def test_static_files_are_revalidated(self):
for path in self.STATIC_PATHS:
await self._assert_revalidated(path)
async def test_pages_are_revalidated(self):
for path in UI_PATHS:
await self._assert_revalidated(path)
async def test_etag_matches_in_a_list_and_as_wildcard(self):
path = "/prompt_manager/js/workspace/index.mjs"
etag = (await self.client.request("GET", path)).headers["ETag"]
for header in (f'W/"other", {etag}', etag.removeprefix("W/"), "*"):
resp = await self.client.request(
"GET", path, headers={"If-None-Match": header}
)
self.assertEqual(resp.status, 304, header)
async def test_changed_file_gets_a_new_etag_and_its_new_body(self):
web_dir = self._fake_root()
module = web_dir / "js" / "m.mjs"
module.write_text("export const v = 1;")
path = "/prompt_manager/js/m.mjs"
first = await self.client.request("GET", path)
etag = first.headers["ETag"]
# Same size and, on coarse clocks, the same mtime: only the bytes differ.
module.write_text("export const v = 2;")
resp = await self.client.request("GET", path, headers={"If-None-Match": etag})
self.assertEqual(resp.status, 200)
self.assertNotEqual(resp.headers["ETag"], etag)
self.assertEqual(await resp.text(), "export const v = 2;")
async def test_gzipped_and_plain_bodies_share_a_weak_etag(self):
"""The gzip middleware changes the bytes, so the tag must be weak."""
resp = await self.client.request("GET", "/prompt_manager/js/admin.js")
self.assertTrue(resp.headers["ETag"].startswith('W/"'))
async def test_errors_carry_no_validator(self):
resp = await self.client.request("GET", "/prompt_manager/js/nope.js")
self.assertEqual(resp.status, 404)
self.assertNotIn("ETag", resp.headers)
@web.middleware
async def _comfyui_cache_control(request, handler):
"""ComfyUI's own middleware (middleware/cache_middleware.py), JS/CSS branch.
It only fills a missing Cache-Control, and ``.mjs`` is not ``.js``: that is
why the workspace modules went out with no header at all before #478.
"""
response = await handler(request)
if request.path.endswith(".js") or request.path.endswith(".css"):
response.headers.setdefault("Cache-Control", "no-store")
return response
class TestRevalidatedCachingBehindMiddlewares(CoreRoutesTestCase):
"""The host's middlewares keep the header and the 304 intact."""
async def get_application(self):
app = await super().get_application()
app.middlewares.append(_comfyui_cache_control)
app.middlewares.append(api_module._gzip_middleware)
return app
async def test_gzipped_files_keep_no_cache_and_answer_304(self):
for path in (
"/prompt_manager/js/workspace/index.mjs",
"/prompt_manager/js/admin.js",
"/prompt_manager/lib/viewerjs/viewer.min.css",
"/prompt_manager/workspace",
):
resp = await self.client.request(
"GET", path, headers={"Accept-Encoding": "gzip"}
)
self.assertEqual(resp.status, 200, path)
self.assertEqual(resp.headers.get("Content-Encoding"), "gzip", path)
self.assertEqual(resp.headers["Cache-Control"], "no-cache", path)
again = await self.client.request(
"GET",
path,
headers={
"Accept-Encoding": "gzip",
"If-None-Match": resp.headers["ETag"],
},
)
self.assertEqual(again.status, 304, path)
self.assertEqual(again.headers["Cache-Control"], "no-cache", path)
# ── instance helpers ──────────────────────────────────────────────────
+3 -1
View File
@@ -45,7 +45,9 @@ SEEDED_PATHS = (
"/srv/someone/renders",
"C:\\Users\\someone\\Documents\\renders",
"D:/Users/someone/prompts.db",
"\\\\nas\\share\\someone\\renders",
# A share on this machine: on Windows the report resolves every source,
# and an unknown server name made that wait about 2.9 s (#487).
"\\\\localhost\\pm-no-such-share\\someone\\renders",
)
_WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:[\\/]")
TOP_KEYS = {
+18 -2
View File
@@ -25,6 +25,7 @@ try:
except ImportError:
from tests.test_api_images import ImageAPITestCase
from database import restore_stage
from database.operations import PromptDatabase
from py.api.library import _encode, _scope
from tests.test_execution_context_database import bundle, snapshot
@@ -43,6 +44,21 @@ OTHER_SESSION = "aaaaaaaa-3333-4333-8333-333333333333"
MISSING_RUN = "bbbbbbbb-4444-4444-8444-444444444444"
@contextmanager
def no_wal_truncate_wait():
"""Restore with the restore notification held back, as these tests need.
The read under test keeps its snapshot open across the restore, so the
publish's WAL truncate can never succeed; without the short wait it would
sit out the full CHECKPOINT_BUSY_MS (30 s) each time (#487).
"""
with (
patch("database.execution_history.notify_history_restore"),
patch.object(restore_stage, "CHECKPOINT_BUSY_MS", 50),
):
yield
class HistoryAPITests(ImageAPITestCase):
async def tearDownAsync(self):
if hasattr(self.api, "close_history_reads"):
@@ -346,7 +362,7 @@ class HistoryAPITests(ImageAPITestCase):
def during_snapshot(*args, **kwargs):
result = original(*args, **kwargs)
with patch("database.execution_history.notify_history_restore"):
with no_wal_truncate_wait():
writer.model.restore_from_file(source)
return result
@@ -890,7 +906,7 @@ class HistoryAPITests(ImageAPITestCase):
def during_snapshot(*args, **kwargs):
result = original(*args, **kwargs)
with patch("database.execution_history.notify_history_restore"):
with no_wal_truncate_wait():
writer.model.restore_from_file(source)
return result
+7 -1
View File
@@ -7,6 +7,7 @@ from contextlib import closing
from pathlib import Path
from unittest.mock import patch
from database import restore_stage
from database.execution_history import HistoryEpochChanged, notify_history_restore
from tests import test_execution_history_database as history_tests
@@ -41,7 +42,12 @@ class HistoryReadFenceTests(unittest.TestCase):
generation = fence.begin()
with reader.recorded_history_snapshot() as epoch:
original = reader.get_history_run(start.run_id)
with patch("database.execution_history.notify_history_restore"):
# The open snapshot keeps the WAL from being truncated; do not
# sit out the full 30 s checkpoint wait for it (#487).
with (
patch("database.execution_history.notify_history_restore"),
patch.object(restore_stage, "CHECKPOINT_BUSY_MS", 50),
):
writer.model.restore_from_file(source)
self.assertEqual(reader.get_execution_epoch(), epoch)
self.assertEqual(original["run"]["storage_epoch"], epoch)
+21 -5
View File
@@ -14,6 +14,7 @@ import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -753,6 +754,17 @@ class LocalMediaTests(LayoutTestCase):
self.assertEqual(layout_module.VIDEO_SUFFIXES, frozenset(VIDEO_EXTENSIONS))
# Windows path rules without the filesystem. On Windows ntpath.realpath asks
# the network for \\server\share and waits seconds for a host that does not
# exist (#487); elsewhere ntpath.realpath already is ntpath.abspath.
LEXICAL_NT = SimpleNamespace(
normcase=ntpath.normcase,
realpath=ntpath.abspath,
dirname=ntpath.dirname,
sep=ntpath.sep,
)
class FolderGuardTests(unittest.TestCase):
"""Portable path-flavor controls (TESTING.md Windows portability preflight)."""
@@ -762,7 +774,7 @@ class FolderGuardTests(unittest.TestCase):
def guard_nt(self, path, home="C:\\Users\\Vito"):
return layout_module._guard_folder(
path, home, pathmod=ntpath, isdir=lambda _p: True
path, home, pathmod=LEXICAL_NT, isdir=lambda _p: True
)
def test_filesystem_roots_and_home_are_rejected(self):
@@ -832,8 +844,12 @@ class FolderGuardTests(unittest.TestCase):
def test_missing_folders_add_nothing(self):
self.assertIsNone(layout_module._guard_folder(str(self.tmp / "x"), "/home"))
# A missing share on this machine: an unknown server name made
# Windows wait about 2.8 s for the network (#487).
self.assertIsNone(
layout_module._guard_folder("\\\\server\\share\\loras", str(self.tmp))
layout_module._guard_folder(
"\\\\localhost\\pm-no-such-share\\loras", str(self.tmp)
)
)
self.assertEqual(
layout_module._guard_folder(str(self.tmp), "/nonexistent-home"),
@@ -843,17 +859,17 @@ class FolderGuardTests(unittest.TestCase):
def test_windows_model_path_matches_a_lower_case_root(self):
self.assertTrue(
layout_module._inside(
"C:/Models/Loras/A.safetensors", ("c:\\models\\loras",), ntpath
"C:/Models/Loras/A.safetensors", ("c:\\models\\loras",), LEXICAL_NT
)
)
self.assertFalse(
layout_module._inside(
"C:/Models/LorasX/A.safetensors", ("c:\\models\\loras",), ntpath
"C:/Models/LorasX/A.safetensors", ("c:\\models\\loras",), LEXICAL_NT
)
)
self.assertFalse(
layout_module._inside(
"\\\\other\\share\\A.safetensors", ("c:\\models\\loras",), ntpath
"\\\\other\\share\\A.safetensors", ("c:\\models\\loras",), LEXICAL_NT
)
)
+12 -2
View File
@@ -9,6 +9,7 @@ import email
import json
import os
import shutil
import ssl
import sys
import tempfile
import threading
@@ -669,6 +670,15 @@ class TestStaleDownloadSweep(unittest.TestCase):
self.assertTrue(old.exists())
def _unused_tls_context():
"""A TLS context for the fake handlers, which never open a connection.
HTTPSHandler() builds a default context, and on Windows that reads the
system certificate store: about 0.14 s per handler (#487).
"""
return ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
class _FakeHTTPSHandler(urllib.request.HTTPSHandler):
"""Serves canned responses through a real urllib opener, recording requests.
@@ -677,7 +687,7 @@ class _FakeHTTPSHandler(urllib.request.HTTPSHandler):
"""
def __init__(self, redirect_to):
super().__init__()
super().__init__(context=_unused_tls_context())
self.redirect_to = redirect_to
self.requests = []
@@ -745,7 +755,7 @@ class _ChainHTTPSHandler(urllib.request.HTTPSHandler):
urllib opener, recording every request; an unknown URL answers 404."""
def __init__(self, routes):
super().__init__()
super().__init__(context=_unused_tls_context())
self.routes = routes
self.requests = []
+53 -2
View File
@@ -18,11 +18,13 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SUBPROCESS_TIMEOUT = 60
WATCHDOG_ENV = ("PM_TEST_HANG_LIMIT", "PM_TEST_SHUTDOWN_LIMIT")
# Integer settings the runner reads; a bad value is a usage error.
RUNNER_ENV = (*WATCHDOG_ENV, "PM_TEST_SLOWEST")
def run_suite(folder, extra_env=None, *args, verbosity=("-v",)):
"""Run the runner on ``folder`` and return the completed process."""
env = {k: v for k, v in os.environ.items() if k not in WATCHDOG_ENV}
env = {k: v for k, v in os.environ.items() if k not in RUNNER_ENV}
# Windows writes a piped stderr in the ANSI codepage; ask for the UTF-8
# this helper decodes, so non-ASCII values and paths survive.
env["PYTHONIOENCODING"] = "utf-8"
@@ -269,8 +271,57 @@ class RunSuiteTests(unittest.TestCase):
self.assertIn("ok", result.stderr, detail)
self.assertIn("Timeout (", result.stderr, detail)
def test_slowest_tests_are_listed_when_asked(self):
self.write(
"test_probe_slowest.py",
"""
import time
import unittest
class Probe(unittest.TestCase):
def test_fast(self):
pass
def test_slow(self):
time.sleep(0.3)
def test_slower(self):
time.sleep(0.6)
""",
)
result = run_suite(self.folder, {"PM_TEST_SLOWEST": "2"})
self.assertEqual(result.returncode, 0, result.stderr)
report = result.stderr.split("slowest 2 of 3 tests:\n", 1)
self.assertEqual(len(report), 2, result.stderr)
lines = [line.split() for line in report[1].strip().splitlines()[:2]]
self.assertEqual(
[line[1] for line in lines],
[
"test_probe_slowest.Probe.test_slower",
"test_probe_slowest.Probe.test_slow",
],
result.stderr,
)
self.assertGreaterEqual(float(lines[0][0].rstrip("s")), 0.6)
self.assertNotIn("test_fast", report[1])
def test_slowest_tests_are_not_listed_by_default(self):
self.write(
"test_probe_quiet.py",
"""
import unittest
class Probe(unittest.TestCase):
def test_ok(self):
pass
""",
)
result = run_suite(self.folder)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("slowest", result.stderr)
def test_invalid_limit_is_a_usage_error(self):
for name in WATCHDOG_ENV:
for name in RUNNER_ENV:
for value in ("abc", "0", "-3", "1.5", "\u00b2"):
with self.subTest(name=name, value=value):
result = run_suite(self.folder, {name: value})
+30 -1
View File
@@ -137,7 +137,13 @@ function mountWorkspace() {
onSave: name => savedViews.create(page, name, state) });
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
const countRequests = new Map();
// #494: a page that has begun to leave (beforeunload) starts no count read: WebKit fails one with "Fetch API cannot
// load … due to access control checks." (a page error). Counts asked for meanwhile wait; a leave that is called off
// (the page is still here) reads them on its next input, as the legacy admin page does (#290).
let leaving = false;
const deferredCounts = new Set();
function refreshCounts(ids) {
if (leaving) { for (const id of ids) deferredCounts.add(id); return; }
for (const id of ids) {
countRequests.get(id)?.abort();
const request = new AbortController();
@@ -167,9 +173,29 @@ function mountWorkspace() {
let favoriteCountTimer = 0;
scope.cleanup(onFavoriteChange(() => {
clearTimeout(favoriteCountTimer);
favoriteCountTimer = setTimeout(() => refreshCounts(FAVORITE_WRITE_COUNTS), FAVORITE_COUNT_DELAY_MS);
favoriteCountTimer = setTimeout(() => { favoriteCountTimer = 0; refreshCounts(FAVORITE_WRITE_COUNTS); }, FAVORITE_COUNT_DELAY_MS);
}));
scope.cleanup(() => clearTimeout(favoriteCountTimer));
// Any beforeunload: a page that stays (Stop, a download, a "stay" prompt) revives on its next input or when shown.
scope.listen(window, 'beforeunload', () => {
leaving = true;
if (favoriteCountTimer) { clearTimeout(favoriteCountTimer); favoriteCountTimer = 0; for (const id of FAVORITE_WRITE_COUNTS) deferredCounts.add(id); }
for (const [id, request] of countRequests) { request.abort(); deferredCounts.add(id); }
countRequests.clear();
});
const revive = () => {
if (!leaving) return;
leaving = false;
const ids = [...deferredCounts]; deferredCounts.clear();
if (ids.length) refreshCounts(ids);
if (restorePending) { restorePending = false; restoreReload.request(); }
};
// Capture, as the admin page does, so no handler that stops an input keeps the page from reviving.
for (const type of ['pointerdown', 'keydown']) {
window.addEventListener(type, revive, true);
scope.cleanup(() => window.removeEventListener(type, revive, true));
}
scope.listen(document, 'visibilitychange', () => { if (document.visibilityState === 'visible') revive(); });
// A metadata scan that ends may have added items to Needs review: re-read its count once per ended scan.
const runningScans = new Set();
scope.cleanup(activity.subscribe(() => {
@@ -186,7 +212,10 @@ function mountWorkspace() {
// again, and the open page is shown again from the restored data; the Activity job view the restore opened stays.
// #415: an open review closes (its plan no longer describes storage); while any other dialog is open, all of it
// waits until the last one has closed, so the dialog keeps what was typed.
// #494: a restore that ends while the page leaves reloads nothing then; a page that stays reloads on revive.
let restorePending = false;
const restoreReload = createRestoreReload(() => {
if (leaving) { restorePending = true; return; }
forgetImagesVisit();
forgetLibraryVisit();
refreshCounts(NAV_COUNT_ROUTES);
+49 -8
View File
@@ -115,9 +115,39 @@ export function mountInspect(main, { parentScope, api, sender }) {
retiring.delete(url);
entry.waiting.abort(); clearTimeout(entry.timer);
releaseMedia(entry.preview);
if (entry.revoke) URL.revokeObjectURL(url);
if (entry.revoke) revokeObjectURL(url);
entry.done();
}
// #483 (D62): the viewer's own video, when a new selection closes the viewer while that video is still starting.
// The viewer stays open until the video reports metadata or an error (or D62's 15 s), then closes; its URL, which
// the session asked to revoke, goes after that. A viewer asked for meanwhile opens then: only the latest request,
// and only if its selection is still current (an automatic open waits for its own inline video again).
let held = null;
function closeViewer() {
const node = document.querySelector('#viewer-dialog video');
if (!node || !isStarting(node)) { viewer.close(); return; }
const waiting = new AbortController();
held = { url: node.getAttribute('src'), revoke: false, waiting, pending: null, timer: setTimeout(finishHeld, STARTING_VIDEO_MS) };
for (const type of ['loadedmetadata', 'error']) node.addEventListener(type, finishHeld, { once: true, signal: waiting.signal });
}
function endHeld() {
const entry = held;
if (!entry) return null;
held = null;
entry.waiting.abort(); clearTimeout(entry.timer);
return entry;
}
function finishHeld() {
const entry = endHeld();
if (!entry) return;
viewer.close();
// Through the session's hook, so an inline video still starting on the same URL keeps it until it settles.
if (entry.revoke) revokeObjectURL(entry.url);
const request = entry.pending;
if (!request || scope.signal.aborted || session.entry !== request.entry || session.state.phase !== 'ready') return;
if (request.auto) autoOpen(request.entry);
else openViewer(request.trigger);
}
function retire(preview, url) {
const waiting = new AbortController();
const entry = { preview, waiting, revoke: false, timer: setTimeout(() => finishRetiring(url), STARTING_VIDEO_MS) };
@@ -129,6 +159,7 @@ export function mountInspect(main, { parentScope, api, sender }) {
}
const finishAllRetiring = () => { for (const url of [...retiring.keys()]) finishRetiring(url); };
const revokeObjectURL = url => {
if (held?.url === url) { held.revoke = true; return; }
const entry = retiring.get(url);
if (entry) entry.revoke = true;
else URL.revokeObjectURL(url);
@@ -136,7 +167,8 @@ export function mountInspect(main, { parentScope, api, sender }) {
const session = createLocalSession({ parentScope: sessionScope, pageTarget: null, revokeObjectURL });
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender,
adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); },
onClose: () => { viewerKey = null; } });
// A held viewer closed by hand (Escape, Close) ends its hold at once: its video is gone.
onClose: () => { viewerKey = null; if (held) finishHeld(); } });
let viewerKey = null, media = null, mediaUrl = null, statusLine = null, rejectionLine = null, rejectionTimer = 0, announced = null, chosen = null;
function statusElement(message) {
@@ -209,7 +241,7 @@ export function mountInspect(main, { parentScope, api, sender }) {
scope.cleanup(session.subscribe(state => {
// D155: a new selection can start while the viewer shows the previous one; it closes first, so no element holds
// the old URL when the session revokes it (the new file's viewer opens once it is ready).
if (viewerKey !== null && (state.phase !== 'ready' || session.entry?.key !== viewerKey)) { viewer.close(); viewerKey = null; }
if (viewerKey !== null && (state.phase !== 'ready' || session.entry?.key !== viewerKey)) { closeViewer(); viewerKey = null; }
showMedia(state);
showStatus(state.message);
const entry = session.entry;
@@ -220,17 +252,14 @@ export function mountInspect(main, { parentScope, api, sender }) {
// while this selection is still the current one and no other dialog is open (the viewer is replaced). D62:
// a video opens it once the inline preview has settled, so a replacement while it starts never tears down a
// second starting video in the viewer (that deadlocked WebKit).
if (AUTO_OPEN.has(state.message)) whenSettled(media, () => {
if (scope.signal.aborted || session.entry !== entry || session.state.phase !== 'ready' || document.querySelector('dialog[open]:not(#viewer-dialog)')) return;
openViewer(input);
});
if (AUTO_OPEN.has(state.message)) autoOpen(entry);
// An unreadable or failed preview says so in its alert; the toast never claims metadata is available.
else if (TOASTABLE.has(state.message)) showWorkspaceToast(INSPECT_COPY.toast);
}
}));
// A replacement or page leave revokes the URL after this subscriber dropped the media; a dispose closes the
// viewer (on abort), drops the media, then ends the session.
scope.cleanup(() => viewer.dispose());
scope.cleanup(() => { const entry = endHeld(); viewer.dispose(); if (entry?.revoke) revokeObjectURL(entry.url); });
scope.cleanup(() => { releaseMedia(media); media = null; mediaUrl = null; });
scope.cleanup(finishAllRetiring);
scope.cleanup(() => sessionScope.dispose());
@@ -239,9 +268,11 @@ export function mountInspect(main, { parentScope, api, sender }) {
// cleared, so a page restored from the back-forward cache never names a file beside the empty selection.
function leave() {
clearRejection();
const entry = endHeld();
viewer.close();
releaseMedia(media); media = null; mediaUrl = null;
finishAllRetiring();
if (entry?.revoke) revokeObjectURL(entry.url);
session.clear();
chosen = null; input.value = '';
}
@@ -287,10 +318,20 @@ export function mountInspect(main, { parentScope, api, sender }) {
for (const type of ['loadedmetadata', 'error']) preview.node.addEventListener(type, done, { once: true, signal: settled.signal });
scope.cleanup(done);
}
/** D155: the viewer opens on `entry` once its inline preview has settled, while it is still the current selection. */
function autoOpen(entry) {
whenSettled(media, () => {
if (scope.signal.aborted || session.entry !== entry || session.state.phase !== 'ready' || document.querySelector('dialog[open]:not(#viewer-dialog)')) return;
// #483: the previous viewer is still closing around its starting video; this one opens once it has.
if (held) { held.pending = { entry, auto: true }; return; }
openViewer(input);
});
}
/** The viewer on the current selection, details showing (D155 amends D97); focus returns to `trigger` on close. */
function openViewer(trigger) {
const entry = session.entry;
if (!entry || session.state.phase !== 'ready') return;
if (held) { held.pending = { entry, trigger, auto: false }; return; }
viewer.open({ items: [entry], selectedKey: entry.key, trigger, details: 'shown' });
viewerKey = entry.key;
}