diff --git a/tests/browser/server.py b/tests/browser/server.py index 2fe0602..89ac601 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -582,6 +582,8 @@ def create_app(root: Path) -> web.Application: api.db.close_all() seed() + # Open Needs review change streams end first, as on the real host. + app.on_shutdown.append(api.close_review_events) app.on_cleanup.append(cleanup) app.add_routes(routes) return app diff --git a/tests/browser/workspace-review.spec.mjs b/tests/browser/workspace-review.spec.mjs index b5e4394..fc0382d 100644 --- a/tests/browser/workspace-review.spec.mjs +++ b/tests/browser/workspace-review.spec.mjs @@ -1,6 +1,7 @@ -// Needs review (#249 PR F, plan Task F1, Mock inventory M4): the SAVED VIEWS row and its count, the Metadata review -// page over the real review routes, and Activity's Review metadata. Items are recorded by the real recorder -// (tests/browser/workspace_review_fixture.py), as the live monitor records a new output. +// Needs review (#249 PR F, plan Tasks F1 and F2, Mock inventory M4): the SAVED VIEWS row and its count, the Metadata +// review page over the real review routes, and Activity's Review metadata. Items are recorded by the real recorder +// (tests/browser/workspace_review_fixture.py), as the live monitor records a new output; the conflict item is a real +// monitor fallback link. An open page re-reads its queue from the review change stream (F2). import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; import { test, expect } from './fixtures.mjs'; @@ -17,14 +18,18 @@ const EMPTY = 'Ambiguous images are added when a metadata scan or a new generati const OPTIONS = ['Sampler 11 · Golden dunes at dusk / Soft negative, blur', 'Sampler 13 · Harbor lights at night / Soft negative, blur', 'Leave unresolved']; -async function seed(request, items = 3) { - const response = await request.post('/__test__/review/reset', { data: { items } }); +async function seed(request, items = 3, special = {}) { + const response = await request.post('/__test__/review/reset', { data: { items, ...special } }); await expect(response).toBeOK(); return response.json(); } async function pending(request) { return (await (await request.get('/prompt_manager/review/items?limit=1')).json()).total; } +const links = async request => (await (await request.get('/__test__/review/links')).json()).links; +const streams = async request => (await (await request.get('/__test__/review/streams')).json()); +const apply = page => panel(page).getByRole('button', { name: 'Apply association', exact: true }); +const warning = page => panel(page).locator('.warning-note'); /** The page with its first item's sampler paths loaded. */ async function open(page) { await page.goto(review); @@ -92,6 +97,61 @@ test('apply candidate links the pair and the item leaves the queue and the count await expect(page).toHaveURL(/#\/images$/); await expect(reviewRow(page)).toHaveText('Needs review1'); expect(await pending(request)).toBe(1); + // The first sampler path's pair is linked to that file, and only to it. + expect(await links(request)).toEqual([{ filename: 'review_0000.png', prompt: 'Golden dunes at dusk', + pair_id: expect.any(Number), positive: 'Golden dunes at dusk', negative: 'Soft negative, blur' }]); +}); + +test('conflict leaves links unchanged and explains', async ({ page, request }) => { + // A real monitor fallback: the new output is linked to the most recent prompt, then queued. + await seed(request, 0, { conflict: true }); + const before = await links(request); + expect(before).toEqual([{ filename: 'conflict.png', prompt: 'Misty forest path', pair_id: null, positive: null, + negative: null }]); + await open(page); + await expect(panel(page).locator('p.small')).toHaveText('conflict.png'); + await expect(context(page).locator('option')).toHaveText(OPTIONS); + const decided = page.waitForResponse(item => /\/decision$/.test(item.url())); + await apply(page).click(); + const answer = await decided; + expect(answer.status()).toBe(409); + expect((await answer.json()).code).toBe('association-conflict'); + await expect(warning(page)).toHaveText('This image is already linked to another prompt or negative, so this sampler path cannot be attached safely. The existing links were preserved.'); + await expect(page).toHaveURL(/#\/review$/); + await expect(context(page)).toBeEnabled(); + await expect(context(page)).toHaveValue(/^[0-9a-f]{64}$/); + expect(await links(request)).toEqual(before); + expect(await pending(request)).toBe(1); + await expect(reviewRow(page)).toHaveText('Needs review1'); + // The file can still be left unresolved, which links nothing either. + await context(page).selectOption({ label: 'Leave unresolved' }); + await apply(page).click(); + await expect(toast(page)).toHaveText('Left unresolved.'); + expect(await links(request)).toEqual(before); + expect(await pending(request)).toBe(0); +}); + +test('a limited item and an unavailable file can only be left unresolved', async ({ page, request }) => { + await seed(request, 0, { limited: true, unavailable: true }); + await open(page); + await expect(cards(page)).toHaveCount(2); + await expect(panel(page).locator('p.small')).toHaveText('limited.png'); + await expect(context(page).locator('option')).toHaveText(['Leave unresolved']); + await expect(warning(page)).toHaveText('This file has too many sampler paths to list. You can leave it unresolved.'); + // The unavailable file: no current root lists it, so its media and Tag are inert. + const unavailable = cards(page).nth(1); + await expect(unavailable.locator('.image-open')).toHaveAttribute('aria-disabled', 'true'); + await expect(unavailable.locator('.image-card-actions button').last()).toHaveAttribute('aria-disabled', 'true'); + await unavailable.getByRole('button', { name: 'Inspect unavailable.png', exact: true }).click(); + await expect(panel(page).locator('p.small')).toHaveText('unavailable.png'); + await expect(warning(page)).toHaveText('This file is not under a current image source, so it cannot be opened. You can leave it unresolved.'); + await expect(panel(page).getByRole('button', { name: 'Tag image', exact: true })).toHaveAttribute('aria-disabled', 'true'); + const observes = []; + page.on('request', item => { if (item.url().endsWith('/image-annotations/observe')) observes.push(item.url()); }); + await apply(page).click(); + await expect(toast(page)).toHaveText('Left unresolved.'); + expect(observes).toEqual([]); + expect(await pending(request)).toBe(1); }); test('leave unresolved removes the item from pending', async ({ page, request }) => { @@ -115,9 +175,10 @@ test('leave for later writes nothing and goes to Images', async ({ page, request expect(await pending(request)).toBe(2); }); -test('tag image from review opens the shared dialog with the scope note', async ({ page, request }) => { +test('tag image from review saves tags and the item remains pending', async ({ page, request }) => { await seed(request, 1); await open(page); + const before = await links(request); await panel(page).getByRole('button', { name: 'Tag image', exact: true }).click(); const dialog = page.getByRole('dialog', { name: 'Tag image', exact: true }); await expect(dialog).toBeVisible(); @@ -128,6 +189,12 @@ test('tag image from review opens the shared dialog with the scope note', async await expect(dialog).toHaveCount(0); expect(await pending(request)).toBe(1); await expect(page).toHaveURL(/#\/review$/); + // The item is untouched: still listed, still open, nothing linked. + await expect(cards(page)).toHaveCount(1); + await expect(context(page)).toBeEnabled(); + expect(await links(request)).toEqual(before); + const [item] = (await (await request.get('/prompt_manager/review/items')).json()).items; + expect([item.state, item.revision]).toEqual(['pending', 0]); }); test('an empty queue shows the empty notice only', async ({ page, request }) => { @@ -195,15 +262,117 @@ test('a failed queue read offers Retry', async ({ page, request }) => { await expect(main(page).locator('.notice h2')).toHaveText('1 image needs a decision'); }); -test('an association conflict keeps the selection and explains', async ({ page, request }) => { +test('keyboard focus labels and touch targets', async ({ page, request }) => { + await seed(request, 2); + await open(page); + const first = cards(page).first(); + for (const [name, role] of [['Open review_0000.png', 'button'], ['Inspect review_0000.png', 'button'], + ['Tag or auto-tag review_0000.png', 'button']]) await expect(first.getByRole(role, { name, exact: true })).toBeVisible(); + await expect(main(page).getByRole('list', { name: 'Review queue', exact: true })).toBeVisible(); + await expect(panel(page)).toBeVisible(); + // Keyboard: Info on the second card opens it, and the panel's controls take focus in their order. + const info = cards(page).nth(1).getByRole('button', { name: 'Inspect review_0001.png', exact: true }); + await info.focus(); + await page.keyboard.press('Enter'); + await expect(panel(page).locator('p.small')).toHaveText('review_0001.png'); + await expect(context(page)).toBeEnabled(); + await context(page).focus(); + await expect(context(page)).toBeFocused(); + const order = await panel(page).evaluate(node => [...node.querySelectorAll('button, select')] + .filter(control => control.offsetParent !== null).map(control => control.getAttribute('aria-label') || control.textContent.trim())); + expect(order[0]).toBe('Tag image'); + expect(order.slice(-2)).toEqual(['Leave for later', 'Apply association']); + // Targets: the panel's controls are 40px on a coarse pointer (the concept's coarse buttons) and 24px otherwise (the + // WCAG 2.2 minimum); the card's buttons keep the concept's compact size, never under 24px (its ≤700px rule). + const coarse = await page.evaluate(() => matchMedia('(pointer: coarse)').matches); + const sized = [ + [panel(page).getByRole('button', { name: 'Tag image', exact: true }), coarse ? 40 : 24], + [context(page), coarse ? 40 : 24], + [panel(page).getByRole('button', { name: 'Leave for later', exact: true }), coarse ? 40 : 24], + [apply(page), coarse ? 40 : 24], + [first.getByRole('button', { name: 'Inspect review_0000.png', exact: true }), 24], + [first.getByRole('button', { name: 'Tag or auto-tag review_0000.png', exact: true }), 24], + ]; + for (const [control, minimum] of sized) { + const box = await control.boundingBox(); + const label = await control.evaluate(node => node.getAttribute('aria-label') || node.textContent.trim().slice(0, 40)); + expect(box.height, label).toBeGreaterThanOrEqual(minimum); + expect(box.width, label).toBeGreaterThanOrEqual(24); + } + // Enter on Leave for later returns to Images. + await panel(page).getByRole('button', { name: 'Leave for later', exact: true }).focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/#\/images$/); +}); + +test('an open page shows items the monitor adds and keeps the open choice', async ({ page, request }) => { await seed(request, 1); await open(page); - await page.route(url => /\/decision$/.test(url.pathname), route => route.fulfill({ status: 409, contentType: 'application/json', - body: JSON.stringify({ success: false, code: 'association-conflict', error: 'This image is already linked to another prompt or negative, so this sampler path cannot be attached safely. The existing links were preserved.' }) }), - { times: 1 }); - await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click(); - await expect(panel(page).locator('.warning-note')).toHaveText(/already linked to another prompt/); - await expect(page).toHaveURL(/#\/review$/); + await expect.poll(async () => (await streams(request)).streams).toBe(1); + await context(page).selectOption({ label: OPTIONS[1] }); + await expect(reviewRow(page)).toHaveText('Needs review1'); + await expect(await request.post('/__test__/review/add', { data: { kind: 'monitor' } })).toBeOK(); + await expect(cards(page)).toHaveCount(2); + await expect(main(page).locator('.notice h2')).toHaveText('2 images need a decision'); + await expect(reviewRow(page)).toHaveText('Needs review2'); + await expect(cards(page).first()).toHaveClass(/selected/); + await expect(panel(page).locator('p.small')).toHaveText('review_0000.png'); + await expect(context(page).locator('option:checked')).toHaveText(OPTIONS[1]); + await expect(warning(page)).toBeHidden(); +}); + +test('an open page shows items a metadata scan adds', async ({ page, request }) => { + await seed(request, 1); + await expect(await request.post('/__test__/review/add', { data: { kind: 'file' } })).toBeOK(); + await open(page); + await expect.poll(async () => (await streams(request)).streams).toBe(1); + await expect(cards(page)).toHaveCount(1); + const started = await request.post('/prompt_manager/jobs/scan', { data: { scope: 'new' } }); + expect(started.status()).toBe(202); + await expect(cards(page)).toHaveCount(2); + await expect(cards(page).nth(1).getByRole('button', { name: 'Inspect added_0000.png', exact: true })).toBeVisible(); + await expect(reviewRow(page)).toHaveText('Needs review2'); +}); + +test('a decision in another tab leaves the open page and opens the next image', async ({ page, request, context: browserContext }) => { + await seed(request, 2); + await open(page); + await expect.poll(async () => (await streams(request)).streams).toBe(1); + const other = await browserContext.newPage(); + await open(other); + await context(other).selectOption({ label: 'Leave unresolved' }); + await apply(other).click(); + await expect(other).toHaveURL(/#\/images$/); + await other.close(); + await expect(cards(page)).toHaveCount(1); + await expect(panel(page).locator('p.small')).toHaveText('review_0001.png'); + await expect(warning(page)).toHaveText('review_0000.png was decided elsewhere, so the next image is open.'); + await expect(main(page).locator('.notice h2')).toHaveText('1 image needs a decision'); + await expect(reviewRow(page)).toHaveText('Needs review1'); await expect(context(page)).toBeEnabled(); - expect(await pending(request)).toBe(1); +}); + +test('teardown releases captures listeners and requests', async ({ page, request }) => { + await seed(request, 1); + await open(page); + await expect.poll(async () => (await streams(request)).streams).toBe(1); + const inFlight = new Set(); + page.on('request', item => { if (item.url().includes('/prompt_manager/review/')) inFlight.add(item); }); + page.on('requestfinished', item => inFlight.delete(item)); + page.on('requestfailed', item => inFlight.delete(item)); + // A decision whose answer never comes: leaving the page must still release its capture. + let held; + const sent = new Promise(resolve => { held = resolve; }); + await page.route(url => /\/decision$/.test(url.pathname), route => held(route)); + await apply(page).click(); + const route = await sent; + expect((await streams(request)).captures).toBe(1); + await page.evaluate(() => { window.location.hash = '#/tags'; }); + await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Tags & categories'); + await expect.poll(async () => (await streams(request)).captures).toBe(0); + await route.abort().catch(() => {}); + // The server drops a closed stream at its next write: a monitor addition is one. + await expect(await request.post('/__test__/review/add', { data: { kind: 'monitor' } })).toBeOK(); + await expect.poll(async () => (await streams(request)).streams).toBe(0); + await expect.poll(() => inFlight.size).toBe(0); }); diff --git a/tests/browser/workspace_review_fixture.py b/tests/browser/workspace_review_fixture.py index c17ca92..efce5c2 100644 --- a/tests/browser/workspace_review_fixture.py +++ b/tests/browser/workspace_review_fixture.py @@ -4,12 +4,32 @@ Each file is a PNG whose workflow has two samplers with their own positive encoders and one shared negative, so the resolver finds it ``ambiguous``; it is recorded through the real recorder (``record_review_file``), exactly as the live monitor records a new output. No path or SQL comes from a request. + +``POST /__test__/review/reset`` also takes the special items F2 needs, queued +after the plain ones in this order: + +- ``conflict``: a new output handled by the real monitor pipeline + (``ImageGenerationHandler.process_new_image``). Its positive encoders are + PromptManager nodes whose texts are not saved, so the monitor falls back to + the most recent prompt ("Misty forest path"), links the file to it and + records the item; applying a sampler path then conflicts with that link. +- ``limited``: an A1111 ``parameters`` block with two negative lines, which + the recorder stores ``limited`` (no candidates). +- ``unavailable``: a file recorded under the ``temp`` directory while it was a + gallery root; the root is then removed again, so no current root lists it. + +``POST /__test__/review/add {kind}`` adds one file while a page is open: +``monitor`` runs the monitor pipeline on a new ambiguous output (recorded), +``file`` only writes one (a metadata scan records it). ``GET +/__test__/review/links`` answers each review file's prompt and pair links, and +``GET /__test__/review/streams`` the open change streams and captures. """ from __future__ import annotations import json import os +import threading from pathlib import Path import shutil import time @@ -24,6 +44,9 @@ if TYPE_CHECKING: POSITIVES = ("Golden dunes at dusk", "Harbor lights at night") NEGATIVE = "Soft negative, blur" +# The monitor's fallback: the most recent saved prompt. +FALLBACK = "Misty forest path" +SPECIAL = ("conflict", "limited", "unavailable") # More than one queue page (40), so Load more has a second page. MAX_ITEMS = 45 _TABLES = ( @@ -45,14 +68,14 @@ _TABLES = ( ) -def _graph() -> dict: +def _graph(encoder_class: str = "CLIPTextEncode") -> dict: graph = { "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}, "2": {"class_type": "CLIPTextEncode", "inputs": {"text": NEGATIVE}}, } for index, text in enumerate(POSITIVES): encoder, sampler = str(10 + 2 * index), str(11 + 2 * index) - graph[encoder] = {"class_type": "CLIPTextEncode", "inputs": {"text": text}} + graph[encoder] = {"class_type": encoder_class, "inputs": {"text": text}} graph[sampler] = { "class_type": "KSampler", "inputs": { @@ -64,15 +87,85 @@ def _graph() -> dict: return graph -def _png(path: Path, colour: tuple[int, int, int]) -> None: +def _png( + path: Path, + colour: tuple[int, int, int], + *, + graph: dict | None = None, + parameters: str | None = None, +) -> None: info = PngInfo() - info.add_text("prompt", json.dumps(_graph())) + if parameters is not None: + info.add_text("parameters", parameters) + else: + info.add_text("prompt", json.dumps(graph or _graph())) path.parent.mkdir(parents=True, exist_ok=True) with Image.new("RGB", (96, 64), colour) as image: image.save(path, pnginfo=info) -def seed_review(api: PromptManagerAPI, root: Path, items: int) -> dict: +def _record(api: PromptManagerAPI, path: Path, expected: str) -> None: + outcome = api.record_review_file(str(path)) + if outcome != expected: + raise RuntimeError(f"{path.name} was {outcome}, not {expected}") + + +def _stamp(path: Path, age: float) -> None: + stamp = time.time() - age + os.utime(path, (stamp, stamp)) + + +class _Monitor: + """The real monitor pipeline over this server's database (one per server).""" + + def __init__(self, api: PromptManagerAPI) -> None: + from utils.image_monitor import ImageGenerationHandler + from utils.prompt_tracker import PromptTracker + + self.handler = ImageGenerationHandler(api.db, PromptTracker(api.db)) + self.handler.review_recorder = api.record_review_file + self.lock = threading.Lock() + + def new_output(self, path: Path) -> None: + """What the monitor does once a new output has settled.""" + with self.lock: + self.handler.process_new_image(str(path)) + + +def _monitored_output(api: PromptManagerAPI, monitor: _Monitor, path: Path) -> None: + from utils.hashing import generate_prompt_hash + + if api.db.get_prompt_by_hash(generate_prompt_hash(FALLBACK)) is None: + api.db.save_prompt(FALLBACK, prompt_hash=generate_prompt_hash(FALLBACK)) + _png(path, (150, 60, 40), graph=_graph("PromptManager")) + before = len(api.db.review_items_for(_locator(path))) + monitor.new_output(path) + if len(api.db.review_items_for(_locator(path))) != before + 1: + raise RuntimeError(f"the monitor did not record {path.name}") + + +def _locator(path: Path) -> str: + return os.path.normcase(os.path.realpath(path)) + + +def _unavailable(api: PromptManagerAPI, root: Path, path: Path) -> None: + from py.config import GalleryConfig + + roots = list(GalleryConfig.MONITORING_DIRECTORIES) + GalleryConfig.MONITORING_DIRECTORIES = [*roots, str(root / "temp")] + try: + _record(api, path, "recorded") + finally: + GalleryConfig.MONITORING_DIRECTORIES = roots + + +def seed_review( + api: PromptManagerAPI, + root: Path, + items: int, + special: tuple = (), + monitor: _Monitor | None = None, +) -> dict: """Replace this server's disposable rows and files with ``items`` queued files.""" with api.db.model.get_connection() as conn: for table in _TABLES: @@ -81,36 +174,142 @@ def seed_review(api: PromptManagerAPI, root: Path, items: int) -> dict: for name in ("output", "input"): shutil.rmtree(root / name) (root / name).mkdir() + shutil.rmtree(root / "temp" / "review", ignore_errors=True) output = root / "output" - now = time.time() files = [] for number in range(items): relative = f"review/review_{number:04}.png" _png(output / relative, (40 + number * 4, 90, 140)) - stamp = now - 60 - number - os.utime(output / relative, (stamp, stamp)) - outcome = api.record_review_file(str(output / relative)) - if outcome != "recorded": - raise RuntimeError(f"{relative} was not recorded: {outcome}") + _stamp(output / relative, 60 + number) + _record(api, output / relative, "recorded") files.append({"path": relative, "filename": Path(relative).name}) + for kind in SPECIAL: + if kind not in special: + continue + relative = f"review/{kind}.png" + path = (root / "temp" if kind == "unavailable" else output) / relative + if kind == "conflict": + _monitored_output(api, monitor, path) + elif kind == "limited": + _png( + path, + (90, 90, 90), + parameters=( + f"{POSITIVES[0]}\nNegative prompt: blur\nNegative prompt: noise\n" + "Steps: 20, Sampler: Euler" + ), + ) + _record(api, path, "limited") + else: + _png(path, (20, 140, 90)) + _unavailable(api, root, path) + files.append({"path": relative, "filename": path.name, "kind": kind}) api.invalidate_gallery_cache() - return {"files": files, "positives": list(POSITIVES), "negative": NEGATIVE} + return { + "files": files, + "positives": list(POSITIVES), + "negative": NEGATIVE, + "fallback": FALLBACK, + } + + +def add_review_file( + api: PromptManagerAPI, root: Path, kind: str, monitor: _Monitor +) -> dict: + """One more ambiguous output: through the monitor, or only written.""" + output = root / "output" + number = ( + len(list((output / "added").glob("*.png"))) + if (output / "added").exists() + else 0 + ) + relative = f"added/added_{number:04}.png" + if kind == "monitor": + _monitored_output(api, monitor, output / relative) + else: + _png(output / relative, (200, 200, 40)) + api.invalidate_gallery_cache() + return {"path": relative, "filename": Path(relative).name} + + +_LINKS = ( + "SELECT gi.filename, p.text AS prompt, pp.id AS pair_id, pos.text AS positive, " + "neg.text AS negative FROM generated_images gi " + "JOIN prompts p ON p.id = gi.prompt_id " + "LEFT JOIN image_prompt_pairs ipp ON ipp.image_id = gi.id " + "LEFT JOIN prompt_pairs pp ON pp.id = ipp.pair_id " + "LEFT JOIN prompts pos ON pos.id = pp.positive_prompt_id " + "LEFT JOIN prompts neg ON neg.id = pp.negative_prompt_id " + "ORDER BY gi.filename, gi.id, pp.id" +) + + +def review_links(api: PromptManagerAPI) -> list: + """Every file's prompt link and pair links, as plain rows.""" + conn = api.db.model.get_connection() + return [dict(row) for row in conn.execute(_LINKS)] def register_review_commands( routes: web.RouteTableDef, api: PromptManagerAPI, root: Path ) -> None: - """``POST /__test__/review/reset {items}``: the fixed queue, 0 to MAX_ITEMS.""" + """Register the review fixture's commands; no path or SQL comes from a request.""" + monitors = [] + + def monitor() -> _Monitor: + if not monitors: + monitors.append(_Monitor(api)) + return monitors[0] @routes.post("/__test__/review/reset") async def reset(request: web.Request) -> web.Response: data = await request.json() if request.can_read_body else {} - if request.query or not isinstance(data, dict) or set(data) - {"items"}: + if ( + request.query + or not isinstance(data, dict) + or set(data) - {"items", *SPECIAL} + ): raise web.HTTPBadRequest() items = data.get("items", 3) if isinstance(items, bool) or not isinstance(items, int): raise web.HTTPBadRequest() if not 0 <= items <= MAX_ITEMS: raise web.HTTPBadRequest() - manifest = await api._run_in_executor(seed_review, api, root, items) + if any(not isinstance(data.get(kind, False), bool) for kind in SPECIAL): + raise web.HTTPBadRequest() + special = tuple(kind for kind in SPECIAL if data.get(kind)) + manifest = await api._run_in_executor( + seed_review, api, root, items, special, monitor() + ) return web.json_response({"success": True, **manifest}) + + @routes.post("/__test__/review/add") + async def add(request: web.Request) -> web.Response: + data = await request.json() if request.can_read_body else None + if request.query or not isinstance(data, dict) or set(data) != {"kind"}: + raise web.HTTPBadRequest() + if data["kind"] not in ("monitor", "file"): + raise web.HTTPBadRequest() + added = await api._run_in_executor( + add_review_file, api, root, data["kind"], monitor() + ) + return web.json_response({"success": True, **added}) + + @routes.get("/__test__/review/links") + async def links(request: web.Request) -> web.Response: + if request.query: + raise web.HTTPBadRequest() + return web.json_response( + {"links": await api._run_in_executor(review_links, api)} + ) + + @routes.get("/__test__/review/streams") + async def streams(request: web.Request) -> web.Response: + """Open review change streams and held captures (teardown checks).""" + if request.query: + raise web.HTTPBadRequest() + store = api._captures + captures = store.stats()["captures"] if store is not None else 0 + return web.json_response( + {"streams": api.review_stream_count(), "captures": captures} + )