Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows-pr2

This commit is contained in:
Vito Sansevero
2026-10-28 20:15:25 -07:00
89 changed files with 7062 additions and 329 deletions
+108
View File
@@ -0,0 +1,108 @@
import {test,expect} from './fixtures.mjs';
import {SIZES,THEMES,seedConceptAt,openConcept,openProduction,diffInventory,mutate} from './concept-inventory.mjs';
// #331 D138: the Images selection bar's "Add to collection" dialog against the concept's own (`bulk-collection` →
// simpleInput, app.js a1392–1398 and a1138), on the concept-shaped fixture, at 1600x1100 and 390x844 in both themes.
// Paired screenshots are artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.gallery-body > .image-grid > .media-card';
/**
* Runs in the page: the open "Add selected images to collection" dialog: its title, the body's tree, the field's
* label, input and suggestions, and the actions, with the concept's geometry and tokens.
*/
function measureAddDialog() {
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 field=body.querySelector(':scope > .field');
const label=field?.querySelector(':scope > label');
const input=field?.querySelector(':scope > input');
const list=input?.list??null;
const actions=body.querySelector(':scope > .dialog-actions');
const rect=dialog.getBoundingClientRect();
return {
title:text(dialog.querySelector('#modal-title')),
tree:[...body.children].map(name),
field:field&&{tree:[...field.children].map(name),height:Math.round(field.getBoundingClientRect().height),
...css(field,'display','flexDirection','rowGap','marginBottom')},
label:label&&{text:text(label),for:label.htmlFor,...css(label,'fontSize','fontWeight','color')},
input:input&&{id:input.id,type:input.type,value:input.value,maxLength:input.maxLength,list:input.getAttribute('list'),
...css(input,'height','paddingLeft','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius')},
suggestions:list&&{id:list.id,display:getComputedStyle(list).display,options:[...list.options].map(option=>option.value)},
actions:{tree:[...actions.children].map(name),...css(actions,'display','justifyContent','columnGap','marginTop'),
buttons:[...actions.children].map(button=>({text:text(button),className:button.className,
...css(button,'backgroundColor','color','fontSize','paddingTop','paddingLeft','borderTopColor')}))},
width:Math.round(rect.width),height:Math.round(rect.height),
};
}
/**
* D138: the action is "Add" (the concept's "Apply in prototype"), the field starts empty (no sample "Experiments") and
* holds at most 100 characters, and a hidden `datalist` of the existing names (the fixture's two) follows the field:
* the shared input dialog's suggestions list, as Set category's (#332 D140).
*/
function mapAddDialogConcept(reference) {
const [cancel,apply]=reference.actions.buttons;
const [field,...rest]=reference.tree;
return {...reference,tree:[field,'datalist',...rest],
input:{...reference.input,value:'',maxLength:100,list:'workspace-input-suggestions'},
suggestions:{id:'workspace-input-suggestions',display:'none',options:['Experiments','Landscapes']},
actions:{...reference.actions,buttons:[cancel,{...apply,text:'Add'}]}};
}
async function openAddDialog(page,main) {
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await page.locator(`${main} #bulkbar`).getByRole('button',{name:'Add to collection',exact:true}).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Add selected images to collection');
await page.mouse.move(0,0);
return page.evaluate(measureAddDialog);
}
async function conceptAddDialog(page,theme) {
await openConcept(page,'images',theme);
return openAddDialog(page,'#main');
}
async function productionAddDialog(page,theme) {
await openProduction(page,'images',theme);
await expect(page.locator(CARDS).first()).toBeVisible();
return openAddDialog(page,'#workspace-main');
}
test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);});
for(const theme of THEMES)for(const [width,height] of SIZES) {
test(`add to collection dialog matches the concept ${theme} ${width}x${height}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const concept=await conceptAddDialog(page,theme);
await page.screenshot({path:testInfo.outputPath(`concept-add-to-collection-${theme}-${width}.png`)});
const production=await productionAddDialog(page,theme);
await page.screenshot({path:testInfo.outputPath(`production-add-to-collection-${theme}-${width}.png`)});
expect(concept.input.value,'the concept pre-fills its sample').toBe('Experiments');
expect(diffInventory(mapAddDialogConcept(concept),production)).toEqual([]);
});
}
test('add to collection comparison detects mutations',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
const concept=await conceptAddDialog(page,'dark');
const expected=mapAddDialogConcept(concept);
const production=await productionAddDialog(page,'dark');
expect(diffInventory(expected,production)).toEqual([]);
// Unmapped, the concept differs only where D138 is recorded.
expect(diffInventory(concept,production).map(item=>item.path).sort()).toEqual(expect.arrayContaining(
['actions.buttons.1.text','tree.1','input.list','input.maxLength','input.value']));
const paths=async()=>diffInventory(expected,await page.evaluate(measureAddDialog)).map(item=>item.path);
await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Name';});
expect(await paths()).toEqual(['label.text']);
await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Collection name';});
const undoStyle=await mutate(page,{css:'dialog#modal #action-input { border-color: rgb(255, 0, 0); } dialog#modal .dialog-actions { gap: 31px; }'});
expect(await paths()).toEqual(expect.arrayContaining(['input.borderTopColor','actions.columnGap']));
await undoStyle();
await page.evaluate(()=>document.querySelector('#workspace-input-suggestions').remove());
expect(await paths()).toEqual(expect.arrayContaining(['suggestions']));
});
+44
View File
@@ -214,6 +214,41 @@ export async function withChipSource(page,root,origin,category=null) {
};
}
/**
* #449 D154: the inspector's tag sections in the concept's prompt-section frame (workspace.css `.tag-section`). The concept
* has one bare `.tags` row after its prompt blocks (app.js a402), so the recorded rule is added and that row wrapped at
* runtime, the browser laying out what follows: `sections` lists `[className, label, name]` per section, the first
* holding the concept's row and each further one a copy of it. Returns the undo.
*/
export const D154_TAG_SECTION=`.tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
.tag-section .role-label { color: var(--ca-muted); }
.tag-section > .tags { margin-top: 0; }`;
export const PROMPT_TAGS=[['linked-tags','Prompt tags','Prompt tags']];
export async function withTagSections(page,root,sections=PROMPT_TAGS) {
const restoreRule=await mutate(page,{css:D154_TAG_SECTION});
const token=await page.evaluate(({root,sections})=>{
const container=document.querySelector(root);
const inner=container?.querySelector(':scope > .detail-inner')??container?.querySelector(':scope > div');
const row=inner&&[...inner.querySelectorAll(':scope > .tags')].find(node=>node.previousElementSibling?.matches('section.prompt-section'));
if(!row)throw new Error(`withTagSections: no tag row after the prompt blocks under ${root}`);
const anchor=row.nextSibling,parent=row.parentNode,added=[];
sections.forEach(([className,label,name],index)=>{
const section=document.createElement('section');section.className=`tag-section ${className}`;section.setAttribute('aria-label',name);
const heading=document.createElement('div');heading.className='prompt-heading';
const text=document.createElement('span');text.className='role-label';text.textContent=`${label} · ${row.children.length}`;
heading.append(text);section.append(heading,index?row.cloneNode(true):row);
parent.insertBefore(section,anchor);added.push(section);
});
const token=`tag-sections-${Math.random().toString(36).slice(2)}`;
(window.__tagSections??=new Map()).set(token,()=>{parent.insertBefore(row,added[0]);added.forEach(section=>section.remove());});
return token;
},{root,sections});
return async()=>{
await page.evaluate(token=>{window.__tagSections.get(token)();window.__tagSections.delete(token);},token);
await restoreRule();
};
}
/**
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
@@ -662,6 +697,13 @@ export function measureInspector(selector) {
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function tagSectionOf(section) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label'),row=section.querySelector(':scope > .tags');
return {className:classes(section),name:section.getAttribute('aria-label'),...edges(section),...box(section),
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
row:when(row,rowOf)};
}
function factsOf(list) {
const style=getComputedStyle(list);
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
@@ -695,6 +737,8 @@ export function measureInspector(selector) {
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
sections:sections.map(sectionOf),
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
// #449 D154: the labelled tag sections after the prompt blocks, each with its frame, heading, label and chip row.
tagSections:parts.filter(item=>item.matches('section.tag-section')).map(tagSectionOf),
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
+67 -17
View File
@@ -28,6 +28,11 @@ import weakref
from pathlib import Path
from typing import Any, Callable, TypeVar
try:
import fcntl
except ImportError: # Windows: no flock, and no /dev/shm to share
fcntl = None
T = TypeVar("T")
# Set to 1 to run seeders on the live database as before #384 (the equivalence
@@ -43,8 +48,13 @@ MEMORY_ROOT = Path("/dev/shm")
# the browsers and the other workers; with less free than this, the scratch
# copy goes on disk instead.
MEMORY_FREE_BYTES = 32 * 1024 * 1024
# Scratch folders are named <prefix><owner pid>-<random>.
# Scratch folders are created as <prefix>new-<owner pid>-<random>, locked, then
# renamed to <prefix>held-<owner pid>-<random>. The pid is for people only:
# ownership is the flock on OWNER_LOCK, which every PID namespace sees (#419).
SCRATCH_PREFIX = "pm-reset-"
STAGING_PREFIX = f"{SCRATCH_PREFIX}new-"
HELD_PREFIX = f"{SCRATCH_PREFIX}held-"
OWNER_LOCK = "owner.lock"
def _quote(name: str) -> str:
@@ -96,29 +106,40 @@ def table_digests(conn: sqlite3.Connection, schema: str = "main") -> dict[str, s
return result
def _process_alive(pid: int) -> bool:
def _hold(folder: Path, *, create: bool) -> int:
"""Take ``folder``'s owner lock without waiting; the open descriptor."""
flags = os.O_RDWR | (os.O_CREAT if create else 0)
fd = os.open(folder / OWNER_LOCK, flags, 0o600)
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
return True # it exists but belongs to someone else
return True
os.close(fd)
raise
return fd
def remove_orphaned_scratch(root: Path) -> None:
"""Remove scratch folders of servers that died without cleaning up.
A server killed outright (SIGKILL, a crash) never runs ``close``, and
tmpfs keeps its folder in memory until reboot. The folder name carries
the owner's process id; a folder whose process is gone is removed.
tmpfs keeps its folder in memory until reboot. A live owner holds the
flock on the folder's owner lock; the kernel drops it when the owner
dies, in whatever PID namespace it ran (a container with ``--ipc=host``
shares /dev/shm but not the pids, #419). A folder whose lock is free is
removed. Folders without a lock (staging, or named before #419) cannot
be proven orphaned and are left alone.
"""
if os.name != "posix":
return # os.kill(pid, 0) would terminate the process on Windows
for folder in root.glob(f"{SCRATCH_PREFIX}*-*"):
pid = folder.name[len(SCRATCH_PREFIX) :].split("-", 1)[0]
if pid.isdigit() and not _process_alive(int(pid)):
if fcntl is None:
return
for folder in root.glob(f"{HELD_PREFIX}*"):
try:
fd = _hold(folder, create=False)
except OSError:
continue # held by its owner, no lock to judge by, or gone
try:
shutil.rmtree(folder, ignore_errors=True)
finally:
os.close(fd)
class _SeedApi:
@@ -162,6 +183,7 @@ class ScratchSeeder:
self._fallback = Path(directory)
self._memory_root = memory_root
self._directory: Path | None = None
self._owner_lock: int | None = None
self._on_live = on_live
self._scratch = None
self._unsynced: "weakref.WeakSet[sqlite3.Connection]" = weakref.WeakSet()
@@ -266,6 +288,9 @@ class ScratchSeeder:
if self._directory is not None:
shutil.rmtree(self._directory, ignore_errors=True)
self._directory = None
if self._owner_lock is not None:
os.close(self._owner_lock) # after the removal: never a free lock
self._owner_lock = None
def _scratch_directory(self) -> Path:
"""A folder in memory (tmpfs) where there is one, else ``directory``.
@@ -274,10 +299,35 @@ class ScratchSeeder:
kernel is writing back the pages they rewrite.
"""
root = self._memory_root
if root is not None and root.is_dir() and os.access(root, os.W_OK):
if (
fcntl is not None
and root is not None
and root.is_dir()
and os.access(root, os.W_OK)
):
remove_orphaned_scratch(root)
if shutil.disk_usage(root).free >= MEMORY_FREE_BYTES:
prefix = f"{SCRATCH_PREFIX}{os.getpid()}-"
return Path(tempfile.mkdtemp(prefix=prefix, dir=root))
held = self._held_memory_directory(root)
if held is not None:
return held
self._fallback.mkdir(parents=True, exist_ok=True)
return self._fallback
def _held_memory_directory(self, root: Path) -> Path | None:
"""A new folder under ``root`` that this seeder holds the lock of.
It is locked under the staging name, which no cleaner judges, and
only then renamed: no cleaner ever sees it held-named and unlocked.
"""
staging = Path(
tempfile.mkdtemp(prefix=f"{STAGING_PREFIX}{os.getpid()}-", dir=root)
)
try:
fd = _hold(staging, create=True)
except OSError:
shutil.rmtree(staging, ignore_errors=True)
return None # no locks on this filesystem: use the disk
held = root / (HELD_PREFIX + staging.name[len(STAGING_PREFIX) :])
os.rename(staging, held)
self._owner_lock = fd
return held
+34 -23
View File
@@ -130,7 +130,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
// Linked, workflow inferred: every concept element.
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']});
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid']});
await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt','');
await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
@@ -139,16 +139,21 @@ test('panel matches the concept order and copy',async({page,request})=>{
// #332 MC8: the chip names where its value came from, in visible text after the category.
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection · recipe');
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling); D154
// (#449) labels their own section after the negative prompt, named for the recipe as D146's category chip is.
const recipeTags=panel.getByRole('region',{name:'Recipe tags',exact:true});
await expect(recipeTags).toHaveClass('tag-section linked-tags');
await expect(recipeTags.locator('.prompt-heading .role-label')).toHaveText('Recipe tags · 2');
await expect(recipeTags.locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.tag-section button')).toHaveCount(0);
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
const footer=panel.locator('.detail-footer');
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']);
// #331 D139: every listed file's footer ends with Collections… (folder icon).
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1);
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2);
await expect(panel).not.toContainText(/prompt evidence|Positive prompt tags/);
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
@@ -157,27 +162,30 @@ test('panel matches the concept order and copy',async({page,request})=>{
await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive');
await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters');
await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension ');
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']);
// Unlinked: no badge, category, tags, pair or history.
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Collections…']);
// Unlinked: no badge, category, tags, pair or history; the footer holds only Collections… (D139).
reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'],
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
// Ambiguous saved association: the same honest absence.
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.tag-section')).toHaveCount(0);
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
const tagRow=panel.locator('.detail-inner > .tags').last();
const tagRow=panel.locator('.tag-section.linked-tags > .tags');
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
await selectFile(page,'complete.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Prompt tags · 2');
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
await selectFile(page,'workflow-only.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
reads.patch=null;await page.unroute('**/images/inspect/**');
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
@@ -241,7 +249,7 @@ test('tag chip filters Images by that tag',async({page,request})=>{
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png');
const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags'));
await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click();
await panel.getByRole('region',{name:'Prompt tags',exact:true}).getByRole('button',{name:'geometric',exact:true}).click();
expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric');
await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible();
await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible();
@@ -258,7 +266,7 @@ test('pair send and history appear only with real roles and link',async({page,re
await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
});
test('no provenance, raw or download controls in the side panel',async({page,request})=>{
@@ -286,19 +294,19 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
// then the pair send and history buttons (D20).
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
// then the pair send and history buttons (D20) and D139's Collections….
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid','button.settings-summary','details','details']});
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(details.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
// File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines.
const provenance=details.locator('details').first();await provenance.locator('summary').click();
@@ -309,7 +317,9 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/);
// D154 (#449) labels the recipe's tags in their own section; the other legacy labels stay gone.
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension/);
await expect(details.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
// Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons.
const raw=details.locator('details').last();await raw.locator('summary').click();
await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible();
@@ -323,9 +333,10 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible();
await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG');
await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG');
// No badge, no line; no resolved pair or link, no buttons after the details.
// No badge, no line; no resolved pair or link: after the details only D139's Collections….
reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png');
expect((await viewerStructure(details)).outer).toEqual(['div']);
expect((await viewerStructure(details)).outer).toEqual(['div','button']);
await expect(details.locator(':scope > button')).toHaveText(['Collections…']);
await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence');
// The history button leaves the viewer for the prompt's history.
await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png');
@@ -464,7 +475,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
test('embedded gallery uses the same full inspector',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
});
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',async({page,request})=>{
+49 -4
View File
@@ -30,9 +30,12 @@ function captionDay(seconds,now=new Date()) {
return `${name} · ${date.toLocaleDateString('en-US',options)}`;
}
/** D8 (amended by #249 PR D): the selection bar's real actions only; Add to collection and Remove links… are left out. */
const OMITTED_BULK=Object.freeze(['Add to collection','Remove links…']);
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
/**
* D8 (amended by #249 PR D and #331): the selection bar's real actions only; Remove links… is left out. Add to
* collection compares in place (D138).
*/
const OMITTED_BULK=Object.freeze(['Remove links…']);
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
/** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */
const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]);
@@ -464,7 +467,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
'Add to collection','Export metadata','Remove links…','Clear']);
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected',
'Set category','Export metadata','Clear']);
'Set category','Add to collection','Export metadata','Clear']);
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
// The bar comparison notices a change to the production bar.
@@ -472,6 +475,48 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
});
/**
* #331 D137: the collection page's Edit leads the heading actions, drawn in the concept as a plain copy of its Import
* button reading "Edit" (no icon, no action), so the browser lays out what follows it; returns an undo.
*/
async function drawCollectionEdit(page) {
await page.evaluate(()=>{
const actions=document.querySelector('#main .heading-actions');
const edit=actions.querySelector('button').cloneNode(false);
for(const key of Object.keys(edit.dataset))delete edit.dataset[key];
edit.textContent='Edit';actions.prepend(edit);window.__collectionEdit=edit;
});
await settle(page);
return ()=>page.evaluate(()=>{window.__collectionEdit.remove();delete window.__collectionEdit;});
}
// #331 D137: `#/collection/<id>` against the concept's `#collection-landscapes` (app.js a1152–1157, a1186–1189): the
// collection's name as the h1, "Collection" as the breadcrumb, the Images description, toolbar and filter row. The grid
// is not compared: the concept's members come from its categories, production's from the stored collection.
for(const [width,height] of SIZES)for(const theme of THEMES)test(`collection page heading, toolbar and filter row match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const manifest=testInfo.manifest;
await openConcept(page,'collection-landscapes',theme);await page.mouse.move(0,0);await settle(page);
await expect(page.locator('#main #page-title')).toHaveText('Landscapes');
await expect(page.locator('#breadcrumb')).toHaveText('Collection');
const restore=await drawCollectionEdit(page);
const reference=await inventoryOf(page,'#main');
await restore();
await openProduction(page,`collection/${manifest.collections.Landscapes}`,theme);await page.mouse.move(0,0);
await expect(page.locator(CARDS)).toHaveCount(12);
await expect(page.locator('#workspace-main #page-title')).toHaveText('Landscapes');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Collection');
await expect(page).toHaveTitle('Collection · Prompt Manager');
await settle(page);
const actual=await inventoryOf(page,'#workspace-main');
expect.soft(actual.heading.actions.buttons.map(item=>item.text),'production heading actions').toEqual(['Edit','Import','New prompt']);
expect.soft(reference.heading.actions.buttons.map(item=>item.text),'mapped concept heading actions').toEqual(['Edit','Import','New prompt']);
expect.soft(compare(reference,actual,manifest,['heading','toolbar','filterRow']),'collection page').toEqual([]);
// The comparison notices a lost Edit.
await mutate(page,{remove:['#workspace-main .heading-actions > button:first-child']});
expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['heading']).length).toBeGreaterThan(0);
});
/** #332: an own category on one listed image, set through the real route (PUT /prompt_manager/images/category). */
const IMAGE_CATEGORY='Portraits';
async function setImageCategory(request,path,category) {
+81 -29
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource,withTagSections,PROMPT_TAGS} from './concept-inventory.mjs';
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
@@ -51,10 +51,12 @@ function withCategoryScope(inventory) {
}
/**
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D146); everything else must
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139, D146 and D154, whose labelled
* tag sections the concept page gets at runtime from `withTagSections`); everything else must
* equal production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do.
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's
* "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details.
*/
export function mapInspectorConcept(reference,{viewport}) {
const {panel,viewerPlain,fileControls}=reference;
@@ -107,18 +109,36 @@ async function openProductionPanel(page,theme) {
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1);
await expect(panelOf(page).locator('.detail-inner > .tag-section.linked-tags')).toBeVisible();
await settle(page);
}
/**
* The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy
* settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON",
* without its action); returns an undo.
* #331 D139: production's "Collections…" after Prompt history in `selector`'s buttons, drawn in the concept as its own
* Prompt history button (no action) with the concept's folder icon (its Images filter chip's); returns an undo.
*/
async function plainViewerChips(page) {
async function withCollectionsButton(page,selector) {
await page.evaluate(selector=>{
const history=[...document.querySelectorAll(`${selector} > button`)].find(button=>button.textContent.includes('Prompt history'));
const button=history.cloneNode(false);
for(const key of Object.keys(button.dataset))delete button.dataset[key];
button.append(document.querySelector('.filter-row .chip > svg.icon').cloneNode(true),' Collections…');
history.after(button);(window.__collectionsButtons??=[]).push(button);
},selector);
await settle(page);
return async()=>{await page.evaluate(()=>{for(const button of window.__collectionsButtons??[])button.remove();delete window.__collectionsButtons;});};
}
/**
* The concept's tag chips in the open viewer in D154's labelled sections (`sections`) as plain `span.chip`s (D20), D34's
* coarse targets and D117's "Copy settings summary" after the six facts (LI-4), drawn as the concept's own plain button
* (its "Download sample JSON", without its action); returns an undo.
*/
async function plainViewerChips(page,sections) {
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN);
const restoreSections=await withTagSections(page,'#viewer-details',sections);
await page.evaluate(()=>{
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
@@ -129,11 +149,12 @@ async function plainViewerChips(page) {
summary.textContent='Copy settings summary';summary.classList.add('settings-summary');
document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary;
});
await settle(page);
const restoreCollections=await withCollectionsButton(page,'#viewer-details');
return async()=>{
await restoreCollections();
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
window.__summaryButton.remove();delete window.__summaryButton;});
await restoreSource();await restoreTargets();
await restoreSections();await restoreSource();await restoreTargets();
};
}
@@ -151,7 +172,7 @@ async function fileControlsOf(page) {
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
*/
async function viewerOf(page,concept,mutation=null) {
async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
await page.locator(CARDS).first().locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
await settle(page);
@@ -160,7 +181,7 @@ async function viewerOf(page,concept,mutation=null) {
const {viewerDetails}=await inspectorOf(page);
const fileControls=await fileControlsOf(page);
let viewerPlain=null;
if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
if(concept) {const restore=await plainViewerChips(page,sections);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
await restoreMutation?.();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
@@ -168,13 +189,18 @@ async function viewerOf(page,concept,mutation=null) {
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
return {viewerDetails,viewerPlain,fileControls};
}
async function inventory(page,concept=false) {
// D146: the concept panel's category chip with its visible origin, laid out by the concept page itself.
async function inventory(page,concept=false,sections=PROMPT_TAGS) {
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history; D154's
// labelled tag sections (by default the fixture's D39 positive tags under "Prompt tags"), laid out by the concept page.
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null;
const restoreSections=concept?await withTagSections(page,'#image-inspector',sections):null;
if(concept)await settle(page);
const {panel}=await inspectorOf(page);
await restoreSections?.();
await restoreCollections?.();
await restoreSource?.();
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept,null,sections);
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
}
@@ -238,7 +264,8 @@ async function productionSendOf(page,context) {
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
async function tagOf(page) {
const before=await page.locator('#result-count').textContent();
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
// The concept's bare row, or production's linked-tags section (D154): the last chip row either way.
await panelOf(page).locator('.detail-inner .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
// Production's caption reads "Loading images…" until the tag's listing answers (#391); the concept's search is instant.
await expect.poll(async()=>{const text=await page.locator('#result-count').textContent();return text!==before&&text!=='Loading images…';}).toBe(true);
await settle(page);
@@ -303,6 +330,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']);
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
// D154: both documents measured the labelled section, so the comparison covers it (never two absent sections).
for(const [name,inventory] of [['concept panel',reference.panel],['concept viewer',reference.viewerPlain],['panel',actual.panel],['viewer',actual.viewerDetails]])
expect.soft(inventory.tagSections.map(section=>section.label.text),`${name} tag sections`).toEqual(['Prompt tags · 3']);
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
expect.soft(production.copy,'copy').toEqual(concept.copy);
@@ -332,15 +362,18 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
await openProductionPanel(page,'dark');
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
const rows=panelOf(page).locator('.detail-inner > .tags');
await expect(rows).toHaveCount(2);
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
const badges=panelOf(page).locator('.detail-inner > .tags');
await expect(badges).toHaveCount(1);
await expect(badges.locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
await expect(badges.locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
// D146 (#332): the visible origin names where the value came from.
await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt');
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(badges.locator('.chip .chip-source')).toHaveText(' · prompt');
// D154: the positive's tags, so the section is "Prompt tags", not "Recipe tags".
const tags=panelOf(page).getByRole('region',{name:'Prompt tags',exact:true});
await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3');
await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(tags.locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
});
// #391: a tag chip replaces the collection; until its listing answers the caption says it is loading, never that the
@@ -353,7 +386,7 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
await page.route(url=>url.pathname==='/prompt_manager/images/output'&&url.searchParams.get('tags')==='cinematic',
async route=>{arrived();await held;await route.continue();},{times:1});
const count=page.locator('#result-count');
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await panelOf(page).locator('.tag-section.linked-tags').getByRole('button',{name:'cinematic',exact:true}).click();
await reached;
await expect(count).toHaveText('Loading images…');
await expect(page.locator(CARDS)).toHaveCount(0);
@@ -363,8 +396,9 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
});
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
// saved as the file's current image tags, production draws them in D154's "Image tags" section (Tags filter buttons in
// the panel, plain in the viewer) and the linked positive's equal tags in its "Prompt tags" section after it (#449),
// and equals the concept with its row in those two sections, in both sizes.
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
test.setTimeout(120_000);
const viewport={width,height};await page.setViewportSize(viewport);
@@ -372,13 +406,14 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
expect(names).toEqual(['cinematic','blue_hour','architecture']);
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
const reference=await inventory(page,true);
const reference=await inventory(page,true,[['image-tags','Image tags','Image tags'],...PROMPT_TAGS]);
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
expect(await saveImageTags(request,path,names)).toEqual(names);
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
await openProductionPanel(page,'dark');
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
await expect(panelOf(page).locator('.tag-section.image-tags .chip')).toHaveText(names);
await expect(panelOf(page).locator('.tag-section.linked-tags .chip')).toHaveText(names);
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
assertInspectorMatch(reference,await inventory(page),viewport);
});
@@ -403,6 +438,17 @@ test('inspector comparison detects mutations',async({page})=>{
await restoreCss();await settle(page);
assertInspectorMatch(reference,await measure(),viewport);
// D154: the tag section's muted label and its row's spacing are part of the mapped concept.
for(const [css,path] of [['#image-inspector .tag-section .role-label { color: rgb(255, 0, 0) }',/panel\.tagSections\.0\.label\.color/],
['#image-inspector .tag-section > .tags { margin-top: 12px }',/panel\.tagSections\.0\.row\.marginTop/],
['#image-inspector .tag-section { border-top-width: 0 }',/panel\.tagSections\.0\.borderTopWidth/]]) {
const restore=await mutate(page,{css});
const mutated=await measure();
expect(()=>assertInspectorMatch(reference,mutated,viewport)).toThrow(path);
await restore();await settle(page);
}
assertInspectorMatch(reference,await measure(),viewport);
const panel=(await inspectorOf(page)).panel;
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
@@ -414,6 +460,12 @@ test('inspector comparison detects mutations',async({page})=>{
expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/);
assertInspectorMatch(reference,await viewerMutated(),viewport);
// D139: Collections… is part of the mapped concept, so losing it is a difference.
const restoreButton=await mutate(page,{remove:['#image-inspector > .detail-footer > button:last-child']});
const withoutCollections=await measure();
expect(()=>assertInspectorMatch(reference,withoutCollections,viewport)).toThrow(/panel\.footer/);
await restoreButton();await settle(page);
// D146: the visible origin is part of the mapped concept, so losing it is a difference.
const restoreSource=await mutate(page,{remove:['#image-inspector .chip-source']});
const sourceMutated=await measure();
+53
View File
@@ -30,6 +30,9 @@ export function mapLibraryConcept(reference) {
// A shorter toolbar moves what follows it up by as much.
const rise=library.toolbar.height-toolbar.height;
const cards=library.cards.map((card,index)=>({...card,
// D148 (#437): each card leads with the concept's Images `.image-overlays` selection checkbox, placed absolutely
// over the art, so nothing else moves; it is compared with the concept's own overlay below.
children:['image-overlays',...card.children],
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
@@ -311,6 +314,56 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
});
/**
* D148 (#437): the selection checkbox overlay of `card` and the `.bulkbar` row inside `main`, with one item checked,
* measured alike in the concept's Images page and production's Library: the overlay's inset in its card, the checkbox,
* and the bar's box, tokens and the actions both bars have (Add tags, Set category, Clear; #442).
*/
const measureSelection=(page,{card,main})=>page.evaluate(({card,main})=>{
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const rect=node=>node.getBoundingClientRect(),px=value=>Math.round(value*100)/100;
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','color');
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginBottom','borderTopWidth','borderTopStyle',
'borderTopColor','borderTopLeftRadius','backgroundColor');
const host=document.querySelector(card),overlay=host.querySelector('.image-overlays'),label=overlay.querySelector(':scope > label');
const input=label.querySelector('input'),root=document.querySelector(main),bar=root.querySelector('.bulkbar');
const shared=['Add tags','Set category','Clear'];
return {
overlay:{left:px(rect(overlay).left-rect(host).left),top:px(rect(overlay).top-rect(host).top),right:px(rect(host).right-rect(overlay).right),
...css(overlay,'position','display','justifyContent','pointerEvents')},
label:css(label,'pointerEvents'),checkbox:{width:px(rect(input).width),height:px(rect(input).height),checked:input.checked,...css(input,'accentColor')},
bar:{left:px(rect(bar).left-rect(root).left),...css(bar,'display','alignItems','columnGap','rowGap','flexWrap'),...edges(bar),...font(bar),
count:{text:bar.querySelector(':scope > strong').textContent,...font(bar.querySelector(':scope > strong'))},
buttons:[...bar.querySelectorAll(':scope > button')].filter(button=>shared.includes(button.textContent.trim()))
.map(button=>({text:button.textContent.trim(),height:px(rect(button).height),...font(button),...edges(button)}))},
};
},{card,main});
async function conceptSelection(page,theme) {
await openConcept(page,'images',theme);
await page.locator('#image-grid .media-card').first().locator('input[data-select]').check();
await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
return measureSelection(page,{card:'#image-grid .media-card',main:'#main'});
}
async function productionSelection(page,theme) {
await openProductionLibrary(page,theme,'no-preference');
await page.locator(CARDS).first().locator('.image-overlays input').check();
await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
return measureSelection(page,{card:CARDS,main:'#workspace-main'});
}
for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library selection checkbox and bar match the concept's Images ones ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const reference=await conceptSelection(page,theme);
await captureCanonicalState(page,testInfo,`concept-images-selection-${width}-${theme}`);
const actual=await productionSelection(page,theme);
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']);
expect(actual).toEqual(reference);
// The comparison notices a moved checkbox (spec §7 mutation check).
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
});
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width:1800,height:1100});
+3
View File
@@ -38,6 +38,9 @@ async function chooseDestination(page) {
test('collection viewer rejects stale source and sends only refreshed API evidence',async({page,request,context})=>{
const host=await fakeHost(context,page);
// #430: the file is never found again here, so the manual Refresh is what this covers (the found-again path is
// workspace-stale-source.spec.mjs).
await page.route('**/prompt_manager/images/rescope',route=>route.fulfill({status:409,json:{success:false,code:'source-changed',error:'The source of this image changed or was removed.'}}));
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>window.copied.push(text)}});});
try {
const loaded=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const stale=loaded.images.find(image=>image.root_index===0&&image.path==='shared/same.png');
+4 -1
View File
@@ -46,6 +46,7 @@ from tests.browser.workspace_library_editor_fixture import (
)
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
from tests.browser.workspace_tagging_fixture import register_tagging_commands
from tests.browser.source_change_fixture import register_source_change_commands
from tests.browser.workspace_review_fixture import register_review_commands
from tests.browser.workspace_collections_fixture import register_collections_commands
from tests.browser.workspace_diagnostics_fixture import (
@@ -231,7 +232,8 @@ def create_app(root: Path) -> web.Application:
history_commands = register_workspace_history_commands(routes, api, root)
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
register_tagging_commands(routes, api, root, model)
restore_sources = register_source_change_commands(routes, api)
register_tagging_commands(routes, api, root, model, on_reset=restore_sources)
register_review_commands(routes, api, root)
register_collections_commands(routes, api, root)
reveal_stub = register_reveal_stub(routes, api, root)
@@ -439,6 +441,7 @@ def create_app(root: Path) -> web.Application:
async def reset(request: web.Request) -> web.Response:
nonlocal history_fixture
request_holds.release()
restore_sources()
# No preview queued by the last test may land in the new fixture.
await api._run_in_executor(api.close_auto_thumbnails)
library_editor_commands.reset()
+66
View File
@@ -0,0 +1,66 @@
"""Source changes under an open workspace page (#430), undone by every reset.
- ``POST /__test__/sources/reverse``: the image sources are listed in reverse
order, as System › Sources would renumber them, so every held
``root_scope`` names another index and the page must re-scope its file.
- ``POST /__test__/scope-key/rotate``: the API's scope and cursor keys
change, as when ``workspace-scope.key`` is lost before a restart: a held
scope then cannot be mapped and the page offers Refresh.
"""
from __future__ import annotations
import secrets
from typing import TYPE_CHECKING, Callable
from aiohttp import web
from utils.workspace_keys import WorkspaceKeys
if TYPE_CHECKING:
from py.api import PromptManagerAPI
_CACHE = "_workspace_keys_cache"
def register_source_change_commands(
routes: web.RouteTableDef, api: PromptManagerAPI
) -> Callable[[], None]:
"""Register the commands; returns the restore every reset runs."""
# Imported here: py.config needs the host stubs create_app installs first.
from py.config import GalleryConfig
saved: dict = {}
def restore() -> None:
if "roots" in saved:
GalleryConfig.MONITORING_DIRECTORIES = saved.pop("roots")
api.invalidate_gallery_cache(retire=True)
if "keys" in saved:
keys = saved.pop("keys")
if keys is None:
api.__dict__.pop(_CACHE, None)
else:
api.__dict__[_CACHE] = keys
@routes.post("/__test__/sources/reverse")
async def reverse(request: web.Request) -> web.Response:
if request.query or request.can_read_body:
raise web.HTTPBadRequest()
roots = list(GalleryConfig.MONITORING_DIRECTORIES)
saved.setdefault("roots", roots)
GalleryConfig.MONITORING_DIRECTORIES = roots[::-1]
await api._run_in_executor(api.invalidate_gallery_cache, True)
return web.json_response({"success": True})
@routes.post("/__test__/scope-key/rotate")
async def rotate(request: web.Request) -> web.Response:
if request.query or request.can_read_body:
raise web.HTTPBadRequest()
saved.setdefault("keys", api.__dict__.get(_CACHE))
api.__dict__[_CACHE] = WorkspaceKeys(
secrets.token_bytes(32), secrets.token_bytes(32), False
)
return web.json_response({"success": True})
return restore
+5 -2
View File
@@ -231,6 +231,8 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
const save = () => tagDialog(page).locator('.dialog-actions .ca-primary').click();
const status = () => tagDialog(page).locator('#tag-generation-status');
// D147: a state that blocks Save shows its reason beside the actions, not in the status line.
const notice = () => tagDialog(page).locator('.dialog-actions > .tag-action-notice');
// Unavailable model.
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
@@ -269,7 +271,8 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await saveImageTags(request, path, CONCEPT_TAGS);
await openTagDialog(page, 'production', 'dark');
await save();
await expect(status()).not.toHaveText('');
await expect(notice()).toHaveText('The file changed since it was previewed. Reopen the Tag dialog.');
await expect(status()).toHaveText('');
await shot('production-file-changed');
await page.unroute(writes);
await page.keyboard.press('Escape');
@@ -278,7 +281,7 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await page.route(writes, route => route.fulfill({ status: 504, body: 'Gateway Timeout' }));
await openTagDialog(page, 'production', 'dark');
await save();
await expect(status()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
await expect(notice()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
expect((await inventory(page)).actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
await shot('production-unknown-outcome');
+40
View File
@@ -52,3 +52,43 @@ export async function unseenRestore(page, restoring) {
open();
}
}
// A restore the open page does see run and finish (#415), driven by the test instead of the 15 s poll's timing. The
// job is shaped as the server's (py/api/backup_restore.py: kind `restore`, one `errors` group, not cancellable).
const RESTORE_JOB_ID = '415415415415415415415415415415ab';
const restoreJob = state => ({ id: RESTORE_JOB_ID, kind: 'restore', title: 'Database restore', state, started_at: 1,
finished_at: state === 'running' ? null : 2, progress: 0, status_line: state === 'running' ? '' : 'Restored the test snapshot.',
processed: 0, total: 0, counters: { errors: 0 }, scope: {}, listed: { errors: 0 }, log_length: 0, cancellable: false });
/** The Activity pill's link while the page follows that restore. */
export const SEEN_RESTORE_PILL = `#/activity/${RESTORE_JOB_ID}`;
/** The toast the page shows when it sees that restore finish (activity-kinds.mjs `restore.finishToast`). */
export const SEEN_RESTORE_TOAST = 'Database restore finished · Restored the test snapshot.';
/**
* Call before the page's first navigation, after `armUnseenRestores(page)` (a later route wins). The page's first
* job-list read lists a restore job running, so its Activity store follows it; its job stream is refused, so the store
* reads the list again, and every later read is held until `finish()`, which answers them with the job finished and
* resolves once one of those answers reached the page. The real restore the test runs meanwhile goes through the API.
*/
export async function followRestore(page) {
let release;
const released = new Promise(resolve => { release = resolve; });
let first = true;
await page.route(`${JOBS}/*/events`, route => route.abort().catch(() => {}));
await page.route(JOBS, async route => {
if (first) {
first = false;
return route.fulfill({ json: { success: true, jobs: [restoreJob('running')] } }).catch(() => {});
}
await released;
// A read the page has aborted since (a newer read supersedes it) can no longer be answered.
return route.fulfill({ json: { success: true, jobs: [restoreJob('finished')] } }).catch(() => {});
});
return {
async finish() {
const answered = page.waitForResponse(response => new URL(response.url()).pathname === '/prompt_manager/jobs');
release();
await answered;
},
};
}
@@ -491,3 +491,366 @@ test('a page that says a collection or view could not be opened takes focus afte
await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(heading(page)).toBeFocused();
});
// #331 D138 (design §7): the selection bar's Add to collection and Remove from collection.
const bar = page => page.locator('#bulkbar > .bulkbar');
const barButton = (page, name) => bar(page).getByRole('button', { name, exact: true });
const addDialog = page => page.getByRole('dialog', { name: 'Add selected images to collection', exact: true });
const addField = page => addDialog(page).getByRole('combobox', { name: 'Collection name', exact: true });
const check = (page, file) => page.getByRole('checkbox', { name: `Select ${file}`, exact: true }).check();
const card = (page, path) => page.locator(`article[data-image-key$=":${path}"]`);
/** Add the checked images under `name` through the bar's dialog. */
async function addChecked(page, name) {
await barButton(page, 'Add to collection').click();
await expect(addDialog(page)).toBeVisible();
await addField(page).fill(name);
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(addDialog(page)).toHaveCount(0);
}
test('Add to collection creates a collection and adds to it ignoring case (CUJ 1–3)', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
for (const file of ['loose.png', 'landscape_01.png', 'experiment_01.png']) await check(page, file);
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Export metadata', 'Clear']);
await barButton(page, 'Add to collection').click();
// The concept's simpleInput with Add (D138) and the existing names as suggestions.
await expect(addDialog(page).locator('label')).toHaveText('Collection name');
await expect(addField(page)).toHaveValue('');
await expect(addField(page)).toHaveAttribute('maxlength', '100');
await expect(addDialog(page).locator('datalist option')).toHaveCount(2);
expect(await addDialog(page).locator('datalist option').evaluateAll(options => options.map(option => option.value)))
.toEqual(['Experiments', 'Landscapes']);
await expect(addDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Add']);
// An empty name keeps the dialog on its field.
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(addField(page)).toBeFocused();
await addField(page).fill(' LoRa testing ');
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(toast(page)).toHaveText('Added 3 images to “LoRa testing”.');
await expect(bar(page).locator('strong')).toHaveText('3 selected');
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('3');
await expect(rows(page)).toHaveCount(3);
// CUJ 2: one of two already in it.
await bar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await check(page, 'loose.png');
await check(page, 'landscape_02.png');
await addChecked(page, 'LoRa testing');
await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”. 1 was already in it.');
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('4');
// CUJ 3: a case variant joins the same collection.
await bar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await check(page, 'landscape_03.png');
await addChecked(page, 'lora TESTING');
await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”.');
await expect(rows(page)).toHaveCount(3);
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('5');
const created = (await listed(request)).find(item => item.name === 'LoRa testing');
expect(created).toMatchObject({ count: 5, missing: 0 });
// Everything already in it.
await addChecked(page, 'LoRa testing');
await expect(toast(page)).toHaveText('All selected images were already in “LoRa testing”.');
});
test('Remove from collection takes the checked images off the open collection (CUJ 5)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await expect(imageCards(page)).toHaveCount(12);
await check(page, 'landscape_01.png');
await check(page, 'landscape_02.png');
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Remove from collection', 'Export metadata', 'Clear']);
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 2 images from “Landscapes”.');
await expect(imageCards(page)).toHaveCount(10);
await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0);
// The removed files left the listing, so the bar has nothing checked.
await expect(bar(page)).toHaveCount(0);
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('10');
// A checked file still listed stays checked across the reload.
await check(page, 'landscape_03.png');
await check(page, 'landscape_04.png');
await page.route(`**${API}/items`, async route => {
if (route.request().method() !== 'DELETE') return route.fallback();
// Remove only landscape_04; landscape_03 stays listed and checked.
const body = route.request().postDataJSON();
await route.continue({ postData: JSON.stringify({ ...body, items: body.items.filter(item => item.path.endsWith('_04.png')) }) });
});
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 1 image from “Landscapes”.');
await expect(imageCards(page)).toHaveCount(9);
await expect(bar(page).locator('strong')).toHaveText('1 selected');
await expect(page.getByRole('checkbox', { name: 'Select landscape_03.png', exact: true })).toBeChecked();
// No file changed: the plain Images page still lists all of them.
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
});
test('Images without a collection filter has no Remove from collection', async ({ page, request }) => {
const manifest = await seed(request);
await page.goto(`${workspace}#/images`);
await check(page, 'loose.png');
await expect(barButton(page, 'Add to collection')).toBeVisible();
await expect(barButton(page, 'Remove from collection')).toHaveCount(0);
// A saved view on a collection has its filter active: Remove shows (design §5).
await page.goto(`${workspace}#/saved/${manifest.saved_view.id}`);
await expect(imageCards(page)).toHaveCount(8);
await check(page, 'experiment_01.png');
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 1 image from “Experiments”.');
await expect(imageCards(page)).toHaveCount(7);
});
test('a failed add keeps the selection; a stale source reloads the images', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
let reply = { status: 500, body: { success: false, error: 'The collection could not be changed.' } };
await page.route(`**${API}/items`, route => route.fulfill({ status: reply.status, contentType: 'application/json',
body: JSON.stringify(reply.body) }));
await check(page, 'loose.png');
await check(page, 'landscape_05.png');
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('The images could not be added. Try again.');
await expect(bar(page).locator('strong')).toHaveText('2 selected');
await expect(rows(page)).toHaveCount(2);
reply = { status: 404, body: { success: false, code: 'files-not-found', error: 'x' } };
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('None of the selected images could be found. Refresh the images and try again.');
await expect(bar(page).locator('strong')).toHaveText('2 selected');
reply = { status: 409, body: { success: false, code: 'source-changed', error: 'x' } };
const reloaded = page.waitForRequest(request => request.url().includes('/prompt_manager/gallery/subfolders'));
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.');
await reloaded;
await expect(imageCards(page)).toHaveCount(21);
await expect(bar(page)).toHaveCount(0);
});
test('more than 500 or an unloaded "all matching" selection is refused with the cap', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
// Every loaded image checked and the listing says more match: "Select all N matching" (#392, D135).
await page.route('**/prompt_manager/images/output?*', async route => {
// A next page never answers, so the listing stays at its loaded 21 of 900.
if (new URL(route.request().url()).searchParams.has('cursor')) return;
const response = await route.fetch();
const data = await response.json();
await route.fulfill({ response, json: { ...data, total: 900, has_more: true, next_cursor: 'more' } });
});
await page.reload();
await expect(imageCards(page)).toHaveCount(21);
await page.locator('#image-grid .image-open').first().focus();
await page.keyboard.press('ControlOrMeta+a');
await bar(page).getByRole('button', { name: 'Select all 900 matching', exact: true }).click();
await expect(bar(page).locator('strong')).toHaveText('All 900 matching selected');
await barButton(page, 'Add to collection').click();
await expect(toast(page)).toHaveText('Select at most 500 images.');
await expect(addDialog(page)).toHaveCount(0);
});
for (const theme of ['dark', 'light']) {
test(`the Add to collection dialog fits 390x844 ${theme}`, async ({ page, request }) => {
await seed(request);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${workspace}#/images`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await check(page, 'loose.png');
await expect(barButton(page, 'Add to collection')).toBeVisible();
await barButton(page, 'Add to collection').click();
await expect(addDialog(page)).toBeVisible();
const fits = await addDialog(page).evaluate(dialog => {
const box = dialog.getBoundingClientRect();
return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth;
});
expect(fits).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await addField(page).fill('Phone');
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(toast(page)).toHaveText('Added 1 image to “Phone”.');
await expect(row(page, 'Phone')).toBeVisible();
});
}
// #331 D139 (design §8): the shared inspector's Collections… in the Images side panel and in the viewer.
const panel = page => page.locator('#image-inspector');
const collectionsDialog = page => page.getByRole('dialog', { name: 'Collections', exact: true });
const choice = (page, name) => collectionsDialog(page).getByRole('checkbox', { name, exact: true });
const newCollection = page => collectionsDialog(page).getByRole('textbox', { name: 'New collection', exact: true });
async function membership(request, path) {
const [image] = (await (await request.get(`/prompt_manager/images/output?mode=workspace&q=${encodeURIComponent(path)}`)).json()).images;
const query = new URLSearchParams({ root_index: String(image.root_index), root_scope: image.root_scope, path: image.path });
const response = await request.get(`${API}/membership?${query}`);
await expect(response).toBeOK();
return (await response.json()).collection_ids;
}
test('Collections… in the side panel adds, removes and creates for one image (CUJ 6)', async ({ page, request }) => {
const manifest = await seed(request);
const { Landscapes, Experiments } = manifest.collections;
await page.goto(`${workspace}#/images`);
// The newest image, landscape_01, is the panel's.
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('landscape_01.png');
const open = panel(page).getByRole('button', { name: 'Collections…', exact: true });
await expect(panel(page).locator('.detail-footer > button').last()).toHaveText('Collections…');
await open.click();
await expect(collectionsDialog(page)).toBeVisible();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(collectionsDialog(page).locator('label.tag-suggestion')).toHaveText(['Experiments', 'Landscapes']);
await expect(collectionsDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
await choice(page, 'Landscapes').uncheck();
await choice(page, 'Experiments').check();
await newCollection(page).fill('Grids');
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
await expect(toast(page)).toHaveText('Collections updated.');
await expect(open).toBeFocused();
await expect(row(page, 'Grids').locator('.nav-count')).toHaveText('1');
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11');
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('9');
const grids = (await listed(request)).find(item => item.name === 'Grids');
expect((await membership(request, 'landscape_01')).sort()).toEqual([Experiments.id, grids.id].sort());
expect(Landscapes.id).not.toBe(grids.id);
// The Images page lists the same files; the dialog opened again shows the new state.
await expect(imageCards(page)).toHaveCount(21);
await open.click();
await expect(choice(page, 'Grids')).toBeChecked();
await expect(choice(page, 'Experiments')).toBeChecked();
await expect(choice(page, 'Landscapes')).not.toBeChecked();
// Save without a change sends nothing.
let writes = 0;
page.on('request', request => { if (request.url().includes(`${API}/items`)) writes++; });
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
expect(writes).toBe(0);
});
test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', async ({ page, request }) => {
const manifest = await seed(request);
await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`);
await expect(imageCards(page)).toHaveCount(12);
await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click();
const viewer = page.locator('#viewer-dialog[open]');
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
// Review L7: no listing request while the viewer is open.
let listings = 0;
const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; };
page.on('request', countListing);
await viewer.locator('#viewer-details').getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
// Review L6: focus moves to the first checkbox once listed.
await expect(choice(page, 'Experiments')).toBeFocused();
await choice(page, 'Landscapes').uncheck();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collections updated.');
await expect(collectionsDialog(page)).toHaveCount(0);
// The viewer stays on the image; the page keeps listing it while the viewer is open.
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11');
await expect(imageCards(page)).toHaveCount(12);
expect(listings, 'no listing request while the viewer is open').toBe(0);
page.off('request', countListing);
await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click();
await expect(viewer).toHaveCount(0);
await expect(imageCards(page)).toHaveCount(11);
await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0);
});
test('a failed membership read says so and Retry reads it again', async ({ page, request }) => {
await seed(request);
let failing = true;
await page.route(`**${API}/membership?*`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' })
: route.fallback());
await page.goto(`${workspace}#/images`);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert').first()).toHaveText('Collections could not be loaded.');
await expect(collectionsDialog(page).getByRole('checkbox')).toHaveCount(0);
await expect(collectionsDialog(page).getByRole('button', { name: 'Save', exact: true })).toHaveAttribute('aria-disabled', 'true');
failing = false;
await collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden();
// A failed write keeps the dialog with the current membership.
await page.route(`**${API}/items`, route => route.fulfill({ status: 500, contentType: 'application/json',
body: '{"success":false,"error":"The collection could not be changed."}' }));
await choice(page, 'Experiments').check();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.');
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(choice(page, 'Landscapes')).toBeChecked();
});
test('a file in no collection with no collections at all can start one', async ({ page }) => {
await page.goto(`${workspace}#/images`);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(collectionsDialog(page)).toContainText('No collections yet. Name one below.');
await newCollection(page).fill('First');
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collections updated.');
await expect(rows(page)).toHaveCount(1);
await expect(row(page, 'First').locator('.nav-count')).toHaveText('1');
});
for (const theme of ['dark', 'light']) {
test(`the Collections dialog fits 390x844 ${theme}`, async ({ page, request }) => {
await seed(request);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${workspace}#/images`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
const fits = await collectionsDialog(page).evaluate(dialog => {
const box = dialog.getBoundingClientRect();
return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth;
});
expect(fits).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
}
// Review M2: on a collection page, a Save that removed the file from the open collection and then failed keeps its
// dialog and message; the listing is read again only once the dialog closes.
test('a partly failed Save from the side panel keeps its dialog until it closes', async ({ page, request }) => {
const manifest = await seed(request);
const { Experiments, Landscapes } = manifest.collections;
// experiment_01 is in both collections; Experiments sorts first, so its remove is sent before Landscapes'.
const [image] = (await (await request.get('/prompt_manager/images/output?mode=workspace&q=experiment_01')).json()).images;
const item = { root_index: image.root_index, root_scope: image.root_scope, path: image.path };
await expect(await request.post(`${API}/items`, { data: { collection_id: Landscapes.id, items: [item] } })).toBeOK();
await page.route(`**${API}/items`, route => {
const body = route.request().postDataJSON();
return route.request().method() === 'DELETE' && body.collection_id === Landscapes.id
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback();
});
await page.goto(`${workspace}#/collection/${Experiments.id}`);
await expect(imageCards(page)).toHaveCount(8);
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('experiment_01.png');
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
await choice(page, 'Experiments').uncheck();
await choice(page, 'Landscapes').uncheck();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.');
// The remove from Experiments went through; the dialog shows it and the page has not reloaded under it.
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(imageCards(page)).toHaveCount(8);
await collectionsDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
await expect(imageCards(page)).toHaveCount(7);
await expect(card(page, 'collections/experiment_01.png')).toHaveCount(0);
});
+19
View File
@@ -495,6 +495,25 @@ test('the inline current file names its prompts apart from the frozen run fields
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
});
// #449 (D154): History's inline current file draws tags in the shared labelled sections, named with its context so they
// stay apart from the frozen run's fields. The server sends History no image_tags facet today (D88), so the read is
// given one here to prove the shared render in History.
test('the inline current file labels its image tags after its negative prompt',async({page})=>{
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
body.image_tags={state:'current',tags:['history_tag']};
await route.fulfill({response,json:body});});
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
const tags=current.getByRole('region',{name:'Current file image tags',exact:true});
await expect(tags.locator('.role-label')).toHaveText('Image tags · 1');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag']);
const negative=current.getByRole('region',{name:'Current file negative prompt',exact:true});
expect(await negative.evaluate((node,section)=>({after:Boolean(node.compareDocumentPosition(section)&Node.DOCUMENT_POSITION_FOLLOWING),inside:node.contains(section)}),await tags.elementHandle())).toEqual({after:true,inside:false});
});
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
// the inline current file and its viewer show "Portraits · this image"; current.png has two saved links, so it inherits
// nothing and shows no chip before.
+5 -1
View File
@@ -569,6 +569,10 @@ test('source and folder discovery failure stays visible and Import Rescan restor
async function staleSourceRecovery(page, request, roots) {
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
// #430: the page first asks the server where a stale file is now. These tests cover the manual Refresh, so the
// file is never found; workspace-stale-source.spec.mjs covers a renumbered file that is found again.
await page.route('**/prompt_manager/images/rescope', route => route.fulfill({ status: 409,
json: { success: false, code: 'source-changed', error: 'The source of this image changed or was removed.' } }));
await page.goto(workspace);
const source = refineDialog(page).getByLabel('Source', { exact: true });
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
@@ -1787,7 +1791,7 @@ test('card checkboxes select images into the concept bar and Clear empties it',
await checkbox(first, 'same.png').check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
// D85: Add tags (the concept's) and Auto-tag selected lead the real actions.
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
// Toggling neither opens the viewer, nor inspects, nor moves focus.
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(checkbox(first, 'same.png')).toBeFocused();
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
@@ -454,6 +454,37 @@ async function leaveAndReturn(page,id) {
}
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
// #415: a restore the page sees finish while New prompt is open keeps the dialog and its entries; the Library is shown
// again from the restored data only once the dialog has closed.
test('a restore seen finishing keeps New prompt open and reloads the Library after it closes',async({page,request})=>{
const f=page.fixture;const snapshot=await request.get('/__test__/library-editor/snapshot');await expect(snapshot).toBeOK();
const buffer=await snapshot.body();
const current=await detail(request,f.positive_id,'active');
await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{title:'Retitled elsewhere'}}})).toBeOK();
const restoreRun=await followRestore(page);
await page.goto(`${workspace}#/library`);const card=page.locator(`article[data-prompt-id="${f.positive_id}"]`);
await expect(card).toContainText('Retitled elsewhere');
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
await headingActions(page).getByRole('button',{name:'+ New prompt',exact:true}).click();
const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
await field(dialog,'Title').fill('Typed before restore');await field(dialog,'Positive prompt').fill('A prompt typed while storage was restored');
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer}}})).toBeOK();
page.writes.length=0;
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
await expect(dialog).toBeVisible();
await expect(field(dialog,'Title')).toHaveValue('Typed before restore');
await expect(field(dialog,'Positive prompt')).toHaveValue('A prompt typed while storage was restored');
await expect(card).toContainText('Retitled elsewhere');
await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
await expect(button(dialog,'Save')).toBeDisabled();expect(page.writes).toHaveLength(1);
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
await expect(card).not.toContainText('Retitled elsewhere');await expect(card).toContainText('Editor portrait');
await expect(page.locator('#workspace-main h1')).toBeFocused();
expect(page.writes).toHaveLength(1);
});
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
const actions=headingActions(page).locator('button');
@@ -0,0 +1,294 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
// #437 D148: select prompts on the Library shelf and set their category in one step (the legacy admin's Set Category).
const route='/prompt_manager/workspace#/library';
const cards=page=>page.locator('article[data-prompt-id]');
const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`);
const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]');
const bar=page=>page.locator('#library-bulkbar > .bulkbar');
const dialog=page=>page.getByRole('dialog',{name:'Categorize selected prompts',exact:true});
const toast=page=>page.locator('.toast');
const heading=page=>page.locator('#workspace-main h1');
const tagsDialog=page=>page.getByRole('dialog',{name:'Tag selected prompts',exact:true});
const chips=(page,id)=>card(page,id).locator('.tags > .chip');
async function addTags(page,value) {
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
await expect(tagsDialog(page)).toBeVisible();
await tagsDialog(page).locator('#action-input').fill(value);
await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
}
async function shelf(request) {
const body=await(await request.get('/prompt_manager/library?limit=100')).json();
return body.items;
}
/** Every POST to the bulk category route, with its body. */
function recordWrites(page) {
const writes=[];
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/category')writes.push(item.postDataJSON());});
return writes;
}
/** The first mounted card's index once two frames in a row agree (the shelf's window follows scrolling a frame later). */
async function stableFirstIndex(page) {
return page.evaluate(()=>new Promise(resolve=>{
const first=()=>document.querySelector('article[data-prompt-id]')?.dataset.index;
let previous=first();
const check=()=>requestAnimationFrame(()=>requestAnimationFrame(()=>{const now=first();if(now===previous)resolve(Number(now));else{previous=now;check();}}));
check();
}));
}
async function setCategory(page,value) {
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
await expect(dialog(page)).toBeVisible();
await dialog(page).locator('#action-input').fill(value);
await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
}
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('Set category writes the selected prompts on the server, reloads the shelf and clears the selection',async({page,request})=>{
const items=await shelf(request);const [first,second,third]=items;
const writes=recordWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
// The checkbox is named after the card's title, as the Images overlay names its file.
await expect(box(page,first.id)).toHaveAccessibleName(new RegExp(`^Select `));
await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected');
await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected');
// Two of three loaded are checked, so Select all N loaded follows the count (D151, #444 ruling).
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Clear']);
await setCategory(page,' Studio set ');
await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.');
await expect(dialog(page)).toHaveCount(0);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
for(const item of [first,second]) await expect(card(page,item.id).locator('.card-title .chip')).toHaveText('Studio set');
await expect(card(page,third.id).locator('.card-title .chip')).toHaveText(third.category || 'Uncategorized');
await expect(box(page,first.id)).not.toBeChecked();
const after=await shelf(request);
expect(after.filter(item=>item.category==='Studio set').map(item=>item.id).sort()).toEqual([first.id,second.id].sort());
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[first.id,second.id],category:'Studio set',expected_epoch:epoch}]);
// The toolbar's category list now offers the new value, and so does the next dialog.
await expect(page.locator('#category-filter option',{hasText:'Studio set'})).toHaveCount(1);
await box(page,third.id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
await expect(dialog(page).locator('datalist option[value="Studio set"]')).toHaveCount(1);
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
expect(writes).toHaveLength(1);
});
test('a blank or control-character category keeps the dialog open; Clear and Escape clear the selection',async({page,request})=>{
const items=await shelf(request);const writes=recordWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
const field=dialog(page).locator('#action-input');
await field.fill(' ');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(dialog(page)).toBeVisible();await expect(field).toBeFocused();
await field.fill('bad\tvalue');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(dialog(page)).toBeVisible();
expect(await field.evaluate(node=>node.validationMessage)).toBe('A category cannot contain control characters.');
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
expect(writes).toHaveLength(0);
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
await expect(box(page,items[0].id)).not.toBeChecked();
await box(page,items[1].id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).focus();await page.keyboard.press('Escape');
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
// A filter change replaces the shelf and clears the selection too.
await box(page,items[1].id).check();await expect(bar(page)).toBeVisible();
await page.locator('#search').fill('portrait');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
});
test('Shift+click selects a range across windowed cards, Tab reaches the checkbox at the window edge, and a return keeps the fence',async({page,request,isMobile})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
const writes=recordWrites(page);
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(let index=0;index<3;index++) {
await expect(more).toHaveAttribute('aria-disabled','false');
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(more).toHaveAttribute('aria-disabled','false');
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,firstId).check();
// Windowed: fewer cards are mounted than are loaded.
expect(await cards(page).count()).toBeLessThan(160);
// Tab from the last mounted card's last control lands on the next (unmounted) card's checkbox.
const last=cards(page).last();const lastIndex=Number(await last.getAttribute('data-index'));
await last.locator('button').last().focus();await page.keyboard.press('Tab');
const next=page.locator(`article[data-index="${lastIndex+1}"]`);
await expect(next.locator('.image-overlays input')).toBeFocused();
// Shift+click that card's checkbox: every card from the first to it is selected, mounted or not.
await next.locator('.image-overlays > label').click({modifiers:['Shift']});
await expect(bar(page).locator('strong')).toHaveText(`${lastIndex+2} selected`);
// Shift+Tab from a checkbox whose previous card is unmounted lands on that card's last control.
// Halfway down the loaded cards (short of the continuation, so no page loads), once the window stops moving.
await page.evaluate(()=>{const grid=document.querySelector('.library-grid');scrollTo(0,grid.getBoundingClientRect().top+scrollY+grid.offsetHeight/2);});
await expect.poll(()=>page.locator('article[data-index="0"]').count()).toBe(0);
const topIndex=await stableFirstIndex(page);
await page.locator(`article[data-index="${topIndex}"] .image-overlays input`).focus();await page.keyboard.press('Shift+Tab');
await expect(page.locator(`article[data-index="${topIndex-1}"] .card-footer .row-actions > button`).last()).toBeFocused();
// Leaving for a prompt's history and coming back restores the shelf without a read; its epoch still fences.
await page.locator(`article[data-index="${topIndex}"]`).getByRole('button',{name:'Explore history',exact:true}).click();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(cards(page).first()).toBeVisible();await expect(page.locator('#library-bulkbar')).toBeEmpty();
const visible=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,visible).check();await setCategory(page,'Returned');
await expect(toast(page)).toHaveText('Category “Returned” set on 1 prompt.');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[visible],category:'Returned',expected_epoch:epoch}]);
});
test('a restore the page did not see refuses the write, changes nothing and reloads the shelf',async({page,request})=>{
await armUnseenRestores(page);
const items=await shelf(request);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
await expect(restore).toBeOK();
const reload=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&!url.searchParams.has('cursor');});
await setCategory(page,'After restore');
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No category was changed.');
await reload;await expect(page.locator('#library-bulkbar')).toBeEmpty();
expect((await shelf(request)).filter(item=>item.category==='After restore')).toEqual([]);
// The reloaded shelf reads the new epoch: the next write goes through.
await box(page,items[0].id).check();await setCategory(page,'After reload');
await expect(toast(page)).toHaveText('Category “After reload” set on 1 prompt.');
});
test('an answer that may follow a committed write says so and reloads; a refused write keeps the selection',async({page,request})=>{
const items=await shelf(request);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await page.route('**/prompt_manager/bulk/category',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1});
await setCategory(page,'Refused');
await expect(toast(page)).toHaveText('Category could not be set. Try again.');
await expect(bar(page).locator('strong')).toHaveText('1 selected');
let sent=false;
await page.route('**/prompt_manager/bulk/category',async route=>{sent=true;await route.fetch();await route.abort('failed');},{times:1});
await setCategory(page,'Unconfirmed');
await expect(toast(page)).toHaveText('The category change could not be confirmed. The Library was reloaded.');
expect(sent).toBe(true);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
await expect(card(page,items[0].id).locator('.card-title .chip')).toHaveText('Unconfirmed');
});
// #442: Add tags writes prompt tags (never image tags) on the selected prompts.
function recordTagWrites(page) {
const writes=[];
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/tags')writes.push(item.postDataJSON());});
return writes;
}
async function promptTags(request,id) {
return (await shelf(request)).find(item=>item.id===id).tags;
}
test('Add tags adds prompt tags to the selected prompts, suggests the vocabulary and reloads the shelf',async({page,request})=>{
const items=await shelf(request);const [first,second,third]=items;const writes=recordTagWrites(page);
const vocabulary=(await(await request.get('/prompt_manager/tags')).json()).tags;
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,first.id).check();await box(page,second.id).check();
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
await expect(tagsDialog(page).locator('datalist option')).toHaveCount(vocabulary.length);
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(tagsDialog(page)).toHaveCount(0);
await addTags(page,' studio-lit , Night ,studio-lit');
await expect(toast(page)).toHaveText('Added “studio-lit, Night” to 2 prompts.');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
for(const item of [first,second]) {
await expect(chips(page,item.id).filter({hasText:'studio-lit'})).toHaveCount(1);
expect(await promptTags(request,item.id)).toEqual(expect.arrayContaining(['studio-lit','Night']));
}
expect(await promptTags(request,third.id)).not.toContain('studio-lit');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[first.id,second.id],tags:['studio-lit','Night'],expected_epoch:epoch}]);
// A second add of the same names changes nothing and says so.
await box(page,first.id).check();await addTags(page,'Studio-Lit');
await expect(toast(page)).toHaveText('1 prompt already had “Studio-Lit”.');
});
test('the Add tags field states what it refuses, and a restore the page did not see adds nothing',async({page,request})=>{
await armUnseenRestores(page);
const items=await shelf(request);const writes=recordTagWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
const field=tagsDialog(page).locator('#action-input');
for(const [value,message] of [[' , ','Enter at least one tag.'],['x'.repeat(51),'A tag can have at most 50 characters.'],
[Array.from({length:21},(_,i)=>`t${i}`).join(','),'Enter at most 20 tags.']]) {
await field.fill(value);await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(tagsDialog(page)).toBeVisible();
expect(await field.evaluate(node=>node.validationMessage)).toBe(message);
}
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();
expect(writes).toHaveLength(0);
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
await expect(restore).toBeOK();
await addTags(page,'after-restore');
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No tags were added.');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
expect((await(await request.get('/prompt_manager/tags')).json()).tags).not.toContain('after-restore');
});
// #444 ruling (D151): select every loaded prompt, as the legacy page's select-all checkbox did for its page.
test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed ones included',async({page,request,isMobile})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
const writes=recordTagWrites(page);
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(let index=0;index<3;index++) {
await expect(more).toHaveAttribute('aria-disabled','false');
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(more).toHaveAttribute('aria-disabled','false');
const loaded=160;expect(await cards(page).count()).toBeLessThan(loaded);
// In the search box Ctrl/Cmd+A keeps its text meaning: it selects the box's text, no prompt.
await page.locator('#search').focus();await page.keyboard.press('ControlOrMeta+a');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
// One checked card offers the rest; the offer selects every loaded prompt and focus moves to the first action.
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,firstId).check();
const offer=bar(page).getByRole('button',{name:`Select all ${loaded} loaded`,exact:true});
await expect(bar(page).locator(':scope > *').nth(1)).toHaveText(`Select all ${loaded} loaded`);
await offer.click();
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
await expect(offer).toHaveCount(0);
await expect(bar(page).getByRole('button',{name:'Add tags',exact:true})).toBeFocused();
// Another batch arrives unchecked: the offer comes back with the new N.
{
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(bar(page).getByRole('button',{name:`Select all ${loaded+40} loaded`,exact:true})).toBeVisible();
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
// Escape in the grid clears; Ctrl/Cmd+A in the grid selects them all again.
const title=cards(page).first().locator('.card-title > button');
await title.focus();await page.keyboard.press('Escape');
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(title).toBeFocused();
await page.keyboard.press('ControlOrMeta+a');
const all=loaded+40;
await expect(bar(page).locator('strong')).toHaveText(`${all} selected`);
// While the write runs, every bar action and the offer are aria-disabled.
await box(page,firstId).uncheck();
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1});
await addTags(page,'everyone');
for(const name of ['Add tags','Set category',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
release();
await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`);
expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1);
});
+2 -1
View File
@@ -618,7 +618,8 @@ test('card matches the concept and never wraps the category mid-word',async({pag
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
// app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer.
await expect(card).toHaveClass('prompt-card');
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']);
// D148 (#437): the concept's Images selection overlay leads the card.
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['image-overlays','card-art','card-content','card-footer']);
expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']);
expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']);
expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']);
@@ -0,0 +1,236 @@
// #430: an open workspace page whose sources changed under it. A ComfyUI restart no longer changes any scope (the
// key is persisted, see tests/test_api_workspace_restart.py); what is left is a real source change. Renumbered
// sources (`/__test__/sources/reverse`) are found again: the Tag image dialog stays open on its file and works, and
// the panel keeps the file. A scope that cannot be mapped (`/__test__/scope-key/rotate`, a lost key) blocks Generate
// and Save visibly, says why beside them and offers Refresh. Both commands are undone by every reset.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
const images = '/prompt_manager/workspace#/images';
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png';
const SOURCE_CHANGED = 'The source changed. Refresh the image collection before tagging this file.';
const card = (page, path, root = 0) => page.locator(`article[data-image-key="${root}:${path}"]`);
const cards = page => page.locator('.gallery-body article[data-image-key]');
const tagButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
const infoButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
const primary = page => dialog(page).locator('.dialog-actions .ca-primary');
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
const status = page => dialog(page).locator('#tag-generation-status');
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const toast = page => page.locator('#workspace-toast');
const answered = (page, suffix, method = 'POST') => page.waitForResponse(item => new URL(item.url()).pathname.endsWith(suffix) && item.request().method() === method);
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
page.staleErrors = [];
page.on('pageerror', error => page.staleErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
expect(page.staleErrors).toEqual([]);
// The next spec file starts on the standard sources and key.
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
});
async function openTag(page, path = FIRST, root = 0) {
const observed = answered(page, '/image-annotations/observe');
await tagButton(page, path, root).click();
await expect(dialog(page)).toBeVisible();
return observed;
}
async function savedTags(request, path, root) {
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
const item = listing.images.find(image => image.path === path && image.root_index === root);
const target = { root_index: root, root_scope: item.root_scope, path };
const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
return observed.tags;
}
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 expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
expect((await openTag(page)).status()).toBe(200);
await manual(page).fill('kept_draft');
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
// Generate is refused (the held scope names another index now); the page asks where the file is and retargets.
const refused = answered(page, '/image-annotations/suggest');
const rescoped = answered(page, '/images/rescope');
const observedAgain = answered(page, '/image-annotations/observe');
await generate(page).click();
expect((await refused).status()).toBe(409);
const mapped = await (await rescoped).json();
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
const observed = await observedAgain;
expect(observed.status()).toBe(200);
expect(observed.request().postDataJSON().target).toEqual({ root_index: 1, root_scope: mapped.image.root_scope, path: FIRST });
await expect(dialog(page)).toBeVisible();
await expect(notice(page)).toHaveCount(0);
await expect(manual(page)).toHaveValue('kept_draft');
await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /\/image-annotations\/capture\//);
// The page listed again under the new numbering and kept the file selected.
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
await expect(card(page, FIRST, 0)).toHaveCount(0);
const suggested = answered(page, '/image-annotations/suggest');
await generate(page).click();
expect((await suggested).status()).toBe(200);
await expect(chips(page).first()).toBeVisible();
const saved = answered(page, '/image-annotations/tags', 'PUT');
await primary(page).click();
expect((await saved).request().postDataJSON().target.root_index).toBe(1);
await expect(toast(page)).toContainText('tags saved for dunes_00001.png.');
expect(await savedTags(request, FIRST, 1)).toContain('kept_draft');
});
test('the inspector finds a renumbered file again; checked files from the old numbering are cleared', async ({ page, request }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await card(page, FIRST).getByRole('checkbox', { name: /^Select / }).check();
await expect(page.locator('.bulkbar')).toBeVisible();
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const refused = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
new URL(item.url()).searchParams.get('root') === '0');
const reread = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
new URL(item.url()).searchParams.get('root') === '1');
await infoButton(page, SECOND).click();
expect((await refused).status()).toBe(409);
expect((await reread).status()).toBe(200);
await expect(card(page, SECOND, 1)).toHaveClass(/\bselected\b/);
await expect(panel(page)).not.toContainText('The source changed');
await expect(panel(page).getByRole('button', { name: 'Refresh image collection', exact: true })).toHaveCount(0);
// A checked key of the old list could name a same-named file in another source: nothing stays checked.
await expect(page.locator('.bulkbar')).toBeHidden();
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
});
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 expect(card(page, FIRST)).toBeVisible();
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
const rescoped = answered(page, '/images/rescope');
expect((await openTag(page)).status()).toBe(409);
expect((await rescoped).status()).toBe(409);
await expect(notice(page)).toContainText(SOURCE_CHANGED);
await expect(status(page)).toHaveText('');
const refresh = notice(page).getByRole('button', { name: 'Refresh image collection', exact: true });
await expect(refresh).toBeVisible();
for (const button of [generate(page), primary(page)]) {
await expect(button).toHaveAttribute('aria-disabled', 'true');
await expect(button).toHaveCSS('opacity', '0.55');
}
// The notice is its own first row of the sticky actions, above the buttons.
const rows = await dialog(page).locator('.dialog-actions').evaluate(actions => {
const noticeBox = actions.firstElementChild.getBoundingClientRect();
return { first: actions.firstElementChild.className, below: [...actions.querySelectorAll(':scope > button')].every(button => button.getBoundingClientRect().top >= noticeBox.bottom - 1) };
});
expect(rows).toEqual({ first: 'tag-action-notice', below: true });
const preview = dialog(page).locator('.tag-image-preview');
await expect(preview).toHaveText('Preview unavailable');
await expect(preview).toHaveAttribute('role', 'img');
const suggest = [];
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggest.push(item.url()); });
// Pressed anyway (Playwright refuses an aria-disabled target, so the press is dispatched), nothing is sent.
await generate(page).dispatchEvent('click');
await primary(page).dispatchEvent('click');
expect(suggest).toEqual([]);
await page.screenshot({ path: test.info().outputPath('stale-source-tag-dialog.png') });
// Refresh closes the dialog and lists again with the current scopes; Tag then works.
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
await refresh.click();
await expect(dialog(page)).toHaveCount(0);
await relisted;
await expect(card(page, FIRST)).toBeVisible();
expect((await openTag(page)).status()).toBe(200);
await expect(notice(page)).toHaveCount(0);
await expect(generate(page)).not.toHaveAttribute('aria-disabled', 'true');
});
test('an unmappable inspector read keeps its visible Refresh, and Refresh recovers the panel', async ({ page }) => {
await page.goto(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');
await infoButton(page, SECOND).click();
expect((await rescoped).status()).toBe(409);
await expect(panel(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
await panel(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();
await relisted;
await expect(cards(page).first()).toHaveClass(/\bselected\b/);
await expect(panel(page)).not.toContainText('The source changed');
});
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 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();
const stale = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.has('cursor'));
await page.locator('#gallery-continuation').scrollIntoViewIfNeeded();
const answer = await stale;
expect(answer.status()).toBe(400);
expect((await answer.json()).code).toBe('cursor-stale');
const continuation = page.locator('#gallery-continuation');
await expect(continuation).toContainText('The image collection changed. Refresh image collection to continue.');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && !new URL(item.url()).searchParams.has('cursor'));
await continuation.getByRole('button', { name: 'Refresh image collection', exact: true }).click();
expect((await relisted).status()).toBe(200);
await expect(continuation).not.toContainText('The image collection changed');
});
test('a viewer whose media source was renumbered closes onto the file, kept selected', async ({ page, request }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const rescoped = answered(page, '/images/rescope');
await card(page, FIRST).locator('.image-open').click();
const mapped = await (await rescoped).json();
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
// Opened again, it shows the file under its current source.
await card(page, FIRST, 1).locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open]')).toBeVisible();
await expect(page.locator('#viewer-dialog').getByText('Media could not be loaded. Retry this file.', { exact: true })).toBeHidden();
await expect.poll(() => page.locator('#viewer-dialog img').first().evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
});
test('a collection page finds a renumbered member again and stays on its members', async ({ page, request }) => {
// Members are stored by root path, not index (#331), so a renumbering keeps them; the page's scopes go stale.
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
const first = listing.images.find(image => image.path === FIRST && image.root_index === 0);
const name = `Stale ${test.info().workerIndex}-${Date.now()}`;
const added = await request.post('/prompt_manager/collections/items', { data: { name, items: [
{ root_index: 0, root_scope: first.root_scope, path: FIRST }] } });
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 expect(cards(page)).toHaveCount(1);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const rescoped = answered(page, '/images/rescope');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('collection') === String(id));
await card(page, FIRST).locator('.image-open').click();
expect((await (await rescoped).json()).image.root_index).toBe(1);
expect((await relisted).status()).toBe(200);
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
await expect(cards(page)).toHaveCount(1);
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
// Tag image works on the member under its new source.
expect((await openTag(page, FIRST, 1)).status()).toBe(200);
await expect(notice(page)).toHaveCount(0);
});
@@ -1,6 +1,7 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
import { followRestore, SEEN_RESTORE_PILL, SEEN_RESTORE_TOAST } from './unseen-restore.mjs';
// System › Backup & export (#249 System S9 Task 24) on the S8 backend and the concept-shaped fixture: Back up and its
// status line (D103), the restore review (D105) and its job, the exports, Import / inspect and the recovery policy
@@ -265,6 +266,24 @@ test('a finished restore shows the open page again from the restored data', asyn
expect(await libraryTotal(request)).toBe(restoredTotal);
});
// #415: an open dialog waits out a finished restore, except a review: the plan it staged no longer describes storage,
// so it closes as dismissed (its stage discarded), as before.
test('a restore seen finishing elsewhere closes an open restore review and discards its stage', async ({ page, request }) => {
const bytes = await backupBytes(request);
const restoreRun = await followRestore(page);
await openBackups(page);
await expect(page.locator('#activity-pill')).toHaveAttribute('href', SEEN_RESTORE_PILL);
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
const discarded = page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) && response.request().method() === 'DELETE');
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
expect((await discarded).status()).toBe(200);
await expect(dialog(page)).toHaveCount(0);
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
});
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/system/digest');
await openBackups(page);
@@ -16,7 +16,7 @@ const toasts = page => page.evaluate(() => window.__toasts);
const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion');
const actionButtons = page => modal(page).locator('.dialog-actions button');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.';
test.beforeEach(async ({ page, request, browserName }) => {
@@ -73,7 +73,7 @@ const jobStart = page => page.waitForRequest(item => item.method() === 'POST' &&
test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => {
await select(page, FIRST);
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
});
test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => {
@@ -110,7 +110,7 @@ test('Add tags runs a job and the cards show the tags after it finishes', async
await expect(bar(page).locator('strong')).toHaveText('2 selected');
expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 });
// The finished job's files are read again: the selected card's panel shows its new image tags.
await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']);
expect(await savedTags(request, UNLINKED)).toEqual([]);
});
@@ -275,7 +275,7 @@ test('Review each image walks the selection one dialog at a time and only approv
expect(await savedTags(request, SECOND)).toEqual([]);
expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']);
// The reviewed file's panel shows its saved tags without reselecting.
await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0);
});
+66 -21
View File
@@ -21,6 +21,8 @@ const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model'
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
const status = page => dialog(page).locator('#tag-generation-status');
// D147: the reason a state blocks Save, on its own row in the sticky actions.
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
const toast = page => page.locator('#workspace-toast');
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
@@ -456,27 +458,32 @@ test('virtualized cards release listeners on unmount', async ({ page, request })
expect(await count()).toBe(mounted);
});
// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
// Task C3 (D88), #449 (D154): the side panel and the viewer details draw the file's own current image tags in their own
// "Image tags" section, then D21's linked tags in a "Prompt tags" or "Recipe tags" section after it. Panel image tags are
// Tags filter buttons; the viewer's chips are plain.
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const linkedTags = page => panel(page).locator('.tag-section.linked-tags > .tags');
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
// D21/D39: the linked recipe's own tag, plain.
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
// D21/D39: the linked recipe's own tag, plain, and no image tags yet.
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('button.chip')).toHaveCount(0);
await expect(panel(page).locator('.tag-section.image-tags')).toHaveCount(0);
await openTag(page, FIRST, { goto: false });
await manual(page).fill('dunes_tag, Editorial');
const reread = inspected(page, FIRST);
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
// The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
// The image tags lead as filter buttons in their own section; the recipe keeps its "editorial" in its own (D154).
await expect(imageTags(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(panel(page).locator('.tag-section.image-tags .role-label')).toHaveText('Image tags · 2');
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
});
@@ -488,9 +495,10 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
await infoButton(page, UNLINKED).click();
await read;
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
await expect(imageTags(page).locator('.chip')).toHaveText(['loose_tag']);
await expect(panel(page).locator('.tag-section.linked-tags')).toHaveCount(0);
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await listing;
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
await expect(card(page, UNLINKED)).toBeVisible();
@@ -500,18 +508,53 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
test('viewer details show the same chips', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
await page.goto(images);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const row = details.locator('.tags').last();
const row = details.getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(row.locator('span.chip')).toHaveCount(3);
await expect(row.locator('button')).toHaveCount(0);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
// A file without image tags: the viewer shows only D21's linked tags.
await page.getByRole('button', { name: 'Next media', exact: true }).click();
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
await expect(details.locator('.tag-section.image-tags')).toHaveCount(0);
});
// #449 (D154): the image tags are a labelled region of their own after the negative prompt, framed like a prompt section,
// so they never read as part of it; the same in the side panel and the viewer details.
async function tagSectionPlacement(root) {
return root.evaluate(node => {
const negative = node.querySelector('section.prompt-section.negative'), tags = node.querySelector('section.tag-section.image-tags');
const style = getComputedStyle(tags), label = getComputedStyle(tags.querySelector('.prompt-heading .role-label'));
return {
after: Boolean(negative.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING), inside: negative.contains(tags),
below: tags.getBoundingClientRect().top >= negative.getBoundingClientRect().bottom,
border: `${style.borderTopWidth} ${style.borderTopStyle}`, paddingTop: style.paddingTop, marginTop: style.marginTop,
label: {transform: label.textTransform, size: label.fontSize, sameColorAsNegative: label.color === getComputedStyle(negative.querySelector('.role-label')).color},
};
});
}
const PLACED = {after: true, inside: false, below: true, border: '1px solid', paddingTop: '15px', marginTop: '15px',
label: {transform: 'uppercase', size: '10px', sameColorAsNegative: false}};
test('D154: image tags sit in their own labelled section after the negative prompt, in the panel and the viewer', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'sunlit']);
await page.goto(images);
const section = panel(page).getByRole('region', { name: 'Image tags', exact: true });
await expect(section.locator('.role-label')).toHaveText('Image tags · 2');
await expect(section.locator('.tags > button.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(panel(page))).toEqual(PLACED);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const viewerSection = details.getByRole('region', { name: 'Image tags', exact: true });
await expect(viewerSection.locator('.role-label')).toHaveText('Image tags · 2');
await expect(viewerSection.locator('.tags > span.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(details)).toEqual(PLACED);
});
test('search finds an image by its image tag', async ({ page, request }) => {
@@ -568,7 +611,7 @@ const listingOf = (page, check) => page.waitForResponse(item => item.url().inclu
test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await page.evaluate(() => {
const node = document.querySelector('aside[aria-label="Selected image details"]');
window.__loadingSeen = false;
@@ -587,12 +630,13 @@ test('the post-save re-read keeps the panel content until the new read lands', a
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
// While the re-read is held, the panel still shows the file's details.
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
const landed = inspected(page, FIRST);
release();
await landed;
await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
await expect(imageTags(page).locator('.chip')).toHaveText(['held_tag']);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
await page.unroute(matcher);
});
@@ -605,7 +649,7 @@ test('a save under a Tags filter re-reads the listing: the selection stays while
await infoButton(page, SECOND).click();
await read;
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
// The newest match (SECOND) is selected once the filter applies.
@@ -728,7 +772,8 @@ test('D116: Retry leads the actions after an unconfirmed save and resends the sa
let lost = true;
await page.route('**/prompt_manager/image-annotations/tags', route => (lost ? (lost = false, route.fulfill({ status: 504, body: 'Gateway Timeout' })) : route.continue()));
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
await expect(status(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
await expect(notice(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
await expect(status(page)).toHaveText('');
const inventory = await page.locator('dialog#modal').evaluate(taggingInventory);
expect(inventory.actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
@@ -764,7 +809,7 @@ test('a tag save from Tags reloads a filtered Images visit instead of restoring
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
@@ -789,7 +834,7 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await openTag(page, SECOND, { goto: false });
+26 -1
View File
@@ -1,7 +1,7 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
@@ -552,6 +552,31 @@ test('conflict and restore states keep the dialog and write nothing',async({page
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
});
// #415: a restore the page sees finish while Create tag is open keeps the dialog and its entries; the page is shown
// again from the restored data only once the dialog has closed.
test('a restore seen finishing keeps an open dialog and reloads the page after it closes',async({page,request})=>{
const saved=await backup(request);
await expect(await request.put('/prompt_manager/tags/mist',{data:{new_name:'fog'}})).toBeOK();
const restoreRun=await followRestore(page);const writes=trackWrites(page);await openPage(page);
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
await expect(tagRow(page,'fog')).toHaveCount(1);
const box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
await field(box,'Tag name').fill('sunrise');
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'vocabulary.db',mimeType:'application/octet-stream',buffer:saved}}})).toBeOK();
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
await expect(box).toBeVisible();await expect(field(box,'Tag name')).toHaveValue('sunrise');
await expect(tagRow(page,'fog')).toHaveCount(1);
await button(box,'Apply').click();
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
expect(await actionNames(box)).toEqual(['Reload','Cancel','Apply']);
expect(tagNames(await snapshot(request))).not.toContain('sunrise');
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
await expect(tagRow(page,'mist')).toHaveCount(1);await expect(tagRow(page,'fog')).toHaveCount(0);
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
});
test('lost response is outcome-unknown',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);
await page.route('**/prompt_manager/vocabulary/tags/*',async route=>{
+10 -2
View File
@@ -194,9 +194,16 @@ def tagging_snapshot(api: PromptManagerAPI) -> dict:
def register_tagging_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
routes: web.RouteTableDef,
api: PromptManagerAPI,
root: Path,
model,
on_reset=lambda: None,
) -> None:
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input.
``on_reset`` runs first on every tagging reset (the source-change undo).
"""
# The fake download (``/__test__/tagging/download``): waits on ``event``,
# then answers ``result``. Every reset sets the event and puts back the
# model's raising default, so no test can leave a worker hanging.
@@ -230,6 +237,7 @@ def register_tagging_commands(
raise web.HTTPBadRequest()
if not 0 <= extra <= MAX_EXTRA_IMAGES:
raise web.HTTPBadRequest()
on_reset()
model.unavailable = set()
model.missing_packages = {}
model.suggestions = None
+13 -9
View File
@@ -263,15 +263,18 @@ test.describe('public chronological history with the actual acknowledged host br
});
});
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
// #403: the graph cannot show a dynamic positive's raw text; a cached job takes it from the execution it reuses.
test('a cached dynamic positive takes its raw text from the execution it reuses and joins the Library scope', async ({ execution }) => {
const text = `dynamic positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
const second = await submit(execution.request, graph);await history(execution.request, second);const reused = await capture(execution, second);const cached = reused.contexts[0];
expect(cached.evidence.positive.effective.source).toBe('host_cache_output');expect(cached.positive.effective).toBe(known.positive.effective);
expect(cached.positive.raw).toBe(known.positive.raw);expect(cached.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cached.positive_prompt_id).toBe(known.positive_prompt_id);
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBe(known.positive.raw);
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
expect(window.recorded_run_count).toBe(2);expect(window.items.map(item => item.run_id)).toContain(runId);
});
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
@@ -369,7 +372,7 @@ test('successful source capture survives downstream failure and real interruptio
}
});
test('cached dynamic raw stays unknown while literal affixes and effective cache text survive', async ({ execution }) => {
test('cached dynamic raw comes from the reused execution while literal affixes and effective cache text survive (#403)', async ({ execution }) => {
const graph = recipeGraph({ dynamic: true, text: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ' });
const first = await submit(execution.request, graph);
await history(execution.request, first);
@@ -377,8 +380,9 @@ test('cached dynamic raw stays unknown while literal affixes and effective cache
const second = await submit(execution.request, graph);
await history(execution.request, second);
const value = (await capture(execution, second)).contexts[0];
expect(value.positive).toEqual({ raw: null, prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw.provenance).toBe('unknown');
expect(value.positive).toEqual({ raw: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(value.evidence.positive.prepend.source).toBe('graph_literal');
expect(value.evidence.positive.effective.source).toBe('host_cache_output');
});
+20 -2
View File
@@ -287,6 +287,14 @@ async function capturedContext(execution, id) {
return result.captures[result.rows[0].run_id].contexts[0];
}
// #403: the prompt's history "tracked runs", once every job's capture reached SQLite.
async function expectTrackedRuns(execution, promptId, expected) {
await expect.poll(async () => {
const response = await execution.request.get(`/prompt_manager/library/${promptId}/history`);
return response.ok() ? (await response.json()).recorded_run_count : null;
}, { timeout: 10000, message: 'history tracked runs equal the Library run count' }).toBe(expected);
}
async function firstRun(execution, page, testCase) {
const prefix = `textlink/${randomUUID()}`;
const built = await build(page, { ...testCase, prefix });
@@ -309,6 +317,8 @@ for (const kind of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit
const counts = await runCounts(execution, first.context);
expect(counts.positive).toBe(3);
if (pair) expect(counts).toMatchObject({ negative: 0, pair: 3 });
// #403: the cached jobs are tracked runs of the prompt too
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
});
}
@@ -329,6 +339,7 @@ test.describe('cached runs in one renderer (#347)', () => {
else expect(again.cached).toContain(first.promptNode);
}
expect((await runCounts(execution, first.context)).positive).toBe(3);
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
}
@@ -352,6 +363,9 @@ test.describe('cached runs in one renderer (#347)', () => {
const bContext = await capturedContext(execution, second.id);
expect(bContext.positive.raw).toBe(b);
expect((await runCounts(execution, bContext)).positive).toBe(1);
// #403: the third job reuses A's execution, not the newest (B's)
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
await expectTrackedRuns(execution, bContext.positive_prompt_id, 1);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
@@ -390,10 +404,14 @@ test.describe('cached runs in one renderer (#347)', () => {
} });
expect(rewired.cached).toContain(first.promptNode);
expect((await runCounts(execution, first.context)).positive).toBe(1);
// The history capture of a cached job is unchanged: effective text from the cache, no stored-prompt hint
// #403: a cached job's history takes the raw text of the execution it reuses and names the prompt
const cachedContext = await capturedContext(execution, (await queueAgain(execution, first, { seed: 3 })).id);
expect(cachedContext.positive.effective).toBe(first.context.positive.effective);
expect(cachedContext.positive_prompt_id).toBeNull();
expect(cachedContext.positive.raw).toBe(first.context.positive.raw);
expect(cachedContext.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cachedContext.positive_prompt_id).toBe(first.context.positive_prompt_id);
expect((await runCounts(execution, first.context)).positive).toBe(2);
// The rewired job used the prompt as its negative: neither number counts it
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
});
});
+1
View File
@@ -121,6 +121,7 @@
"POST /prompt_manager/images/generate-thumbnails",
"POST /prompt_manager/images/generate-thumbnails/progress",
"POST /prompt_manager/images/link",
"POST /prompt_manager/images/rescope",
"POST /prompt_manager/images/reveal",
"POST /prompt_manager/jobs/autotag",
"POST /prompt_manager/jobs/duplicates",
+341 -5
View File
@@ -209,16 +209,18 @@ test('a branch named by recorded run history shows the Recorded run badge', asyn
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
return {...record(), imageTags, library};
}
test('renders current image tags before linked tags without duplicates', async () => {
test('renders current image tags before linked tags, each without repeats within its source', async () => {
const {inspectorTagChips} = await load();
assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
[{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
// D154 amends D88: a linked tag that is also an image tag stays in its own labelled section.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal', 'Dunes']}), {filterable:true}),
[{name:'dunes', filter:true, source:'image'}, {name:'minimal', filter:true, source:'image'},
{name:'Minimal', filter:true, source:'linked'}, {name:'warm_light', filter:true, source:'linked'}, {name:'editorial', filter:true, source:'linked'}]);
// D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
[{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
[{name:'Editorial', filter:true, source:'image'}, {name:'editorial', filter:false, source:'linked'}, {name:'cover', filter:false, source:'linked'}]);
// An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true, source:'image'}]);
assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
});
@@ -236,6 +238,61 @@ test('none and unavailable render no image chips', async () => {
}
});
// #449 (D154): the image's own tags and the linked prompt's or recipe's tags each get a labelled section after the
// prompt blocks, so per-image tags never read as part of the negative prompt.
function renderVariant(record, variant, options = {}) {
const f = mountedDom(), scope = createScope();
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, ...options});
view.setState({loading:false, error:false, record});
return {f, done() {view.dispose(); scope.dispose(); f.restore();}};
}
const tagSections = f => f.walk(f.container, n => n.tagName === 'section' && /(^| )tag-section( |$)/.test(n.className ?? ''));
function sectionSummary(f, section) {
const label = f.walk(section, n => /(^| )role-label( |$)/.test(n.className ?? ''))[0];
const chips = f.walk(section, n => /(^| )chip( |$)/.test(n.className ?? ''));
return {className:section.className, name:section.getAttribute('aria-label'), label:label?.textContent, chips:chips.map(chip => `${chip.tagName}:${chip.textContent}`)};
}
test('chips name their source: the image or the linked prompt', async () => {
const {inspectorTagChips, tagSectionLabel} = await load();
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}).map(chip => chip.source),
['image', 'image', 'linked', 'linked', 'linked']);
assert.equal(tagSectionLabel('image', {tags_scope:'pair'}), 'Image tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'positive'}), 'Prompt tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'pair'}), 'Recipe tags');
});
test('image tags and linked tags render in their own labelled sections', async () => {
for (const [variant, options, tag] of [['panel', {onTagFilter() {}}, 'button'], ['full', {}, 'span']]) {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes', 'minimal']}), variant, options);
try {
const sections = tagSections(f).map(section => sectionSummary(f, section));
assert.deepEqual(sections, [
{className:'tag-section image-tags', name:'Image tags', label:'Image tags · 2', chips:[`${tag}:dunes`, `${tag}:minimal`]},
{className:'tag-section linked-tags', name:'Prompt tags', label:'Prompt tags · 3', chips:[`${tag}:Minimal`, `${tag}:warm_light`, `${tag}:editorial`]},
], variant);
// No bare chip row follows the prompt blocks any more.
const inner = f.container.children.find(n => n.tagName === 'div');
assert.equal(inner.children.filter(n => n.className === 'tags').length, 0, variant);
} finally { done(); }
}
});
test('a recipe names its tags and the context names the regions', async () => {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes']}, {status:'linked', tags:['cover'], tags_scope:'pair'}), 'panel', {promptContext:'Current file'});
try {
assert.deepEqual(tagSections(f).map(section => sectionSummary(f, section)), [
{className:'tag-section image-tags', name:'Current file image tags', label:'Image tags · 1', chips:['span:dunes']},
{className:'tag-section linked-tags', name:'Current file recipe tags', label:'Recipe tags · 1', chips:['span:cover']},
]);
} finally { done(); }
});
test('an empty tag source draws no section', async () => {
let view = renderVariant(tagged(null), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Prompt tags · 3']); } finally { view.done(); }
view = renderVariant(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Image tags · 1']); } finally { view.done(); }
view = renderVariant(tagged({state:'none', tags:[]}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.equal(tagSections(view.f).length, 0); } finally { view.done(); }
});
// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata
// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the
// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values.
@@ -384,6 +441,285 @@ test('the D117 wrap rule targets only the settings summary button', async () =>
assert.doesNotMatch(css, /#viewer-details > div > button/);
});
// #331 D139 (design §8): the shared inspector's "Collections…" footer button and its checklist dialog.
const collectionsDialog = await import('../../web/js/workspace/inspector-collections.mjs').catch(() => null);
const dialogModule = () => { assert.ok(collectionsDialog, 'inspector-collections.mjs is not implemented'); return collectionsDialog; };
const COLLECTION_SCOPE = 'a'.repeat(64);
const FILE = {root_index:2, root_scope:COLLECTION_SCOPE, path:'a.png'};
function collectionsStore({member = [3], membership, add, remove} = {}) {
const calls = [];
const store = {
calls,
collections:[{id:3, name:'LoRa testing', count:3, missing:0}, {id:5, name:'Portraits', count:1, missing:0}],
membership: async target => { calls.push(['membership', target]); return membership ? membership() : [...member]; },
add: async (where, items) => { calls.push(['add', where, items]); if (add) return add(where, items);
return {collection:{id:where.collectionId ?? 9, name:where.name ?? 'x'}, created:!!where.name, added:1, already:0, notFound:0}; },
remove: async (id, items) => { calls.push(['remove', id, items]); if (remove) return remove(id, items);
return {collection:{id, name:'x'}, removed:1}; },
settled: async () => ({}),
};
return store;
}
function footerButtons(f) {
return f.walk(f.container, n => n.tagName === 'button').map(n => n.textContent.trim());
}
test('inspector shows Collections… only for a rooted file with a store', async () => {
for (const variant of ['panel', 'full']) {
const f = mountedDom(), scope = createScope();
try {
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, collections:collectionsStore()});
view.setState({loading:false, error:false, record:{...record(), historyTarget:'#/library/1'}});
const labels = footerButtons(f);
assert.ok(labels.includes('Collections…'), `${variant}: the button is offered`);
// After Prompt history, as the last footer action.
assert.equal(labels.at(-1), 'Collections…');
assert.equal(labels.at(-2), 'Prompt history');
const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent.trim() === 'Collections…')[0];
assert.equal(button.children[0].tagName, 'svg', 'with the folder icon');
view.dispose();
} finally { scope.dispose(); f.restore(); }
}
// No store (Prompt library galleries, Inspect a file) or a file that is not a listed one: no button.
const local = await localRecord(localPng('Fox Render.png'));
for (const [shown, store] of [[record(), null], [local, collectionsStore()],
[{...record(), identity:{...record().identity, root_scope:null}}, collectionsStore()]]) {
const f = mountedDom(), scope = createScope();
try {
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full', collections:store});
view.setState({loading:false, error:false, record:shown});
assert.ok(!footerButtons(f).includes('Collections…'));
view.dispose();
} finally { scope.dispose(); f.restore(); }
}
});
/** Open the Collections dialog over FILE; returns its nodes by role and helpers. */
function openCollections(store, extra = {}) {
const {openCollectionsDialog} = dialogModule();
const f = mountedDom(), scope = createScope(), toasts = [], changes = [];
let dialog = null, closed = 0;
openCollectionsDialog({scope, collections:store, target:FILE, trigger:null, notify:text => toasts.push(text),
onChanged:change => changes.push(change), openDialog:args => { dialog = args; f.container.append(...args.nodes); },
// As the shell does: closing runs the dialog's onClose once.
closeDialog:() => { closed++; dialog?.onClose?.(); }, ...extra});
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
const find = predicate => f.walk(f.container, predicate);
const boxes = () => find(n => n.tagName === 'input' && n.type === 'checkbox');
const button = text => find(n => n.tagName === 'button' && n.textContent === text)[0];
const text = () => find(n => n.tagName === 'p').map(n => n.textContent).filter(Boolean);
return {f, scope, toasts, changes, get dialog() { return dialog; }, get closed() { return closed; }, settle, find, boxes, button, text,
done() { scope.dispose(); f.restore(); }};
}
test('the Collections dialog loads the membership and lists every collection', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
assert.equal(view.dialog.title, 'Collections');
assert.ok(view.text().includes('Loading collections…'));
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
await view.settle();
assert.deepEqual(store.calls, [['membership', FILE]]);
const labels = view.find(n => n.tagName === 'label' && n.className === 'tag-suggestion');
assert.deepEqual(labels.map(label => label.textContent), ['LoRa testing', 'Portraits']);
assert.deepEqual(view.boxes().map(box => box.checked), [true, false]);
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
assert.equal(field.maxLength, 100);
assert.equal(view.find(n => n.tagName === 'label' && n.htmlFor === field.id)[0].textContent, 'New collection');
assert.deepEqual(view.find(n => n.className === 'dialog-actions')[0].children.map(n => n.textContent), ['Cancel', 'Save']);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
} finally { view.done(); }
});
test('without collections the dialog says to name one', async () => {
const store = collectionsStore({member:[]});
store.collections = [];
const view = openCollections(store);
try {
await view.settle();
assert.ok(view.text().includes('No collections yet. Name one below.'));
assert.equal(view.boxes().length, 0);
} finally { view.done(); }
});
test('a failed membership read offers Retry', async () => {
let failing = true;
const store = collectionsStore({membership:() => { if (failing) throw new TypeError('offline'); return [5]; }});
const view = openCollections(store);
try {
await view.settle();
assert.ok(view.text().includes('Collections could not be loaded.'));
assert.equal(view.button('Retry').hidden, false);
assert.equal(view.boxes().length, 0);
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
failing = false;
view.button('Retry').fire('click');
await view.settle();
assert.deepEqual(view.boxes().map(box => box.checked), [false, true]);
assert.equal(view.button('Retry').hidden, true);
} finally { view.done(); }
});
test('Save sends the new name, each added and each removed collection, in that order', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' Grids ';
view.button('Save').fire('click');
await view.settle(); await view.settle();
assert.deepEqual(store.calls.slice(1), [
['add', {name:'Grids'}, [FILE]],
['add', {collectionId:5}, [FILE]],
['remove', 3, [FILE]],
]);
assert.equal(view.closed, 1);
assert.deepEqual(view.toasts, ['Collections updated.']);
assert.deepEqual(view.changes, [{added:[9, 5], removed:[3]}]);
} finally { view.done(); }
});
test('Save without a change sends nothing and closes', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' ';
view.button('Save').fire('click');
await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership']);
assert.equal(view.closed, 1);
assert.deepEqual(view.toasts, []);
assert.deepEqual(view.changes, []);
} finally { view.done(); }
});
test('a failed Save keeps the dialog, says so and shows the current membership', async () => {
let reads = 0;
const store = collectionsStore({membership:() => (reads++ ? [5] : [3]),
remove:() => { throw Object.assign(new Error('x'), {status:500}); }});
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
view.button('Save').fire('click');
// While it saves, × and Escape keep the dialog and Save is busy.
assert.equal(view.dialog.canClose(), false);
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
for (let index = 0; index < 4; index++) await view.settle();
assert.equal(view.closed, 0);
assert.deepEqual(view.toasts, []);
const alert = view.find(n => n.getAttribute?.('role') === 'alert')[0];
assert.equal(alert.textContent, 'The collections could not be updated. Try again.');
// The add went through; the list shows what the server holds now. Review M2: the page hears about it only once
// the dialog closes, so a listing refresh cannot dispose the dialog before its failure is read.
assert.deepEqual(view.changes, []);
assert.deepEqual(view.boxes().map(box => box.checked), [false, true]);
assert.equal(view.dialog.canClose(), true);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
view.button('Cancel').fire('click');
assert.deepEqual(view.changes, [{added:[5], removed:[]}]);
} finally { view.done(); }
});
test('a name the store cannot take is refused on the field, keeping the edits (review L5)', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
const validity = [];
field.setCustomValidity = text => validity.push(text);
field.reportValidity = () => validity.push('reported');
field.value = 'bad\u0001name';
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership'], 'nothing sent and no re-read');
assert.deepEqual(validity, ['The collections could not be updated. Try again.', 'reported']);
assert.deepEqual(view.boxes().map(box => box.checked), [false, true], 'the checkbox edits stay');
assert.equal(view.closed, 0);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
// Editing the field clears its message.
field.fire('input');
assert.equal(validity.at(-1), '');
} finally { view.done(); }
});
test('after a failed Save, Save waits for the membership re-read (review M1)', async () => {
let release;
const store = collectionsStore({remove:() => { throw new Error('x'); }});
store.settled = () => new Promise(resolve => { release = resolve; });
const view = openCollections(store);
try {
await view.settle();
view.boxes()[0].checked = false;
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.equal(typeof release, 'function', 'the store is settling');
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
assert.ok(view.text().includes('Loading collections…'));
const sent = store.calls.length;
view.button('Save').fire('click');
await view.settle();
assert.equal(store.calls.length, sent, 'no stale plan is sent');
release({});
for (let index = 0; index < 3; index++) await view.settle();
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
} finally { view.done(); }
});
test('a typed name of an existing collection is that collection checkbox (review L4)', async () => {
// "portraits" names Portraits (not a member): one add by id, no name request.
let store = collectionsStore();
let view = openCollections(store);
try {
await view.settle();
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' PORTRAITS ';
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.deepEqual(store.calls.slice(1), [['add', {collectionId:5}, [FILE]]]);
} finally { view.done(); }
// Unchecking LoRa testing while typing its name keeps it: nothing to send.
store = collectionsStore();
view = openCollections(store);
try {
await view.settle();
view.boxes()[0].checked = false;
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = 'lora testing';
view.button('Save').fire('click');
await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership']);
assert.equal(view.closed, 1);
} finally { view.done(); }
});
test('focus starts on the field, moves to the first checkbox once listed, and falls back on close (review L6)', async () => {
const trigger = {isConnected:false, focus() { assert.fail('a detached trigger is not focused'); }};
const fallback = {focused:false, focus() { this.focused = true; }};
const view = openCollections(collectionsStore(), {trigger, focusFallback:() => fallback});
try {
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
assert.equal(view.dialog.focusTarget, field);
await view.settle();
assert.equal(view.boxes()[0].focused, true);
view.button('Cancel').fire('click');
assert.equal(fallback.focused, true);
} finally { view.done(); }
const empty = collectionsStore({member:[]});
empty.collections = [];
const none = openCollections(empty);
try {
await none.settle();
assert.equal(none.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].focused, true);
} finally { none.done(); }
});
test('category chip names its origin visibly and shows an own category for any link status (#332 D146)', async () => {
const base = await localRecord(localPng('a.png'));
const chipTexts = record => {
+66 -3
View File
@@ -253,15 +253,16 @@ test('an unreadable or malformed models read falls back to the documented defaul
test('observe failures and malformed answers end in observe-error and retry observes again', async () => {
const { createImageTaggingController } = await load();
let answer = () => fail(409, 'source-changed', 'The source changed. Refresh the image collection before tagging this file.');
let answer = () => fail(500, 'observe-failed');
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answer() }));
const c = createImageTaggingController({ api: h.api, target: TARGET });
await c.open();
assert.equal(c.state.phase, 'observe-error');
assert.equal(c.state.statusText, 'The source changed. Refresh the image collection before tagging this file.');
assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
assert.equal(c.state.canSave, false);
assert.equal(c.state.canGenerate, false);
assert.equal(c.state.canRetry, true);
assert.equal(c.state.blocked, true, 'a failed capture blocks Generate and Save with its reason');
answer = () => response(200, observed({ tag_revision: -1 }));
await c.retry();
assert.equal(c.state.phase, 'observe-error');
@@ -274,9 +275,71 @@ test('observe failures and malformed answers end in observe-error and retry obse
await c.retry();
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.blocked, false);
assert.equal(h.of('POST', OBSERVE).length, 4);
});
test('#430: a changed source blocks the dialog until it is retargeted, keeping the draft', async () => {
const { createImageTaggingController } = await load();
const SOURCE = 'The source changed. Refresh the image collection before tagging this file.';
const fresh = { root_index: 1, root_scope: 'e'.repeat(64), path: TARGET.path };
const h = harness(defaultRoutes({
[`POST ${OBSERVE}`]: call => (call.body.target.root_scope === SCOPE ? fail(409, 'source-changed', SOURCE) : response(200, observed())),
}));
const c = createImageTaggingController({ api: h.api, target: TARGET });
await c.open();
assert.equal(c.state.phase, 'source-changed');
assert.equal(c.state.statusText, SOURCE);
assert.deepEqual([c.state.canSave, c.state.canGenerate, c.state.canRetry, c.state.blocked], [false, false, false, true]);
assert.equal(await c.retry(), false, 'the same stale target is never observed again');
assert.equal(h.of('POST', OBSERVE).length, 1);
assert.throws(() => c.retarget({ ...fresh, root_scope: 'bad' }), TypeError);
await c.retarget(fresh);
await settle();
assert.equal(c.state.phase, 'ready');
assert.deepEqual(h.of('POST', OBSERVE).at(-1).body.target, fresh);
assert.deepEqual({ ...c.target }, fresh);
// Generate and Save now carry the new target.
c.setManual('kept draft');
await c.generate();
assert.deepEqual(h.of('POST', SUGGEST)[0].body.target, fresh);
});
test('#430: a source change answered to Generate or Save also blocks; retarget keeps the typed draft', async () => {
for (const route of [SUGGEST, TAGS]) {
const method = route === TAGS ? 'PUT' : 'POST';
let stale = true;
const { c, h } = await opened({
[`${method} ${route}`]: call => (stale ? fail(409, 'source-changed', 'server says source') :
route === TAGS ? response(200, receipt(call.body.tags)) : response(200, suggested())),
});
c.setManual('kept draft');
if (route === TAGS) await c.save(); else await c.generate();
assert.equal(c.state.phase, 'source-changed');
assert.equal(c.state.statusText, 'server says source');
stale = false;
await c.retarget({ root_index: 2, root_scope: 'f'.repeat(64), path: TARGET.path });
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.manual, 'kept draft');
assert.equal(h.of('DELETE', CAPTURE).length, 1, 'the old capture is released');
}
});
test('#430: retarget waits for nothing: it refuses while a save is in flight and after close', async () => {
const gate = deferred();
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => gate.promise });
const saving = c.save();
await flush();
assert.equal(c.state.phase, 'saving');
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
gate.resolve(response(200, receipt(['Minimal'])));
await saving;
c.cancel();
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
assert.equal(h.of('POST', OBSERVE).length, 1);
});
test('model change clears suggestions count and status but keeps manual', async () => {
const { c } = await opened();
c.setManual('Minimal, dune');
@@ -547,7 +610,7 @@ test('write errors keep the draft and allow another save', async () => {
});
test('storage-changed and file-changed disable save until reopen', async () => {
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'file-changed'], ['capture-expired', 'capture-expired']]) {
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'source-changed'], ['capture-expired', 'capture-expired']]) {
const message = `server says ${code}`;
let answer = () => fail(409, code, message);
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => answer() });
@@ -59,3 +59,9 @@ test('the toolbar list merges prompt and image categories once each', async () =
assert.deepEqual(mergeCategories(['A', '', ' ', 7], null), ['A']);
assert.deepEqual(mergeCategories(undefined, ['landscapes', 'Landscapes']), ['landscapes', 'Landscapes']);
});
test('the outcome names what was set: images by default, prompts for the Library bar (#437)', async () => {
const {setOutcome} = await source;
assert.equal(setOutcome({updated:1, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 1 prompt.');
assert.equal(setOutcome({updated:2, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 2 prompts.');
});
+285
View File
@@ -0,0 +1,285 @@
// The Images selection bar's collection actions (#331 D138, design §7): Add to collection after Set category, Remove
// from collection beside it while a collection filter is active, the 500 cap and every toast of §7.4.
import test from 'node:test';
import assert from 'node:assert/strict';
// Guarded: before the module exists every test fails on an assertion, not on the import.
const loaded = await import('../../web/js/workspace/images-collections.mjs').catch(() => null);
const module = () => { assert.ok(loaded, 'images-collections.mjs is not implemented'); return loaded; };
const { mountSelectionBar } = await import('../../web/js/workspace/images-selection.mjs');
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
const strings = await import('../../web/js/workspace/collections.mjs');
const SCOPE = 'b'.repeat(64);
const image = (path, extra = {}) => ({ root_index: 0, root_scope: SCOPE, path, filename: path.split('/').pop(), ...extra });
const target = path => ({ root_index: 0, root_scope: SCOPE, path });
const images = count => Array.from({ length: count }, (_, index) => image(`set/${index}.png`));
const keyOf = item => `${item.root_index}:${item.path}`;
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
const coded = (code, status = 400) => Object.assign(new Error(code), { name: 'ApiError', code, status });
/** A minimal DOM for the selection bar: elements with order, attributes, listeners, focus and a toast. */
function fakeDOM() {
const previous = globalThis.document;
let active = null;
class Element extends EventTarget {
constructor(tag) {
super();
this.tagName = tag.toUpperCase();
this.children = [];
this.parentNode = null;
this.attributes = {};
this.dataset = {};
this.className = '';
this.id = '';
this._text = '';
}
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
get nextElementSibling() {
const siblings = this.parentNode?.children ?? [];
return siblings[siblings.indexOf(this) + 1] ?? null;
}
append(...nodes) {
for (const node of nodes) { node.remove(); node.parentNode = this; this.children.push(node); }
}
after(node) {
node.remove();
node.parentNode = this.parentNode;
this.parentNode.children.splice(this.parentNode.children.indexOf(this) + 1, 0, node);
}
remove() {
if (!this.parentNode) return;
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode = null;
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
removeAttribute(name) { delete this.attributes[name]; }
querySelector() { return this.children.find(child => child.tagName === 'BUTTON' && child.getAttribute('aria-disabled') !== 'true') ?? null; }
querySelectorAll() { return []; }
focus() { active = this; }
click() { this.dispatchEvent(new Event('click')); }
}
const toast = new Element('div');
const body = new Element('body');
globalThis.document = {
body,
get activeElement() { return active ?? body; },
createElement: tag => new Element(tag),
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
getElementById: id => (id === 'workspace-toast' ? toast : null),
};
return { Element, body, toast, restore() { globalThis.document = previous; } };
}
/** The bar over `items` with `checked` keys; `options` adds the collection props. */
function mountBar(dom, items, checkedKeys, options = {}) {
const scope = createScope();
const grid = new dom.Element('div');
const focusFallback = new dom.Element('h1');
const bar = mountSelectionBar({ scope, api: {}, grid, getItems: () => items, focusFallback, onCategorySet() {},
activity: options.activity ?? null, onTagged() {}, getFilter: () => ({}),
getListing: () => ({ total: options.total ?? items.length, hasMore: false, positiveCount: null }),
checked: Object.freeze(new Set(checkedKeys)), ...options.props });
return { scope, bar, labels: () => bar.element.children[0]?.children.map(child => child.textContent) ?? [],
button: text => bar.element.children[0].children.find(child => child.textContent === text) };
}
function store(overrides = {}) {
const calls = [];
return {
calls,
collections: [{ id: 3, name: 'Experiments', count: 8, missing: 0 }, { id: 7, name: 'Landscapes', count: 12, missing: 1 }],
add: async (where, items) => { calls.push(['add', where, items]); return overrides.add?.(where, items); },
remove: async (id, items) => { calls.push(['remove', id, items]); return overrides.remove?.(id, items); },
};
}
test('selection bar lists Add to collection after Set category', () => {
const dom = fakeDOM();
try {
const items = images(2);
const collections = store();
const plain = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections } });
assert.deepEqual(plain.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Export metadata', 'Clear']);
plain.scope.dispose();
// §7.1: Remove from collection beside Add, only while a collection filter is active.
const filtered = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections, collection: '7' } });
assert.deepEqual(filtered.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Remove from collection', 'Export metadata', 'Clear']);
filtered.scope.dispose();
// Without the client store (a page that has none) neither shows.
const none = mountBar(dom, items, [keyOf(items[0])]);
assert.deepEqual(none.labels(), ['1 selected', 'Set category', 'Export metadata', 'Clear']);
none.scope.dispose();
} finally { dom.restore(); }
});
test('more than 500 checked toasts and opens nothing', async () => {
const dom = fakeDOM();
try {
const items = images(501);
const collections = store();
const view = mountBar(dom, items, items.map(keyOf), { props: { collections, collection: '7' } });
view.button('Add to collection').click();
assert.equal(dom.toast.textContent, 'Select at most 500 images.');
assert.equal(dom.body.children.length, 0, 'no dialog opened');
dom.toast.textContent = '';
view.button('Remove from collection').click();
await flush();
assert.equal(dom.toast.textContent, 'Select at most 500 images.');
assert.deepEqual(collections.calls, []);
view.scope.dispose();
} finally { dom.restore(); }
});
test('Remove from collection removes the checked files and reloads the listing', async () => {
const dom = fakeDOM();
try {
const items = images(3);
let changed = 0;
const collections = store({ remove: () => ({ collection: { id: 7, name: 'Landscapes' }, removed: 2 }) });
const view = mountBar(dom, items, [keyOf(items[0]), keyOf(items[2])], { props: { collections, collection: '7',
onCollectionsChanged: () => { changed++; } } });
const remove = view.button('Remove from collection');
remove.click();
// A second press while the first is in flight is ignored.
assert.equal(remove.getAttribute('aria-disabled'), 'true');
remove.click();
await flush();
assert.deepEqual(collections.calls, [['remove', 7, [target('set/0.png'), target('set/2.png')]]]);
assert.equal(dom.toast.textContent, 'Removed 2 images from “Landscapes”.');
assert.equal(changed, 1);
assert.equal(remove.getAttribute('aria-disabled'), null);
view.scope.dispose();
} finally { dom.restore(); }
});
test('the collection-target rule keeps the all-matching selection to its loaded images', () => {
const { collectionTargets } = module();
assert.deepEqual(collectionTargets(images(2)), { targets: [target('set/0.png'), target('set/1.png')] });
assert.deepEqual(collectionTargets(images(501)), { refusal: strings.TOO_MANY_TOAST });
// All N matching: only when every match is loaded and checked, and at most 500.
assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 2 }), { targets: [target('set/0.png'), target('set/1.png')] });
assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 3 }), { refusal: strings.TOO_MANY_TOAST });
assert.deepEqual(collectionTargets(images(501), { allMatching: true, total: 501 }), { refusal: strings.TOO_MANY_TOAST });
// A file without a listing identity cannot be named; none left is "could not be found".
assert.deepEqual(collectionTargets([{ ...image('x.png'), root_scope: 'nope' }]), { refusal: strings.FILES_NOT_FOUND_TOAST });
});
const pageScope = () => {
const controller = new AbortController();
return { signal: controller.signal, cleanup() {}, abort: () => controller.abort() };
};
test('Add to collection opens the concept dialog with the existing names as suggestions', () => {
const { openAddToCollection } = module();
let dialog = null;
const collections = store();
openAddToCollection({ scope: pageScope(), collections, images: images(1), trigger: 'trigger', notify: assert.fail,
openInput: args => { dialog = args; } });
assert.equal(dialog.title, 'Add selected images to collection');
assert.equal(dialog.label, 'Collection name');
assert.equal(dialog.action, 'Add');
assert.equal(dialog.maxLength, 100);
assert.equal(dialog.trigger, 'trigger');
assert.deepEqual(dialog.suggestions, ['Experiments', 'Landscapes']);
assert.equal(dialog.value ?? '', '');
});
/** Apply `name` in the Add dialog over `count` images; resolves with the toasts and the store's calls. */
async function add(name, answer, { count = 1, extra = {} } = {}) {
const { openAddToCollection } = module();
const toasts = [];
let dialog = null, sourceChanged = 0;
const collections = store({ add: answer });
openAddToCollection({ scope: pageScope(), collections, images: images(count), notify: text => toasts.push(text),
openInput: args => { dialog = args; }, onSourceChanged: () => { sourceChanged++; }, ...extra });
await dialog.onApply(name);
return { toasts, calls: collections.calls, sourceChanged };
}
const answer = (added, already, notFound, created = false) => () => ({ collection: { id: 9, name: 'LoRa testing' }, created,
added, already, notFound });
test('Add sends the normalized name and the checked files', async () => {
const { toasts, calls } = await add(' LoRa testing ', answer(3, 0, 0, true), { count: 3 });
assert.deepEqual(calls, [['add', { name: 'LoRa testing' }, [target('set/0.png'), target('set/1.png'), target('set/2.png')]]]);
assert.deepEqual(toasts, ['Added 3 images to “LoRa testing”.']);
});
test('Add toasts every outcome of §7.4', async () => {
const cases = [
[answer(1, 0, 0), 'Added 1 image to “LoRa testing”.'],
[answer(1, 1, 0), 'Added 1 image to “LoRa testing”. 1 was already in it.'],
[answer(2, 3, 0), 'Added 2 images to “LoRa testing”. 3 were already in it.'],
[answer(1, 0, 1), 'Added 1 image to “LoRa testing”. 1 could not be found.'],
[answer(1, 2, 4), 'Added 1 image to “LoRa testing”. 2 were already in it. 4 could not be found.'],
[answer(0, 2, 0), 'All selected images were already in “LoRa testing”.'],
[answer(0, 1, 1), 'Added 0 images to “LoRa testing”. 1 was already in it. 1 could not be found.'],
[() => { throw coded('files-not-found', 404); }, 'None of the selected images could be found. Refresh the images and try again.'],
[() => { throw coded('collection-not-found', 404); }, 'That collection no longer exists.'],
[() => { throw new TypeError('offline'); }, 'The images could not be added. Try again.'],
];
for (const [reply, text] of cases) assert.deepEqual((await add('LoRa testing', reply)).toasts, [text], text);
});
test('a stale source on Add says so and reloads the listing', async () => {
const { toasts, sourceChanged } = await add('Grids', () => { throw coded('source-changed', 409); });
assert.deepEqual(toasts, ['The sources changed. Refresh the images and try again.']);
assert.equal(sourceChanged, 1);
});
test('Add with a name the store cannot take sends nothing and says it failed', async () => {
const { toasts, calls } = await add('bad\u0001name', answer(1, 0, 0));
assert.deepEqual(calls, []);
assert.deepEqual(toasts, ['The images could not be added. Try again.']);
});
test('Add after the page left shows no toast', async () => {
const { openAddToCollection } = module();
const scope = pageScope();
const toasts = [];
let dialog = null;
openAddToCollection({ scope, collections: store({ add: answer(1, 0, 0) }), images: images(1), notify: text => toasts.push(text),
openInput: args => { dialog = args; } });
scope.abort();
await dialog.onApply('Grids');
assert.deepEqual(toasts, []);
});
test('Add refuses an over-cap or all-matching selection before any dialog', () => {
const { openAddToCollection } = module();
const toasts = [];
const open = extra => openAddToCollection({ scope: pageScope(), collections: store(), notify: text => toasts.push(text),
openInput: () => assert.fail('no dialog opens'), ...extra });
open({ images: images(501) });
open({ images: images(2), allMatching: true, total: 40 });
assert.deepEqual(toasts, [strings.TOO_MANY_TOAST, strings.TOO_MANY_TOAST]);
});
/** Remove `count` images from collection 7 with `reply`; resolves with the toasts and callbacks. */
async function remove(reply, count = 1) {
const { removeFromCollection } = module();
const toasts = [];
let changed = 0, sourceChanged = 0;
const collections = store({ remove: reply });
await removeFromCollection({ scope: pageScope(), collections, collectionId: 7, images: images(count),
notify: text => toasts.push(text), onChanged: () => { changed++; }, onSourceChanged: () => { sourceChanged++; } });
return { toasts, changed, sourceChanged, calls: collections.calls };
}
test('Remove toasts every outcome of §7.4', async () => {
assert.deepEqual(await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 1 })),
{ toasts: ['Removed 1 image from “Landscapes”.'], changed: 1, sourceChanged: 0, calls: [['remove', 7, [target('set/0.png')]]] });
assert.deepEqual((await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 3 }), 3)).toasts,
['Removed 3 images from “Landscapes”.']);
// A failure keeps the listing; a deleted collection reloads it (the page then says the collection is gone).
const failed = await remove(() => { throw new TypeError('offline'); });
assert.deepEqual([failed.toasts, failed.changed], [['The images could not be removed. Try again.'], 0]);
const gone = await remove(() => { throw coded('collection-not-found', 404); });
assert.deepEqual([gone.toasts, gone.changed], [['That collection no longer exists.'], 1]);
const stale = await remove(() => { throw coded('source-changed', 409); });
assert.deepEqual([stale.toasts, stale.changed, stale.sourceChanged], [['The sources changed. Refresh the images and try again.'], 0, 1]);
});
@@ -0,0 +1,172 @@
// Run with: node --test tests/js/workspace-images-source-recovery.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const load = () => import('../../web/js/workspace/images-source-recovery.mjs');
const OLD = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'sub/a.png', filename: 'a.png' });
const FRESH = Object.freeze({ root_index: 1, root_scope: 'b'.repeat(64), path: 'sub/a.png', filename: 'a.png', url: '/u' });
function fakeApi(answer) {
const calls = [];
return { calls, post: async (url, body, options) => { calls.push({ url, body, options }); return answer(body); } };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('rescopeImage posts only the target and returns the fresh descriptor', async () => {
const { rescopeImage, RESCOPE_URL } = await load();
const api = fakeApi(() => ({ success: true, image: FRESH }));
const signal = new AbortController().signal;
const fresh = await rescopeImage(api, OLD, { signal });
assert.deepEqual(fresh, FRESH);
assert.ok(Object.isFrozen(fresh));
assert.deepEqual(api.calls, [{ url: RESCOPE_URL, body: { target: { root_index: 0, root_scope: OLD.root_scope, path: 'sub/a.png' } }, options: { signal } }]);
});
test('rescopeImage answers null for a refusal, a malformed answer, another path or a bad target', async () => {
const { rescopeImage } = await load();
const refused = { post: async () => { throw Object.assign(new Error('gone'), { status: 409, code: 'source-changed' }); } };
assert.equal(await rescopeImage(refused, OLD), null);
const lost = { post: async () => { throw new TypeError('Failed to fetch'); } };
await assert.rejects(rescopeImage(lost, OLD), TypeError, 'a lost answer is not final');
for (const answer of [null, { success: false }, { success: true, image: { ...FRESH, root_scope: 'x' } },
{ success: true, image: { ...FRESH, path: 'other.png' } }, { success: true, image: { ...FRESH, root_index: -1 } }]) {
assert.equal(await rescopeImage(fakeApi(() => answer), OLD), null);
}
const api = fakeApi(() => ({ success: true, image: FRESH }));
for (const image of [null, { ...OLD, root_scope: 'short' }, { ...OLD, path: '' }, { ...OLD, root_index: 1.5 }]) {
assert.equal(await rescopeImage(api, image), null);
}
assert.equal(api.calls.length, 0, 'a malformed target is never sent');
});
test('one rescope per held pair: callers share it, a tried pair answers its first result, reset forgets', async () => {
const { createSourceRecovery } = await load();
let answer = { success: true, image: FRESH };
const api = fakeApi(() => answer);
const applied = [];
const recovery = createSourceRecovery({ api, generation: () => 7, apply: async (image, fresh, started) => { applied.push([image.path, fresh.root_index, started]); return fresh; } });
const [first, second] = await Promise.all([recovery.recover(OLD), recovery.recover({ ...OLD })]);
assert.deepEqual([first, second], [FRESH, FRESH]);
assert.equal(api.calls.length, 1);
assert.deepEqual(applied, [['sub/a.png', 1, 7]]);
answer = { success: false };
assert.deepEqual(await recovery.recover(OLD), FRESH, 'the pair is not asked again');
recovery.reset();
assert.equal(await recovery.recover(OLD), null, 'after a reset the pair is asked again');
assert.equal(api.calls.length, 2);
assert.equal(await recovery.recover({ ...OLD, root_scope: 'bad' }), null);
assert.equal(api.calls.length, 2);
});
test('a refused pair never loops, an aborted page applies nothing and a failing apply is null', async () => {
const { createSourceRecovery } = await load();
const api = fakeApi(() => ({ success: true, image: FRESH }));
const page = new AbortController();
let applied = 0;
const recovery = createSourceRecovery({ api, signal: page.signal, apply: async () => { applied += 1; return FRESH; } });
page.abort();
assert.equal(await recovery.recover(OLD), null);
assert.equal(applied, 0);
const failing = createSourceRecovery({ api, apply: async () => { throw new Error('page gone'); } });
assert.equal(await failing.recover(OLD), null);
assert.throws(() => createSourceRecovery({ api }), TypeError);
// An api without post (an embedded gallery's page adapter) finds nothing.
assert.equal(await createSourceRecovery({ api: {}, apply: async () => FRESH }).recover(OLD), null);
});
test('the memo forgets its oldest pairs past its limit', async () => {
const { createSourceRecovery } = await load();
const api = fakeApi(body => ({ success: true, image: { ...FRESH, path: body.target.path } }));
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
for (let index = 0; index < 65; index++) await recovery.recover({ ...OLD, path: `f${index}.png` });
assert.equal(api.calls.length, 65);
await recovery.recover({ ...OLD, path: 'f64.png' });
assert.equal(api.calls.length, 65, 'a recent pair is still remembered');
await recovery.recover({ ...OLD, path: 'f0.png' });
assert.equal(api.calls.length, 66, 'the oldest pair was forgotten');
});
function fakePage({ selected = true, root = 'all', current = true } = {}) {
const log = [];
const page = {
filters: { root, subfolder: root === 'all' ? '' : 'sub' },
current: started => { log.push(['current', started]); return current; },
selected: image => selected && image.path === OLD.path,
inspect: image => log.push(['inspect', image.root_index]),
dropSelection: () => log.push(['drop']),
stream: {
refresh: async () => { log.push(['refresh']); throw new Error('read failed'); },
reset: (filters, options) => log.push(['reset', { ...filters }, options]),
},
updateScope: () => log.push(['scope']),
closeViewer: () => log.push(['close-viewer']),
clearChecks: () => log.push(['clear-checks']),
listsNow: () => true,
rediscover: () => log.push(['rediscover']),
};
return { page, log };
}
test('a renumbered source keeps nothing by its old key: only the file stays selected', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage({ root: '0' });
assert.equal(await recoveredSource(page)(OLD, FRESH, 3), FRESH);
assert.deepEqual(log, [['current', 3], ['close-viewer'], ['clear-checks'], ['inspect', 1], ['scope'],
['reset', { root: '1', subfolder: 'sub' }, { load: true }], ['rediscover']]);
});
test('another Source filter falls back to All sources; an unselected file drops the old selection', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage({ root: '2', selected: false });
await recoveredSource(page)(OLD, FRESH, 0);
assert.deepEqual(log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
assert.ok(log.some(entry => entry[0] === 'drop'));
assert.ok(!log.some(entry => entry[0] === 'inspect'));
const all = fakePage({ root: 'all' });
await recoveredSource(all.page)(OLD, FRESH, 0);
assert.deepEqual(all.log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
});
test('an unchanged numbering re-reads in place; a page that moved on is left alone', async () => {
const { recoveredSource } = await load();
const same = { ...FRESH, root_index: 0 };
const { page, log } = fakePage();
assert.equal(await recoveredSource(page)(OLD, same, 1), same);
assert.deepEqual(log, [['current', 1], ['inspect', 0], ['refresh']], 'a failed re-read is not an error');
const unselected = fakePage({ selected: false });
await recoveredSource(unselected.page)(OLD, same, 1);
assert.deepEqual(unselected.log, [['current', 1], ['refresh']]);
const moved = fakePage({ current: false });
assert.equal(await recoveredSource(moved.page)(OLD, FRESH, 1), FRESH, 'the dialog may still retarget');
assert.deepEqual(moved.log, [['current', 1]]);
await flush();
});
test('a scope that still names its root is not stale: nothing is applied', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage();
assert.equal(await recoveredSource(page)(OLD, { ...OLD, url: '/u' }, 1), null);
assert.deepEqual(log, []);
});
test('a lost rescope answer is not remembered: the pair is asked again', async () => {
const { createSourceRecovery } = await load();
let down = true;
const api = { calls: 0, post: async () => { api.calls += 1; if (down) throw Object.assign(new Error('bad gateway'), { status: 502 }); return { success: true, image: FRESH }; } };
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
assert.equal(await recovery.recover(OLD), null);
down = false;
assert.deepEqual(await recovery.recover(OLD), FRESH);
assert.equal(api.calls, 2);
});
test('the same index under another scope (a changed key) re-reads in place and keeps the selection', async () => {
const { recoveredSource } = await load();
const rekeyed = { ...OLD, root_scope: 'c'.repeat(64) };
const { page, log } = fakePage();
page.stream.refresh = async () => { log.push(['refresh']); };
assert.equal(await recoveredSource(page)(OLD, rekeyed, 2), rekeyed);
assert.deepEqual(log, [['current', 2], ['inspect', 0], ['refresh']]);
assert.ok(!log.some(entry => ['reset', 'drop', 'clear-checks', 'close-viewer'].includes(entry[0])));
});
@@ -0,0 +1,96 @@
const test = require('node:test');
const assert = require('node:assert/strict');
// #437 D148: the Prompt library's selection bar sets one category on the selected prompts, fenced by the storage epoch.
const source = import('../../web/js/workspace/library-selection.mjs');
test('the dialog is the shared simpleInput with the prompt title, and the route and cap are the legacy ones', async () => {
const {PROMPT_CATEGORY_DIALOG, CATEGORY_URL, MAX_CATEGORY_PROMPTS, TOO_MANY_PROMPTS} = await source;
assert.deepEqual({...PROMPT_CATEGORY_DIALOG}, {title:'Categorize selected prompts', label:'Category', action:'Apply'});
assert.equal(CATEGORY_URL, '/prompt_manager/bulk/category');
assert.equal(MAX_CATEGORY_PROMPTS, 1000);
assert.equal(TOO_MANY_PROMPTS, 'Select at most 1,000 prompts.');
});
test('a confirmed write toasts the shared outcome with "prompt", invalidates and reloads', async () => {
const {writeOutcome} = await source;
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:2, unchanged:1, not_found:0}, null),
{toast:'Category “Studio” set on 3 prompts.', committed:true, reload:true});
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:1, unchanged:0, not_found:2}, null),
{toast:'Category “Studio” set on 1 prompt. 2 could not be found.', committed:true, reload:true});
});
test('none found, a restored storage, an unconfirmed answer and a refused write each say what happened', async () => {
const {writeOutcome, NONE_FOUND, STORAGE_CHANGED, UNCONFIRMED, CATEGORY_FAILED} = await source;
assert.deepEqual(writeOutcome({success:true, category:'X', updated:0, unchanged:0, not_found:3}, null),
{toast:NONE_FOUND, committed:false, reload:true});
assert.deepEqual(writeOutcome(null, {status:409, code:'storage-changed'}), {toast:STORAGE_CHANGED, committed:false, reload:true});
for (const failure of [new TypeError('Failed to fetch'), {status:502}, {status:503}, {status:504}]) {
assert.deepEqual(writeOutcome(null, failure), {toast:UNCONFIRMED, committed:true, reload:true});
}
assert.deepEqual(writeOutcome(null, null), {toast:UNCONFIRMED, committed:true, reload:true});
for (const status of [400, 500]) {
assert.deepEqual(writeOutcome(null, {status}), {toast:CATEGORY_FAILED, committed:false, reload:false});
}
assert.equal(STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No category was changed.');
});
test('the field refuses control characters as validate_category does', async () => {
const {categoryProblem, CONTROL_CHARACTERS} = await source;
assert.equal(categoryProblem('Studio portraits'), null);
assert.equal(categoryProblem('tab\there'), CONTROL_CHARACTERS);
assert.equal(categoryProblem('nul\u0000'), CONTROL_CHARACTERS);
assert.equal(categoryProblem('unicode ✓ é'), null);
});
test('Add tags uses the Images dialog words for prompts, the tag routes and the editor limits (#442)', async () => {
const {PROMPT_TAGS_DIALOG, TAGS_URL, TAG_OPTIONS_URL, MAX_PROMPT_TAGS} = await source;
assert.deepEqual({...PROMPT_TAGS_DIALOG}, {title:'Tag selected prompts', label:'Tags, separated by commas', action:'Apply'});
assert.equal(TAGS_URL, '/prompt_manager/bulk/tags');
assert.equal(TAG_OPTIONS_URL, '/prompt_manager/tags');
assert.equal(MAX_PROMPT_TAGS, 20);
});
test('the Add tags toast says what was added, already present, skipped and missing', async () => {
const {tagsOutcome, writeOutcome, NONE_FOUND} = await source;
assert.equal(tagsOutcome({tags:['a', 'b'], updated:2, unchanged:0, over_limit:0, not_found:0}), 'Added “a, b” to 2 prompts.');
assert.equal(tagsOutcome({tags:['a'], updated:1, unchanged:2, over_limit:1, not_found:3}),
'Added “a” to 1 prompt. 2 prompts already had them. 1 prompt would pass 20 tags and was skipped. 3 could not be found.');
assert.equal(tagsOutcome({tags:['a'], updated:0, unchanged:2, over_limit:0, not_found:0}), '2 prompts already had “a”.');
// Every prompt skipped: found, so the skip sentence, never "none found"; nothing committed.
assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:2, not_found:0}, null, 'tags'),
{toast:'2 prompts would pass 20 tags and were skipped.', committed:false, reload:true});
assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:0, not_found:2}, null, 'tags'),
{toast:NONE_FOUND, committed:false, reload:true});
});
test('Add tags failures name tags, not the category', async () => {
const {writeOutcome, TAGS_STORAGE_CHANGED, TAGS_UNCONFIRMED, TAGS_FAILED} = await source;
assert.equal(writeOutcome(null, {status:409, code:'storage-changed'}, 'tags').toast, TAGS_STORAGE_CHANGED);
assert.equal(writeOutcome(null, {status:503}, 'tags').toast, TAGS_UNCONFIRMED);
assert.equal(writeOutcome(null, {status:400}, 'tags').toast, TAGS_FAILED);
assert.equal(TAGS_STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No tags were added.');
});
test('the Add tags field refuses what validate_tags refuses', async () => {
const {tagsProblem, TAG_PROBLEMS} = await source;
assert.equal(tagsProblem('red, blue'), null);
assert.equal(tagsProblem(' , ,'), TAG_PROBLEMS.none);
assert.equal(tagsProblem(Array.from({length:21}, (_, i) => `t${i}`).join(',')), TAG_PROBLEMS.many);
assert.equal(tagsProblem(Array.from({length:21}, () => 'same').join(',')), null);
assert.equal(tagsProblem('x'.repeat(51)), TAG_PROBLEMS.long);
assert.equal(tagsProblem('bad\ttag'), TAG_PROBLEMS.control);
});
test('Select all N loaded is offered while some loaded prompts are unchecked (#444 ruling)', async () => {
const {loadedOffer, offersLoaded} = await source;
assert.equal(loadedOffer(3), 'Select all 3 loaded');
assert.equal(loadedOffer(1203), 'Select all 1,203 loaded');
assert.equal(offersLoaded(new Set([1]), [1, 2, 3]), true);
assert.equal(offersLoaded(new Set([1, 2, 3]), [1, 2, 3]), false);
assert.equal(offersLoaded(new Set(), [1, 2]), false);
assert.equal(offersLoaded(new Set([9]), [9]), false);
// More loaded than any action accepts: never offered (Ctrl/Cmd+A still selects them).
assert.equal(offersLoaded(new Set([1]), Array.from({length:1001}, (_, i) => i + 1)), false);
assert.equal(offersLoaded(new Set([1]), Array.from({length:1000}, (_, i) => i + 1)), true);
});
+10
View File
@@ -104,3 +104,13 @@ test('archived variant reads use their own scope and cursor without changing the
const active=[];const plain=createLibraryDetail({api:{get:async url=>{active.push(url);return {positive:item(1),variants:[]};}},onChange:()=>{}});await plain.load(1);assert.equal(new URL(active[0],'https://x').searchParams.has('archive'),false);
assert.throws(()=>createLibraryDetail({api:{},onChange:()=>{},archive:'parents'}));
});
test('the stream keeps the first page\'s storage epoch and refuses a later page from another one (#437)',async()=>{
const {createLibraryStream}=await source;const A='11111111-1111-4111-8111-111111111111',B='22222222-2222-4222-8222-222222222222';
const pending=[{...page([item(1)],'next'),library_epoch:A},{...page([item(2)],'more'),library_epoch:A},{...page([item(3)]),library_epoch:B}];
const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.epoch,null);
await s.reset();assert.equal(s.state.epoch,A);await s.loadMore();assert.equal(s.state.epoch,A);assert.equal(s.state.items.length,2);
await s.loadMore();assert.ok(s.state.error);assert.equal(s.state.items.length,2);assert.equal(s.state.epoch,A);
const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.epoch,A);restored.dispose();
const fresh=[{...page([item(4)]),library_epoch:B}];const reset=s.reset({text:'x'});assert.equal(s.state.epoch,null);
s.dispose();const next=createLibraryStream({api:{get:async()=>fresh.shift()}});await next.reset();assert.equal(next.state.epoch,B);next.dispose();await reset;
});
+65
View File
@@ -192,6 +192,71 @@ test('views reload once after a restore this page saw finish; a failed restore c
assert.equal(listeners.size, 0);
});
// #415: a fake document whose open dialogs the test sets, and a MutationObserver stand-in it triggers.
function dialogPage() {
const page = { open: 0, observers: new Set() };
page.doc = { documentElement: {}, querySelector: selector => (selector === 'dialog[open]' && page.open > 0 ? {} : null) };
page.Observer = class {
constructor(callback) { this.callback = callback; }
observe(target, options) { this.target = target; this.options = options; page.observers.add(this); }
disconnect() { page.observers.delete(this); }
};
page.mutate = open => { page.open = open; for (const observer of [...page.observers]) observer.callback([]); };
return page;
}
test('the restore reload runs at once when no dialog is open', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
assert.equal(reloads, 1);
assert.equal(page.observers.size, 0, 'nothing waits');
reload.request();
assert.equal(reloads, 2);
});
test('the restore reload waits for every open dialog to close, then runs once (#415)', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
page.open = 1;
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
reload.request();
assert.equal(reloads, 0, 'an open dialog holds the reload');
assert.equal(page.observers.size, 1, 'one observer for repeated requests');
const [observer] = page.observers;
assert.equal(observer.target, page.doc.documentElement);
assert.deepEqual(observer.options, { subtree: true, childList: true, attributes: true, attributeFilter: ['open'] });
page.mutate(1);
assert.equal(reloads, 0, 'a dialog swapped for another keeps it waiting');
page.mutate(0);
assert.equal(reloads, 1);
assert.equal(page.observers.size, 0, 'the observer stops once it ran');
page.mutate(0);
assert.equal(reloads, 1);
page.open = 1;
reload.request();
page.mutate(0);
assert.equal(reloads, 2, 'a later restore waits again');
});
test('disposing drops a waiting restore reload', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
page.open = 1;
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
reload.dispose();
assert.equal(page.observers.size, 0);
page.mutate(0);
assert.equal(reloads, 0);
reload.dispose();
});
test('a downloaded backup the server could not verify says so', async () => {
const { downloadDatabaseBackup, BACKUP_DONE, backupUnverified } = await scan;
const toasts = [];
+31
View File
@@ -594,6 +594,9 @@ test('an input dialog offers suggestions and a secondary action', async () => {
const [plainField, plainActions] = byId(dom, 'modal-body').children;
assert.equal(plainField.children[1].getAttribute('list'), null);
assert.deepEqual(plainActions.children.map(node => node.textContent), ['Cancel', 'A']);
// An empty list (Add to collection with no collections yet, #331 D138) adds no datalist either.
openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [], onApply() {} });
assert.equal(dom.all(node => node.tagName === 'DATALIST').length, 0);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [1], onApply() {} }), TypeError);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: '' }, onApply() {} }), TypeError);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: 'X' }, onApply() {} }), TypeError);
@@ -908,6 +911,34 @@ test('openReviewDialog lays out heading, grid, options, sentence, backup box and
}
});
// #415: a finished restore ends an open review (its reviewed plan no longer describes storage) as a dismissal, and
// leaves any other dialog, with what was typed in it, open.
test('closeReviewDialog closes only an open review, as dismissed', async () => {
const { openReviewDialog, openInputDialog, closeReviewDialog, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
closeReviewDialog();
const outcomes = [];
openInputDialog({ scope, title: 'Save view', label: 'Name', value: 'typed', action: 'Save', onApply() {} });
closeReviewDialog();
assert.equal(modal(dom).open, true, 'an input dialog stays');
assert.equal(byId(dom, 'action-input').value, 'typed');
openReviewDialog({ scope, title: 'Restore preview', grid: [['Integrity', 'Verified']], cancelLabel: 'Cancel',
primary: { label: 'Restore', danger: true }, onStart: () => outcomes.push('start'), onDismiss: () => outcomes.push('dismiss') });
closeReviewDialog();
assert.deepEqual(outcomes, ['dismiss']);
assert.equal(modal(dom), undefined, 'the shell is gone');
closeReviewDialog();
assert.deepEqual(outcomes, ['dismiss']);
} finally {
scope.dispose();
closeWorkspaceDialog();
dom.restore();
}
});
test('review dialog backup box keeps the dialog open and toasts the outcome', async () => {
const { openReviewDialog, closeWorkspaceDialog, SCAN_BACKUP_COPY } = await dialogsModule;
const { downloadDatabaseBackup, BACKUP_DONE, BACKUP_FAILED } = await scanModule;
+4 -3
View File
@@ -2556,9 +2556,10 @@ class TestRootScopeServing(ImageAPITestCase):
listing = await self.client.get("/prompt_manager/images/output?mode=workspace")
descriptor = (await listing.json())["images"][0]
self.assertRegex(descriptor.get("root_scope", ""), r"^[0-9a-f]{64}$")
with patch.object(
images_module, "_WORKSPACE_CURSOR_SECRET", b"different-process-key"
):
from utils.workspace_keys import WorkspaceKeys
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
with patch.object(self.api, "_workspace_keys", return_value=rotated):
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
self.api._get_all_output_dirs = lambda: []
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
+3 -2
View File
@@ -1418,9 +1418,10 @@ class TestPromptInspector(ImageAPITestCase):
async def test_root_scope_rotated_key_and_removed_index_skip_reader_and_db(self):
png(self.output_dir / "image.png", prompt=graph())
descriptor = await self.scoped_descriptor()
import py.api.images as images_module
from utils.workspace_keys import WorkspaceKeys
with patch.object(images_module, "_WORKSPACE_CURSOR_SECRET", b"new-key"):
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
with patch.object(self.api, "_workspace_keys", return_value=rotated):
await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64))
self.api._get_all_output_dirs = lambda: []
await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64))
+169
View File
@@ -0,0 +1,169 @@
"""POST /prompt_manager/images/rescope: a stale target's current identity (#430).
A page that holds ``{root_index, root_scope, path}`` after the sources were
renumbered asks where that file is now. The server maps the target only when
its scope was issued, at its old index, for one of the current roots, so the
answer names nothing the page could not already address; a removed or
replaced root, a foreign scope or a changed key stay refused.
"""
import json
import os
import sys
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from test_api_images import ImageAPITestCase, make_png
except ImportError: # imported as tests.test_api_rescope
from tests.test_api_images import ImageAPITestCase, make_png
from utils.workspace_keys import WorkspaceKeys # noqa: E402
RESCOPE = "/prompt_manager/images/rescope"
class TestRescopeRoute(ImageAPITestCase):
async def get_application(self):
app = await super().get_application()
self.other_dir = self.comfy_dir / "other"
self.other_dir.mkdir()
make_png(self.output_dir / "sub" / "a.png")
make_png(self.other_dir / "sub" / "a.png", color=(0, 0, 255))
return app
def target(self, path="sub/a.png", index=0, root=None):
root = root or self.output_dir
return {
"root_index": index,
"root_scope": self.api._gallery_root_scope(root, index),
"path": path,
}
async def post(self, body):
response = await self.client.post(RESCOPE, json=body)
data = await response.json()
self.assertNotIn(str(self.comfy_dir), json.dumps(data))
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
return response.status, data
async def listed(self):
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace"}
)
return {
(item["root_index"], item["path"]): item
for item in (await response.json())["images"]
}
async def test_an_unchanged_root_answers_the_listing_descriptor(self):
status, data = await self.post({"target": self.target()})
self.assertEqual(status, 200)
self.assertTrue(data["success"])
self.assertEqual(data["image"], (await self.listed())[(0, "sub/a.png")])
async def test_a_renumbered_root_maps_to_its_new_index(self):
held = self.target()
self.api._get_all_output_dirs = lambda: [self.other_dir, self.output_dir]
status, data = await self.post({"target": held})
self.assertEqual(status, 200)
image = data["image"]
self.assertEqual((image["root_index"], image["path"]), (1, "sub/a.png"))
self.assertEqual(
image["root_scope"], self.api._gallery_root_scope(self.output_dir, 1)
)
self.assertEqual(image, (await self.listed())[(1, "sub/a.png")])
async def test_a_removed_replaced_or_foreign_root_is_source_changed(self):
held = self.target()
cases = {
"removed": lambda: [],
"replaced": lambda: [self.other_dir],
}
for name, roots in cases.items():
with self.subTest(name):
self.api._get_all_output_dirs = roots
status, data = await self.post({"target": held})
self.assertEqual((status, data["code"]), (409, "source-changed"))
self.api._get_all_output_dirs = lambda: [self.output_dir]
for scope in ("0" * 64, self.api._gallery_root_scope(self.output_dir, 1)):
with self.subTest(scope=scope[:8]):
status, data = await self.post(
{"target": {**held, "root_scope": scope}}
)
self.assertEqual((status, data["code"]), (409, "source-changed"))
async def test_a_changed_key_cannot_be_mapped(self):
held = self.target()
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
with patch.object(self.api, "_workspace_keys", return_value=rotated):
status, data = await self.post({"target": held})
self.assertEqual((status, data["code"]), (409, "source-changed"))
async def test_the_first_of_two_spellings_of_one_root_wins(self):
held = self.target()
self.api._get_all_output_dirs = lambda: [
self.other_dir,
self.output_dir,
self.output_dir / "sub" / "..",
]
status, data = await self.post({"target": held})
self.assertEqual((status, data["image"]["root_index"]), (200, 1))
async def test_a_missing_file_is_not_found(self):
status, data = await self.post({"target": self.target("sub/gone.png")})
self.assertEqual((status, data["code"]), (404, "file-missing"))
(self.output_dir / "dir.png").mkdir()
status, data = await self.post({"target": self.target("dir.png")})
self.assertEqual((status, data["code"]), (404, "file-missing"))
async def test_a_path_leaving_the_root_is_refused(self):
outside = make_png(self.comfy_dir / "outside.png")
self._make_symlink(outside, self.output_dir / "escape.png")
status, _data = await self.post({"target": self.target("escape.png")})
self.assertEqual(status, 403)
async def test_previews_are_not_rescoped(self):
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
status, _data = await self.post(
{"target": self.target("thumbnails/a_thumb.png")}
)
self.assertEqual(status, 403)
async def test_malformed_bodies_are_refused_before_any_file_access(self):
good = self.target()
bodies = [
b"{",
[],
{},
{"target": good, "extra": 1},
{"target": {**good, "root_index": -1}},
{"target": {**good, "root_index": True}},
{"target": {**good, "root_index": 4096}},
# Past Python's int-to-text digit limit: never a 500.
b'{"target": {"root_index": 1' + b"0" * 5000 + b"}}",
{"target": {**good, "root_scope": "A" * 64}},
{"target": {**good, "root_scope": "abc"}},
{"target": {**good, "path": ""}},
{"target": {**good, "path": "../a.png"}},
{"target": {**good, "path": "/abs.png"}},
{"target": {**good, "path": "C:/a.png"}},
{"target": {**good, "path": "sub\\a.png"}},
{"target": {**good, "path": "sub//a.png"}},
{"target": {**good, "path": "notes.txt"}},
{"target": {**good, "path": "x" * 2000 + ".png"}},
]
with patch("pathlib.Path.is_file", side_effect=AssertionError("file access")):
for body in bodies:
with self.subTest(body=str(body)[:60]):
if isinstance(body, bytes):
response = await self.client.post(
RESCOPE,
data=body,
headers={"Content-Type": "application/json"},
)
else:
response = await self.client.post(RESCOPE, json=body)
self.assertIn(response.status, (400, 403))
self.assertFalse((await response.json())["success"])
+6 -2
View File
@@ -1882,9 +1882,13 @@ class TestWorkspaceCursorEncoding(unittest.TestCase):
for order in ("newest", "oldest", "name", "name-desc"):
with self.subTest(sort=order):
key = _workspace_key(entry, order)
token = _workspace_cursor(key, "scope")
token = _workspace_cursor(key, "scope", b"k" * 32)
self.assertLessEqual(len(token), 4096)
self.assertEqual(_workspace_cursor_key(token, "scope", order), key)
self.assertEqual(
_workspace_cursor_key(token, "scope", order, b"k" * 32), key
)
with self.assertRaises(ValueError):
_workspace_cursor_key(token, "scope", order, b"o" * 32)
# The pre-#379 candidate read, kept as the differential reference for the
+218
View File
@@ -0,0 +1,218 @@
"""A ComfyUI restart keeps an open workspace page's scopes and cursors (#430).
A restart is a new ``PromptManagerAPI`` on the same database in a new
process. Here a second API instance on its own aiohttp server stands in for
it (``tests/test_workspace_keys.py`` also derives the scope in a real child
process); it must accept every ``root_scope`` and listing cursor the first one
handed out, while a changed root list or key still refuses them.
"""
import base64
import json
import os
import sys
from unittest.mock import patch
from urllib.parse import quote
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from test_api_images import ImageAPITestCase, make_png
except ImportError: # imported as tests.test_api_workspace_restart
from tests.test_api_images import ImageAPITestCase, make_png
from database.operations import PromptDatabase # noqa: E402
from py.api import PromptManagerAPI # noqa: E402
from utils.workspace_keys import KEY_FILE_NAME, WorkspaceKeys # noqa: E402
LISTING = "/prompt_manager/images/output"
def _payload(token):
raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
return json.loads(raw[32:])
class TestRestartKeepsTheOpenPage(ImageAPITestCase):
async def get_application(self):
app = await super().get_application()
for index in range(3):
make_png(self.output_dir / f"image_{index}.png")
return app
async def first_page(self, client=None, **params):
response = await (client or self.client).get(
LISTING, params={"mode": "workspace", "limit": "1", **params}
)
self.assertEqual(response.status, 200)
return await response.json()
async def restarted(self):
"""A second API on the same database and roots, on its own server."""
api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
api._get_all_output_dirs = lambda: [self.output_dir]
api._find_comfyui_output_dir = lambda: str(self.output_dir)
app = web.Application()
routes = web.RouteTableDef()
api.add_routes(routes)
app.router.add_routes(routes)
client = TestClient(TestServer(app))
await client.start_server()
async def close():
await client.close()
api.close_auto_thumbnails()
api.db.close_all()
self.addAsyncCleanup(close)
return api, client
async def test_a_restart_keeps_every_held_scope_and_cursor_valid(self):
page = await self.first_page()
image, cursor = page["images"][0], page["next_cursor"]
self.assertTrue(
os.path.isfile(
os.path.join(os.path.dirname(self._temp_db.name), KEY_FILE_NAME)
)
)
_api, client = await self.restarted()
more = await client.get(
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
)
self.assertEqual(more.status, 200, await more.text())
self.assertNotEqual((await more.json())["images"][0]["path"], image["path"])
inspected = await client.get(
f"/prompt_manager/images/inspect/{quote(image['path'])}",
params={"root": "0", "root_scope": image["root_scope"]},
)
self.assertEqual(inspected.status, 200, await inspected.text())
served = await client.get(
f"/prompt_manager/images/serve/{quote(image['path'])}",
params={"root": "0", "root_scope": image["root_scope"]},
)
self.assertEqual(served.status, 200)
relisted = await self.first_page(client)
self.assertEqual(relisted["images"][0]["root_scope"], image["root_scope"])
async def test_a_key_change_refuses_the_cursor_as_stale(self):
cursor = (await self.first_page())["next_cursor"]
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
with patch.object(self.api, "_workspace_keys", return_value=rotated):
response = await self.client.get(
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
)
self.assertEqual(response.status, 400)
self.assertEqual((await response.json())["code"], "cursor-stale")
async def test_renumbered_roots_refuse_the_cursor_as_stale(self):
cursor = (await self.first_page())["next_cursor"]
other = self.comfy_dir / "other"
other.mkdir()
self.api._get_all_output_dirs = lambda: [other, self.output_dir]
response = await self.client.get(
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
)
self.assertEqual(response.status, 400)
self.assertEqual((await response.json())["code"], "cursor-stale")
async def test_another_listing_refuses_the_cursor_as_stale(self):
cursor = (await self.first_page())["next_cursor"]
response = await self.client.get(
LISTING,
params={"mode": "workspace", "limit": "1", "cursor": cursor, "q": "x"},
)
self.assertEqual(response.status, 400)
self.assertEqual((await response.json())["code"], "cursor-stale")
async def test_a_malformed_cursor_is_a_plain_bad_request(self):
# A 32-byte signature over a payload is well formed: a wrong one is stale.
for cursor in ("!!", "a" * 5000, base64.urlsafe_b64encode(b"x" * 20).decode()):
with self.subTest(cursor=cursor[:10]):
response = await self.client.get(
LISTING,
params={"mode": "workspace", "limit": "1", "cursor": cursor},
)
self.assertEqual(response.status, 400)
self.assertNotEqual((await response.json()).get("code"), "cursor-stale")
async def test_the_cursor_carries_no_digest_of_the_root_paths(self):
cursor = (await self.first_page())["next_cursor"]
payload = _payload(cursor)
# v2: the listing scope (it hashes every absolute root) is in the MAC only.
self.assertEqual(set(payload), {"v", "key"})
self.assertEqual(payload["v"], 2)
async def test_another_database_folder_gets_other_scopes(self):
page = await self.first_page()
other_db = self.comfy_dir / "elsewhere" / "prompts.db"
other_db.parent.mkdir()
api = PromptManagerAPI(db=PromptDatabase(str(other_db)))
self.addCleanup(api.db.close_all)
self.addCleanup(api.close_auto_thumbnails)
self.assertNotEqual(
api._gallery_root_scope(self.output_dir, 0), page["images"][0]["root_scope"]
)
# A fresh interpreter: the API on the given database, as after a restart.
_CHILD = """
import json, sys
from pathlib import Path
from unittest.mock import MagicMock
sys.path.insert(0, sys.argv[1])
from tests.host_stubs import install_folder_paths_stub
install_folder_paths_stub()
sys.modules.setdefault("server", MagicMock())
from database.operations import PromptDatabase
from py.api import PromptManagerAPI
from py.api.images import _workspace_cursor_key
api = PromptManagerAPI(db=PromptDatabase(sys.argv[2]))
scope = api._gallery_root_scope(Path(sys.argv[3]), 0)
secret = api._workspace_keys().cursor
key = _workspace_cursor_key(sys.argv[4], "s" * 64, "newest", secret)
api.close_auto_thumbnails()
api.db.close_all()
print(json.dumps({"scope": scope, "key": list(key)}))
"""
class TestRestartInANewProcess(ImageAPITestCase):
"""The real thing: two other interpreters on the same database."""
def child(self, cursor):
import subprocess
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
result = subprocess.run(
[
sys.executable,
"-c",
_CHILD,
repo,
self._temp_db.name,
str(self.output_dir),
cursor,
],
cwd=self.comfy_dir,
capture_output=True,
text=True,
timeout=120,
)
self.assertEqual(result.returncode, 0, result.stderr[-2000:])
return json.loads(result.stdout.strip().splitlines()[-1])
async def test_new_processes_derive_the_same_scope_and_accept_the_cursor(self):
from py.api.images import _workspace_cursor
make_png(self.output_dir / "image.png")
listing = await self.client.get(LISTING, params={"mode": "workspace"})
scope = (await listing.json())["images"][0]["root_scope"]
key = (-12.5, "image.png", "0" * 64, 0)
cursor = _workspace_cursor(key, "s" * 64, self.api._workspace_keys().cursor)
for _restart in range(2):
self.assertEqual(self.child(cursor), {"scope": scope, "key": list(key)})
+125 -15
View File
@@ -22,6 +22,9 @@ from database.operations import PromptDatabase
from tests.browser import fast_reset
from tests.browser.fast_reset import ScratchSeeder, table_digests
# The owner lock of /dev/shm scratch folders needs POSIX flock (#419).
HAS_FLOCK = unittest.skipUnless(os.name == "posix", "needs POSIX flock")
def digests(db: PromptDatabase) -> dict:
return table_digests(db.model.get_connection())
@@ -275,6 +278,7 @@ class TestScratchSeeder(unittest.TestCase):
self.assertEqual(digests(self.live), expected)
self.assertFalse((self.root / "unused").exists())
@HAS_FLOCK
def test_the_scratch_lives_under_the_memory_root_when_there_is_one(self):
memory = self.root / "memory"
memory.mkdir()
@@ -288,6 +292,16 @@ class TestScratchSeeder(unittest.TestCase):
seeder.close()
self.assertEqual(list(memory.iterdir()), [])
@unittest.skipIf(os.name == "posix", "without flock the memory root is unused")
def test_without_flock_the_scratch_stays_on_disk(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
seeder.run(reseed)
self.assertEqual(list(memory.iterdir()), [])
self.assertTrue((self.root / "fallback" / "seed.db").exists())
def test_a_full_memory_root_puts_the_scratch_on_disk(self):
memory = self.root / "memory"
memory.mkdir()
@@ -303,26 +317,32 @@ class TestScratchSeeder(unittest.TestCase):
self.assertEqual(list(memory.iterdir()), [])
self.assertTrue((self.root / "fallback" / "seed.db").exists())
@unittest.skipUnless(os.name == "posix", "process probe is POSIX only")
def test_scratch_folders_of_dead_servers_are_removed(self):
@HAS_FLOCK
def test_the_scratch_folder_is_held_by_a_lock_until_close(self):
memory = self.root / "memory"
memory.mkdir()
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
orphan = memory / f"pm-reset-{dead.pid}-abc"
alive = memory / f"pm-reset-{os.getpid()}-def"
other = memory / "pm-reset-notapid"
for folder in (orphan, alive, other):
folder.mkdir()
(folder / "seed.db").write_bytes(b"x")
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
seeder.run(reseed)
self.assertFalse(orphan.exists())
self.assertTrue(alive.exists())
self.assertTrue(other.exists())
own = [p for p in memory.glob(f"pm-reset-{os.getpid()}-*") if p != alive]
self.assertEqual(len(own), 1)
(folder,) = memory.iterdir()
self.assertTrue(folder.name.startswith(f"pm-reset-held-{os.getpid()}-"))
self.assertFalse(lock_is_free(folder / "owner.lock"))
seeder.close()
self.assertEqual(list(memory.iterdir()), [])
@HAS_FLOCK
def test_a_lock_that_cannot_be_taken_puts_the_scratch_on_disk(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
refuse = mock.patch.object(
fast_reset.fcntl, "flock", side_effect=OSError("no locks here")
)
with refuse:
seeder.run(reseed)
self.assertEqual(list(memory.iterdir()), [])
self.assertTrue((self.root / "fallback" / "seed.db").exists())
def test_close_releases_the_scratch_files(self):
self.seeder.run(reseed)
@@ -331,5 +351,95 @@ class TestScratchSeeder(unittest.TestCase):
self.seeder.close() # idempotent
# A process that takes the owner lock of a folder, says so, and waits.
HOLDER = """
import fcntl, os, sys, time
fd = os.open(os.path.join(sys.argv[1], "owner.lock"), os.O_RDWR | os.O_CREAT)
fcntl.flock(fd, fcntl.LOCK_EX)
print("held", flush=True)
time.sleep(600)
"""
def lock_is_free(path: Path) -> bool:
fd = os.open(path, os.O_RDWR)
try:
fast_reset.fcntl.flock(fd, fast_reset.fcntl.LOCK_EX | fast_reset.fcntl.LOCK_NB)
except BlockingIOError:
return False
finally:
os.close(fd)
return True
@HAS_FLOCK
class TestOrphanedScratch(unittest.TestCase):
"""Only folders whose owner lock is free are orphans (#419)."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.memory = Path(self.tmp.name)
def folder(self, name: str) -> Path:
folder = self.memory / name
folder.mkdir()
(folder / "seed.db").write_bytes(b"x")
return folder
def hold(self, folder: Path) -> subprocess.Popen:
holder = subprocess.Popen(
[sys.executable, "-c", HOLDER, str(folder)],
stdout=subprocess.PIPE,
text=True,
)
self.addCleanup(holder.wait)
self.addCleanup(holder.stdout.close)
self.addCleanup(holder.kill)
self.assertEqual(holder.stdout.readline().strip(), "held")
return holder
def test_a_folder_whose_owner_was_killed_is_removed(self):
folder = self.folder("pm-reset-held-123-abc")
holder = self.hold(folder)
holder.kill()
holder.wait()
fast_reset.remove_orphaned_scratch(self.memory)
self.assertFalse(folder.exists())
def test_a_held_folder_is_kept_whatever_pid_its_name_carries(self):
# Under --ipc=host the owner's pid is from another PID namespace: it
# does not exist here, but its lock does.
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
folder = self.folder(f"pm-reset-held-{dead.pid}-abc")
self.hold(folder)
fast_reset.remove_orphaned_scratch(self.memory)
self.assertTrue(folder.exists())
self.assertTrue((folder / "seed.db").exists())
def test_folders_without_an_owner_lock_are_kept(self):
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
legacy = self.folder(f"pm-reset-{dead.pid}-abc")
staging = self.folder(f"pm-reset-new-{dead.pid}-def")
unlocked = self.folder(f"pm-reset-held-{dead.pid}-ghi")
fast_reset.remove_orphaned_scratch(self.memory)
for folder in (legacy, staging, unlocked):
self.assertTrue(folder.exists(), folder.name)
def test_a_folder_held_by_another_seeder_in_this_process_is_kept(self):
api = types.SimpleNamespace(db=PromptDatabase(str(self.memory / "live.db")))
self.addCleanup(api.db.close_all)
root = self.memory / "shm"
root.mkdir()
other = ScratchSeeder(api, self.memory / "fallback", memory_root=root)
self.addCleanup(other.close)
other.run(reseed)
(folder,) = root.iterdir()
fast_reset.remove_orphaned_scratch(root)
self.assertTrue(folder.exists())
if __name__ == "__main__":
unittest.main()
+255
View File
@@ -0,0 +1,255 @@
"""#437: the workspace Library's fenced bulk "Set category" (DB command and route)."""
import sqlite3
import tempfile
import unittest
from pathlib import Path
from database.execution_history_schema import rotate_history_epoch
from database.library_commands import LibraryCommandError
from database.operations import PromptDatabase
from utils.hashing import generate_prompt_hash
try:
from test_api_prompts import RouteCoverageCase
except ImportError:
from tests.test_api_prompts import RouteCoverageCase
URL = "/prompt_manager/bulk/category"
def save(db, text, **metadata):
return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata)
def row(db, prompt_id):
with db.model.get_connection() as conn:
found = conn.execute(
"SELECT category, updated_at FROM prompts WHERE id=?", (prompt_id,)
).fetchone()
return tuple(found) if found else None
class TestBulkLibraryCategory(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db = PromptDatabase(str(Path(self.temp.name) / "bulk.db"))
self.epoch = self.db.get_library_options()["library_epoch"]
def tearDown(self):
self.db.close_all()
self.temp.cleanup()
def run_bulk(self, ids, category, epoch=None):
return self.db.bulk_set_library_category(
ids, category, expected_epoch=epoch or self.epoch
)
def test_sets_changed_rows_and_counts_unchanged_and_missing(self):
a = save(self.db, "Alpha", category="Old")
b = save(self.db, "Beta", category="New")
c = save(self.db, "Gamma")
before_b = row(self.db, b)
result = self.run_bulk([a, b, c, 99999], " New ")
self.assertEqual(
result,
{
"library_epoch": self.epoch,
"category": "New",
"updated": 2,
"unchanged": 1,
"not_found": 1,
},
)
self.assertEqual(row(self.db, a)[0], "New")
self.assertEqual(row(self.db, c)[0], "New")
# An unchanged row keeps its timestamp (and so its edit token).
self.assertEqual(row(self.db, b), before_b)
def test_changed_category_rotates_the_edit_token_only_for_changed_rows(self):
a = save(self.db, "Alpha", category="Old")
b = save(self.db, "Beta", category="New")
tokens = {
pid: self.db.get_library_positive(pid)["positive"]["edit_token"]
for pid in (a, b)
}
self.run_bulk([a, b], "New")
self.assertNotEqual(
self.db.get_library_positive(a)["positive"]["edit_token"], tokens[a]
)
self.assertEqual(
self.db.get_library_positive(b)["positive"]["edit_token"], tokens[b]
)
def test_changed_rows_become_registered_library_parents_like_the_editor(self):
a = save(self.db, "Alpha", category="Old")
b = save(self.db, "Beta", category="Same")
def registered(pid):
with self.db.model.get_connection() as conn:
return bool(
conn.execute(
"SELECT 1 FROM prompt_library_state WHERE prompt_id=?", (pid,)
).fetchone()
)
self.assertEqual((registered(a), registered(b)), (False, False))
self.run_bulk([a, b], "Same")
self.assertEqual((registered(a), registered(b)), (True, False))
def test_updated_at_is_utc_iso_text(self):
a = save(self.db, "Alpha")
self.run_bulk([a], "Utc")
self.assertTrue(row(self.db, a)[1].endswith("+00:00"))
def test_negative_only_prompts_are_not_library_parents(self):
pair = self.db.save_prompt_pair("Cat", "Blur")
negative = self.db.get_prompt_pair(pair)["negative_prompt_id"]
positive = self.db.get_prompt_pair(pair)["positive_prompt_id"]
result = self.run_bulk([positive, negative], "Animals")
self.assertEqual((result["updated"], result["not_found"]), (1, 1))
self.assertIsNone(row(self.db, negative)[0])
def test_restored_storage_writes_nothing(self):
a = save(self.db, "Alpha", category="Old")
new_epoch = rotate_history_epoch(self.db.model.get_connection())
with self.assertRaises(LibraryCommandError) as caught:
self.run_bulk([a], "New")
self.assertEqual(caught.exception.code, "storage-changed")
self.assertEqual(caught.exception.epoch, new_epoch)
self.assertEqual(row(self.db, a)[0], "Old")
def test_a_failure_mid_write_leaves_every_row_as_it_was(self):
ids = [save(self.db, f"P{i}", category="Old") for i in range(3)]
with self.db.model.get_connection() as conn:
conn.execute(
"CREATE TRIGGER fail_third BEFORE UPDATE OF category ON prompts "
f"WHEN OLD.id={ids[2]} BEGIN SELECT RAISE(ABORT, 'boom'); END"
)
with self.assertRaises(sqlite3.DatabaseError):
self.run_bulk(ids, "New")
self.assertEqual([row(self.db, pid)[0] for pid in ids], ["Old"] * 3)
def test_many_ids_are_read_in_chunks(self):
ids = [save(self.db, f"Many {i}") for i in range(1000)]
result = self.run_bulk(ids, "Bulk")
self.assertEqual(result["updated"], 1000)
def test_rejects_invalid_arguments(self):
a = save(self.db, "Alpha")
for ids, category, epoch in (
([a], "", self.epoch),
([a], " ", self.epoch),
([a], None, self.epoch),
([a], "x" * 101, self.epoch),
([a], "bad\x01", self.epoch),
([a], "ok", "not-a-uuid"),
([], "ok", self.epoch),
):
with self.subTest(category=category, epoch=epoch, ids=ids):
with self.assertRaises(ValueError):
self.run_bulk(ids, category, epoch)
class TestBulkLibraryCategoryRoute(RouteCoverageCase):
def epoch(self):
return self.api.db.get_library_options()["library_epoch"]
async def test_fenced_form_sets_categories_and_reports_counts(self):
ids = [self._save_prompt(f"route {i}", category="Old") for i in range(2)]
status, body = await self._json(
"POST",
URL,
json={
"prompt_ids": ids,
"category": "Fresh",
"expected_epoch": self.epoch(),
},
)
self.assertEqual(status, 200)
self.assertEqual(
body,
{
"success": True,
"library_epoch": self.epoch(),
"category": "Fresh",
"updated": 2,
"unchanged": 0,
"not_found": 0,
},
)
self.assertEqual(self.api.db.get_prompt_by_id(ids[1])["category"], "Fresh")
async def test_restored_storage_answers_409_storage_changed(self):
pid = self._save_prompt("route restore", category="Old")
stale = self.epoch()
new_epoch = rotate_history_epoch(self.api.db.model.get_connection())
status, body = await self._json(
"POST",
URL,
json={"prompt_ids": [pid], "category": "Fresh", "expected_epoch": stale},
)
self.assertEqual((status, body["code"]), (409, "storage-changed"))
self.assertFalse(body["success"])
self.assertEqual(body["library_epoch"], new_epoch)
self.assertIsInstance(body["error"], str)
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Old")
async def test_fenced_form_validates_its_body(self):
pid = self._save_prompt("route validate")
epoch = self.epoch()
for body in (
{"prompt_ids": [pid], "category": "", "expected_epoch": epoch},
{"prompt_ids": [pid], "category": " ", "expected_epoch": epoch},
{"prompt_ids": [pid], "category": 5, "expected_epoch": epoch},
{"prompt_ids": [pid], "category": "x" * 101, "expected_epoch": epoch},
{"prompt_ids": [pid], "category": "ok", "expected_epoch": "nope"},
{"prompt_ids": [pid], "category": "ok", "expected_epoch": 7},
{"prompt_ids": [], "category": "ok", "expected_epoch": epoch},
{"prompt_ids": [True], "category": "ok", "expected_epoch": epoch},
{
"prompt_ids": list(range(1, 1002)),
"category": "ok",
"expected_epoch": epoch,
},
{"prompt_ids": [pid], "expected_epoch": epoch},
{
"prompt_ids": [pid],
"category": "ok",
"expected_epoch": epoch,
"extra": 1,
},
):
with self.subTest(body=body):
status, answer = await self._json("POST", URL, json=body)
self.assertEqual(status, 400)
self.assertFalse(answer["success"])
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
async def test_fenced_form_reports_an_unexpected_failure(self):
pid = self._save_prompt("route failure")
self._break("bulk_set_library_category")
status, body = await self._json(
"POST",
URL,
json={
"prompt_ids": [pid],
"category": "ok",
"expected_epoch": self.epoch(),
},
)
self.assertEqual(status, 500)
self.assertFalse(body["success"])
async def test_legacy_form_is_unchanged(self):
pid = self._save_prompt("route legacy", category="Old")
status, body = await self._json(
"POST", URL, json={"prompt_ids": [pid], "category": "Legacy"}
)
self.assertEqual((status, body["updated_count"]), (200, 1))
self.assertNotIn("library_epoch", body)
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Legacy")
if __name__ == "__main__":
unittest.main()
+286
View File
@@ -0,0 +1,286 @@
"""#442: the workspace Library's fenced bulk "Add tags" (DB command and route)."""
import sqlite3
import tempfile
import unittest
from pathlib import Path
from database.execution_history_schema import rotate_history_epoch
from database.library_commands import LibraryCommandError
from database.operations import PromptDatabase
from utils.hashing import generate_prompt_hash
try:
from test_api_prompts import RouteCoverageCase
except ImportError:
from tests.test_api_prompts import RouteCoverageCase
URL = "/prompt_manager/bulk/tags"
def save(db, text, **metadata):
return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata)
def tags_of(db, prompt_id):
with db.model.get_connection() as conn:
rows = conn.execute(
"SELECT t.name FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=? ORDER BY t.name",
(prompt_id,),
).fetchall()
return [row[0] for row in rows]
def updated_at(db, prompt_id):
with db.model.get_connection() as conn:
return conn.execute(
"SELECT updated_at FROM prompts WHERE id=?", (prompt_id,)
).fetchone()[0]
def tag_names(db):
with db.model.get_connection() as conn:
return sorted(row[0] for row in conn.execute("SELECT name FROM tags"))
class TestBulkLibraryTags(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db = PromptDatabase(str(Path(self.temp.name) / "tags.db"))
self.epoch = self.db.get_library_options()["library_epoch"]
def tearDown(self):
self.db.close_all()
self.temp.cleanup()
def run_bulk(self, ids, tags, epoch=None):
return self.db.bulk_add_library_tags(
ids, tags, expected_epoch=epoch or self.epoch
)
def test_adds_only_missing_tags_and_counts_each_outcome(self):
a = save(self.db, "Alpha", tags=["red"])
b = save(self.db, "Beta", tags=["red", "blue"])
c = save(self.db, "Gamma")
before_b = updated_at(self.db, b)
result = self.run_bulk([a, b, c, 99999], [" red ", "blue", "Blue"])
self.assertEqual(
result,
{
"library_epoch": self.epoch,
"tags": ["red", "blue"],
"updated": 2,
"unchanged": 1,
"not_found": 1,
"over_limit": 0,
},
)
self.assertEqual(tags_of(self.db, a), ["blue", "red"])
self.assertEqual(tags_of(self.db, c), ["blue", "red"])
self.assertEqual(updated_at(self.db, b), before_b)
self.assertTrue(updated_at(self.db, a).endswith("+00:00"))
def test_a_case_variant_of_a_held_tag_is_not_added_and_names_stay_exact(self):
a = save(self.db, "Alpha", tags=["Portrait"])
b = save(self.db, "Beta")
result = self.run_bulk([a, b], ["portrait"])
# Alpha already holds it (casefold); Beta gets the name exactly as typed.
self.assertEqual((result["updated"], result["unchanged"]), (1, 1))
self.assertEqual(tags_of(self.db, a), ["Portrait"])
self.assertEqual(tags_of(self.db, b), ["portrait"])
self.assertEqual(tag_names(self.db), ["Portrait", "portrait"])
c = save(self.db, "Gamma", tags=["STRASSE"])
self.assertEqual(self.run_bulk([c], ["straße"])["unchanged"], 1)
def test_names_nothing_receives_are_never_inserted(self):
full = save(self.db, "Full", tags=[f"t{i}" for i in range(20)])
self.run_bulk([full, 99999], ["unused"])
self.assertNotIn("unused", tag_names(self.db))
def test_a_prompt_already_over_twenty_that_gains_nothing_is_unchanged(self):
over = save(self.db, "Over", tags=[f"t{i}" for i in range(22)])
result = self.run_bulk([over], ["t1"])
self.assertEqual((result["unchanged"], result["over_limit"]), (1, 0))
def test_image_and_pair_tags_are_untouched(self):
pair = self.db.save_prompt_pair("Cat", "Blur", tags=["pairtag"])
positive = self.db.get_prompt_pair(pair)["positive_prompt_id"]
with self.db.model.get_connection() as conn:
before = conn.execute("SELECT COUNT(*) FROM prompt_pair_tags").fetchone()[0]
self.run_bulk([positive], ["pairtag", "own"])
self.assertEqual(tags_of(self.db, positive), ["own", "pairtag"])
with self.db.model.get_connection() as conn:
after = conn.execute("SELECT COUNT(*) FROM prompt_pair_tags").fetchone()[0]
self.assertEqual(after, before)
def test_a_prompt_that_would_pass_twenty_tags_is_skipped_untouched(self):
full = save(self.db, "Full", tags=[f"t{i}" for i in range(19)])
other = save(self.db, "Other")
before = updated_at(self.db, full)
result = self.run_bulk([full, other], ["x", "y"])
self.assertEqual((result["updated"], result["over_limit"]), (1, 1))
self.assertEqual(len(tags_of(self.db, full)), 19)
self.assertEqual(updated_at(self.db, full), before)
self.assertEqual(tags_of(self.db, other), ["x", "y"])
# Exactly twenty is allowed.
self.assertEqual(self.run_bulk([full], ["x"])["updated"], 1)
def test_changed_prompts_become_registered_library_parents(self):
a = save(self.db, "Alpha")
self.run_bulk([a], ["new"])
with self.db.model.get_connection() as conn:
self.assertIsNotNone(
conn.execute(
"SELECT 1 FROM prompt_library_state WHERE prompt_id=?", (a,)
).fetchone()
)
def test_negative_only_prompts_are_not_found(self):
pair = self.db.save_prompt_pair("Cat", "Blur")
negative = self.db.get_prompt_pair(pair)["negative_prompt_id"]
result = self.run_bulk([negative], ["x"])
self.assertEqual((result["updated"], result["not_found"]), (0, 1))
self.assertEqual(tags_of(self.db, negative), [])
def test_restored_storage_writes_nothing_not_even_a_tag_name(self):
a = save(self.db, "Alpha")
rotate_history_epoch(self.db.model.get_connection())
with self.assertRaises(LibraryCommandError) as caught:
self.run_bulk([a], ["fresh"])
self.assertEqual(caught.exception.code, "storage-changed")
self.assertEqual(tags_of(self.db, a), [])
self.assertNotIn("fresh", tag_names(self.db))
def test_a_failure_mid_write_leaves_every_prompt_as_it_was(self):
ids = [save(self.db, f"P{i}") for i in range(3)]
with self.db.model.get_connection() as conn:
conn.execute(
"CREATE TRIGGER fail_third BEFORE INSERT ON prompt_tags "
f"WHEN NEW.prompt_id={ids[2]} BEGIN SELECT RAISE(ABORT, 'boom'); END"
)
with self.assertRaises(sqlite3.DatabaseError):
self.run_bulk(ids, ["x"])
self.assertEqual([tags_of(self.db, pid) for pid in ids], [[], [], []])
self.assertNotIn("x", tag_names(self.db))
def test_entries_are_counted_after_dedupe_and_the_limit_names_prompts(self):
a = save(self.db, "Alpha")
self.assertEqual(self.run_bulk([a], ["dup"] * 30 + ["Dup"])["updated"], 1)
with self.assertRaises(ValueError) as caught:
self.run_bulk([a], [f"t{i}" for i in range(101)])
self.assertEqual(str(caught.exception), "At most 20 tags can be added at once")
def test_many_ids_are_read_in_chunks(self):
ids = [save(self.db, f"Many {i}") for i in range(1000)]
self.assertEqual(self.run_bulk(ids, ["bulk"])["updated"], 1000)
def test_rejects_invalid_arguments(self):
a = save(self.db, "Alpha")
for ids, tags, epoch in (
([a], [], self.epoch),
([a], [" "], self.epoch),
([a], "red", self.epoch),
([a], ["x" * 51], self.epoch),
([a], ["bad\x01"], self.epoch),
([a], ["a,b"], self.epoch),
([a], [f"t{i}" for i in range(21)], self.epoch),
([a], ["ok"], "not-a-uuid"),
([], ["ok"], self.epoch),
):
with self.subTest(tags=tags, epoch=epoch, ids=ids):
with self.assertRaises(ValueError):
self.run_bulk(ids, tags, epoch)
class TestBulkLibraryTagsRoute(RouteCoverageCase):
def epoch(self):
return self.api.db.get_library_options()["library_epoch"]
async def test_fenced_form_adds_tags_and_reports_counts(self):
ids = [self._save_prompt(f"route {i}") for i in range(2)]
status, body = await self._json(
"POST",
URL,
json={"prompt_ids": ids, "tags": ["Fresh"], "expected_epoch": self.epoch()},
)
self.assertEqual(status, 200)
self.assertEqual(
body,
{
"success": True,
"library_epoch": self.epoch(),
"tags": ["Fresh"],
"updated": 2,
"unchanged": 0,
"not_found": 0,
"over_limit": 0,
},
)
async def test_restored_storage_answers_409_storage_changed(self):
pid = self._save_prompt("route restore")
stale = self.epoch()
new_epoch = rotate_history_epoch(self.api.db.model.get_connection())
status, body = await self._json(
"POST",
URL,
json={"prompt_ids": [pid], "tags": ["x"], "expected_epoch": stale},
)
self.assertEqual((status, body["code"]), (409, "storage-changed"))
self.assertEqual(body["library_epoch"], new_epoch)
async def test_fenced_form_validates_its_body(self):
pid = self._save_prompt("route validate")
epoch = self.epoch()
for body in (
{"prompt_ids": [pid], "tags": [], "expected_epoch": epoch},
{"prompt_ids": [pid], "tags": "x", "expected_epoch": epoch},
{"prompt_ids": [pid], "tags": ["x" * 51], "expected_epoch": epoch},
{"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": "nope"},
{"prompt_ids": [True], "tags": ["ok"], "expected_epoch": epoch},
{
"prompt_ids": list(range(1, 1002)),
"tags": ["ok"],
"expected_epoch": epoch,
},
{"prompt_ids": [pid], "expected_epoch": epoch},
{"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": epoch, "x": 1},
):
with self.subTest(body=body):
status, answer = await self._json("POST", URL, json=body)
self.assertEqual(status, 400)
self.assertFalse(answer["success"])
self.assertEqual(tags_of(self.api.db, pid), [])
async def test_fenced_form_reports_an_unexpected_failure(self):
pid = self._save_prompt("route failure")
self._break("bulk_add_library_tags")
status, body = await self._json(
"POST",
URL,
json={"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": self.epoch()},
)
self.assertEqual(status, 500)
self.assertFalse(body["success"])
async def test_legacy_form_refuses_a_body_that_is_not_an_object(self):
for raw in ("[1, 2]", "not json"):
with self.subTest(raw=raw):
status, body = await self._json(
"POST", URL, data=raw, headers={"Content-Type": "application/json"}
)
self.assertEqual(status, 400)
self.assertFalse(body["success"])
async def test_legacy_form_is_unchanged(self):
pid = self._save_prompt("route legacy")
status, body = await self._json(
"POST", URL, json={"prompt_ids": [pid], "tags": ["Legacy"]}
)
self.assertEqual((status, body["updated_count"]), (200, 1))
self.assertNotIn("library_epoch", body)
if __name__ == "__main__":
unittest.main()
+193 -1
View File
@@ -666,7 +666,7 @@ class TestOwnCounts(unittest.TestCase):
return counts
class TestReceipts(unittest.TestCase):
class ReceiptCase(unittest.TestCase):
def setUp(self):
self.context = ("j1", None)
self.runs = CachedRuns(
@@ -686,6 +686,8 @@ class TestReceipts(unittest.TestCase):
def keys(self):
return list(self.runs._receipts)
class TestReceipts(ReceiptCase):
def test_least_recently_used_run_keys_are_dropped(self):
self.begin(1)
for name in ("a", "b", "c"):
@@ -726,6 +728,196 @@ class TestReceipts(unittest.TestCase):
self.assertEqual(list(self.runs._attempt.nodes), ["b"])
class TestRecordedFields(ReceiptCase):
"""The texts a receipt keeps for the history capture of a cached job (#403)."""
F = (("raw a", " pre ", ""),)
def record(self, item_id, name="a", output="x", fields=F, facts=None):
self.begin(item_id)
self.runs.record_item(name, (output,), facts or Facts("h"), fields)
self.runs.end_attempt(item_id)
def lookup(self, name="a", values=(("x",),), prompt_id="j9", item_id=9):
self.begin(item_id, prompt_id)
return self.runs.recorded_fields(prompt_id, name, values)
def test_a_cached_job_reads_the_fields_of_the_execution_it_reuses(self):
self.record(1)
self.assertEqual(self.lookup(), [self.F])
def test_list_items_return_their_own_fields_in_order(self):
self.begin(1)
for index, text in enumerate(("x", "y")):
self.context = ("j1", index)
self.runs.record_item("a", (text,), Facts("h"), ((text, "", ""),))
self.runs.end_attempt(1)
self.assertEqual(
self.lookup(values=(("x", "y"),)), [(("x", "", ""),), (("y", "", ""),)]
)
def test_fails_closed(self):
self.record(1)
for case, kwargs in (
("other output", dict(values=(("changed",),))),
("other node", dict(name="b")),
("unknown node", dict(name="zzz")),
("no values", dict(values=())),
("malformed values", dict(values="x")),
):
with self.subTest(case=case):
self.assertIsNone(self.lookup(**kwargs))
with self.subTest(case="another job"):
self.begin(9, "j9")
self.assertIsNone(self.runs.recorded_fields("j8", "a", (("x",),)))
with self.subTest(case="no job"):
self.runs.end_attempt(9)
self.assertIsNone(self.runs.recorded_fields("j9", "a", (("x",),)))
def test_an_execution_without_fields_gives_none(self):
for fields in (None, (("a", "b"),), ((1, "", ""),), (), "abc"):
with self.subTest(fields=fields):
self.runs = CachedRuns(context=lambda: self.context)
self.record(1, fields=fields)
self.assertIsNone(self.lookup())
def test_a_lookup_never_counts_or_marks_the_node_replayed(self):
self.record(1)
self.lookup()
self.assertFalse(self.runs.replayed("a"))
def test_equal_outputs_with_other_raw_text_conflict_and_still_count(self):
key = prompt_node_key(self.g, "a")
self.record(1, fields=(("x ", "", ""),))
self.record(2, fields=(("x", "", ""),))
# Counting dedupes by outputs as before; the fields cannot name either
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
self.assertIsNone(self.lookup())
self.assertIsNotNone(self.runs._match(key, (("x",),)))
# The conflict stays: a later execution cannot tell which text it was
self.record(3, fields=(("x", "", ""),))
self.assertIsNone(self.lookup())
def test_equal_outputs_with_other_facts_still_count_the_newest(self):
key = prompt_node_key(self.g, "a")
self.record(1, facts=Facts("old"), fields=(("a x", "", ""),))
self.record(2, facts=Facts("new"), fields=(("x", "a ", ""),))
items = self.runs._match(key, (("x",),))
self.assertEqual([item.facts for item in items], [Facts("new")])
def test_equal_outputs_and_fields_still_replace(self):
key = prompt_node_key(self.g, "a")
self.record(1)
self.record(2)
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
self.assertEqual(self.lookup(), [self.F])
def test_fields_dropped_by_the_budget_do_not_conflict(self):
self.runs = CachedRuns(context=lambda: self.context, max_field_bytes=0)
self.record(1)
self.runs._max_field_bytes = 100
self.record(2)
self.assertEqual(self.lookup(), [self.F])
def test_oversized_or_invalid_fields_are_not_kept(self):
for raw in ("x" * (module.MAX_FIELD_BYTES + 1), "\ud800"):
with self.subTest(size=len(raw)):
self.runs = CachedRuns(context=lambda: self.context)
self.record(1, fields=((raw, "", ""),))
self.assertIsNone(self.lookup())
self.assertEqual(self.runs._field_bytes, 0)
def test_old_receipts_give_up_their_fields_to_new_ones(self):
self.runs = CachedRuns(
context=lambda: self.context, max_keys=3, max_field_bytes=10
)
self.record(1, "a", fields=(("123456", "", ""),))
self.record(2, "b", fields=(("123456", "", ""),))
self.assertEqual(self.runs._field_bytes, 6)
self.assertIsNone(self.lookup("a"))
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
# "a" still counts
self.assertIsNotNone(self.runs._match(prompt_node_key(self.g, "a"), (("x",),)))
# Larger than the whole budget: not kept, nothing else given up
self.record(3, "c", fields=(("12345678901", "", ""),))
self.assertEqual(self.runs._field_bytes, 6)
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
def test_one_receipt_holds_at_most_one_runs_history(self):
with mock.patch.object(module, "MAX_RECEIPT_FIELD_BYTES", 8):
self.begin(1)
for index, raw in enumerate(("12345", "12345")):
self.context = ("j1", index)
self.runs.record_item("a", (raw,), Facts("h"), ((raw, "", ""),))
self.runs.end_attempt(1)
self.assertEqual(self.runs._field_bytes, 5)
self.assertIsNone(self.lookup(values=(("12345", "12345"),)))
def test_the_byte_count_follows_replacement_and_eviction(self):
self.begin(1)
self.runs.record_item("a", ("x",), Facts("h"), (("12345", "", ""),))
self.runs.record_item("a", ("x",), Facts("h"), (("123", "", ""),))
self.assertEqual(self.runs._field_bytes, 3)
self.runs.end_attempt(1)
for item_id, raw in ((2, "1"), (3, "22"), (4, "333")):
self.record(item_id, output=raw, fields=((raw, "", ""),))
# max_receipts=2: only the receipts of jobs 4 and 3 remain
self.assertEqual(self.runs._field_bytes, 5)
for item_id, name in ((5, "b"), (6, "c")): # max_keys=2: "a" is dropped
self.record(item_id, name, fields=(("1", "", ""),))
self.assertEqual(self.runs._field_bytes, 2)
self.assertEqual(
self.runs._field_bytes,
sum(module._bytes(r) for r in self.runs._receipts.values()),
)
class TestCachedJobsRecordFields(JobCase):
"""The real nodes keep the texts they gave the history capture (#403)."""
def lookup(self, g, node_id="pm", prompt_id="job-x"):
self.item_id += 1
self.runs.begin_attempt(
((self.item_id, prompt_id, g, {}, ["save"], {}), self.item_id)
)
values = cached_string_outputs(self.executor, node_id, g[node_id]["class_type"])
found = self.runs.recorded_fields(prompt_id, node_id, values)
self.runs.end_attempt(self.item_id)
return found
def test_each_node_kind_keeps_its_runtime_fields(self):
for kind in (
"PromptManager",
"PromptManagerFocused",
"PromptManagerSplit",
"PromptManagerText",
):
with self.subTest(kind=kind):
g = graph(kind)
self.first(g, text=f" {LLM} ")
self.assertEqual(
self.lookup(graph(kind, seed=2)), [((f" {LLM} ", "", ""),)]
)
def test_pair_keeps_positive_and_negative_fields(self):
g = graph("PromptManagerPair", negative_text="typed negative")
self.first(g, negative="typed negative")
self.assertEqual(
self.lookup(
graph("PromptManagerPair", negative_text="typed negative", seed=2)
),
[((LLM, "", ""), ("typed negative", "", ""))],
)
def test_a_disabled_node_keeps_no_fields(self):
g = graph()
g["pm"]["inputs"]["save_to_library"] = False
self.first(g)
g2 = graph(seed=2)
g2["pm"]["inputs"]["save_to_library"] = False
self.assertIsNone(self.lookup(g2))
class TestCacheReader(unittest.TestCase):
def setUp(self):
patcher = mock.patch.dict(sys.modules, host_modules())
+143 -3
View File
@@ -100,14 +100,19 @@ class HostTests(unittest.TestCase):
self.executor.caches = types.SimpleNamespace(outputs=HierarchicalCache())
def begin(
self, kind="PromptManager", raw=" raw ", prepend=" pre ", append=" post "
self,
kind="PromptManager",
raw=" raw ",
prepend=" pre ",
append=" post ",
negative="negative",
):
g = {
"p": {
"class_type": kind,
"inputs": {
"text": raw,
"negative_text": "negative",
"negative_text": negative,
"prepend_text": prepend,
"append_text": append,
},
@@ -125,7 +130,7 @@ class HostTests(unittest.TestCase):
},
},
}
if type(raw) is list or type(prepend) is list:
if any(type(value) is list for value in (raw, prepend, negative)):
g["dynamic"] = {"class_type": "RuntimeGenerator", "inputs": {}}
g["stateful"] = {
"class_type": "PromptManagerText",
@@ -445,3 +450,138 @@ class HostTests(unittest.TestCase):
ref = install_and_remove()
gc.collect()
self.assertIsNone(ref())
# -- #403: a cached runtime text takes its fields from the reused execution
class Receipts:
"""A fake ``CachedRuns.recorded_fields``: fixed fields, calls recorded."""
def __init__(self, fields):
self.fields, self.calls = fields, []
def recorded_fields(self, prompt_id, node_id, values):
self.calls.append((prompt_id, node_id, values))
return self.fields
def observe_with(self, receipts, outputs, **begin):
self.begin(**begin)
self.executor.caches.outputs.entries["p"] = Entry(None, outputs)
self.observe("execution_start")
host.observe_cached_context(
self.executor,
"execution_cached",
{"prompt_id": JOB, "nodes": ["p"]},
receipts=receipts,
)
return self.seal()
def test_cached_runtime_raw_comes_from_the_matching_receipt(self):
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertEqual(receipts.calls, [(JOB, "p", (("cached",),))])
self.assertEqual(result.raw.value, "LLM raw")
self.assertEqual(
(result.raw.provenance, result.raw.source), ("inferred", "graph_semantics")
)
self.assertEqual(result.effective.value, "cached")
self.assertEqual(result.effective.source, "host_cache_output")
# Fields the graph already knows are never replaced
self.assertEqual(result.prepend.value, " pre ")
self.assertEqual(result.prepend.source, "graph_literal")
self.assertEqual(result.append.value, " post ")
def test_cached_runtime_prepend_comes_from_the_matching_receipt(self):
receipts = self.Receipts([((" raw ", "LLM pre", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], prepend=["dynamic", 0]
).positive
self.assertEqual(result.prepend.value, "LLM pre")
self.assertEqual(result.prepend.source, "graph_semantics")
self.assertEqual(result.raw.value, " raw ")
self.assertEqual(result.raw.source, "graph_literal")
def test_a_receipt_disagreeing_with_a_known_field_fills_nothing(self):
receipts = self.Receipts([(("LLM raw", "other pre", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.prepend.value, " pre ")
def test_pair_runtime_negative_takes_the_negative_fields(self):
receipts = self.Receipts(
[((" raw ", " pre ", " post "), ("LLM negative", "", ""))]
)
result = self.observe_with(
receipts,
[object(), object(), ["cached positive"], ["LLM negative"]],
kind="PromptManagerPair",
negative=["dynamic", 0],
)
self.assertEqual(result.negative.raw.value, "LLM negative")
self.assertEqual(result.negative.raw.source, "graph_semantics")
self.assertEqual(result.positive.raw.value, " raw ")
self.assertEqual(result.positive.raw.source, "graph_literal")
def test_known_typed_fields_are_never_replaced_by_a_receipt(self):
receipts = self.Receipts([(("other", "other", "other"),)])
result = self.observe_with(receipts, [object(), ["cached"]]).positive
self.assertEqual(
(result.raw.value, result.prepend.value, result.append.value),
(" raw ", " pre ", " post "),
)
self.assertEqual(result.raw.source, "graph_literal")
def test_no_matching_receipt_keeps_the_cached_raw_unknown(self):
for fields in (None, [None], [], "bad", [(("a", "b"),)], [((1, "", ""),)]):
with self.subTest(fields=fields):
result = self.observe_with(
self.Receipts(fields), [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.effective.value, "cached")
def test_a_failing_receipt_lookup_keeps_the_capture(self):
class Broken:
def recorded_fields(self, *args):
raise RuntimeError("boom")
result = self.observe_with(
Broken(), [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.effective.value, "cached")
def test_pair_slots_take_their_own_receipt_fields(self):
receipts = self.Receipts(
[(("LLM positive", " pre ", " post "), ("LLM negative", "", ""))]
)
result = self.observe_with(
receipts,
[object(), object(), ["cached positive"], ["cached negative"]],
kind="PromptManagerPair",
raw=["dynamic", 0],
)
self.assertEqual(
receipts.calls[0], (JOB, "p", (("cached positive",), ("cached negative",)))
)
self.assertEqual(result.positive.raw.value, "LLM positive")
self.assertEqual(result.positive.effective.value, "cached positive")
# The negative editor is typed: the graph's value stays
self.assertEqual(result.negative.raw.value, "negative")
self.assertEqual(result.negative.raw.source, "graph_literal")
def test_default_receipts_are_the_process_cached_runs(self):
from utils import cached_runs
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
with patch.object(cached_runs, "CACHED_RUNS", receipts):
self.begin(raw=["dynamic", 0])
self.executor.caches.outputs.entries["p"] = Entry(
None, [object(), ["cached"]]
)
self.observe("execution_start")
self.observe("execution_cached", nodes=["p"])
self.assertEqual(self.seal().positive.raw.value, "LLM raw")
+150 -1
View File
@@ -54,7 +54,7 @@ def status(kind="execution_success", job=JOB):
return Status("success", True, [(kind, {"prompt_id": job, "timestamp": 20})])
class HistoryHooksTests(unittest.TestCase):
class HooksCase(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
@@ -122,6 +122,8 @@ class HistoryHooksTests(unittest.TestCase):
def drain(self, registration):
drain_writer(registration.writer, clock=time.monotonic)
class HistoryHooksTests(HooksCase):
def test_package_history_initializes_when_monitor_and_usage_setup_fail(self):
try:
from tests.test_init_package import (
@@ -767,3 +769,150 @@ class HistoryHooksTests(unittest.TestCase):
self.assertEqual(
calls, ["execution_start", "execution_cached", "execution_cached"]
)
class CachedRuntimeHistoryTests(HooksCase):
"""A cached runtime-linked prompt is a tracked run, like an executed one (#403).
The real history hook and writer, a real ``CachedRuns`` and the real host
adapter over fakes of ComfyUI's executor, cache and executing context.
"""
TEXT = "a lighthouse the LLM wrote"
GRAPH = {
"str": {"class_type": "PrimitiveString", "inputs": {"value": "seed"}},
"llm": {"class_type": "PreviewAny", "inputs": {"source": ["str", 0]}},
"p": {"class_type": "PromptManager", "inputs": {"text": ["llm", 0]}},
"s": {"class_type": "KSampler", "inputs": {"positive": ["p", 0]}},
}
def setUp(self):
super().setUp()
from utils import cached_runs
from utils.cached_runs import CachedRuns
self.context = None
self.runs = CachedRuns(context=lambda: self.context or (None, None))
self.cache_entries = {}
entries = self.cache_entries
execution = types.ModuleType("execution")
caching = types.ModuleType("comfy_execution.caching")
package = types.ModuleType("comfy_execution")
utils_module = types.ModuleType("comfy_execution.utils")
entry_type = namedtuple("CacheEntry", "ui outputs")
class PromptExecutor:
def add_message(self, event, data, broadcast):
return None
class HierarchicalCache:
def get_local(self, node_id):
return entries.get(node_id)
execution.PromptExecutor, execution.CacheEntry = PromptExecutor, entry_type
caching.HierarchicalCache = HierarchicalCache
package.caching, package.utils = caching, utils_module
utils_module.get_executing_context = lambda: (
SimpleNamespace(prompt_id=self.context[0], node_id="p", list_index=0)
if self.context
else None
)
self.entry_type = entry_type
modules = patch.dict(
sys.modules,
{
"execution": execution,
"comfy_execution": package,
"comfy_execution.caching": caching,
"comfy_execution.utils": utils_module,
},
)
modules.start()
self.addCleanup(modules.stop)
swap = patch.object(cached_runs, "CACHED_RUNS", self.runs)
swap.start()
self.addCleanup(swap.stop)
self.executor = PromptExecutor()
self.executor.server = self.server
self.executor.caches = SimpleNamespace(outputs=HierarchicalCache())
self.prompt_id = self.db.save_prompt(text=self.TEXT)
self.item_id = 0
def job(self, cached):
"""One job; the node executes unless ``cached`` (ComfyUI's cache serves it)."""
from uuid import uuid4
from utils.cached_runs import stored_facts
from utils.execution_context_capture import record_runtime_context
self.item_id += 1
job = str(uuid4())
result = ((0, job, self.GRAPH, {}, ["s"]), self.item_id)
self.queue.results.append(result)
self.queue.get()
self.runs.begin_attempt(result)
self.context = (job, 0)
self.executor.add_message("execution_start", {"prompt_id": job}, False)
if cached:
self.executor.add_message(
"execution_cached", {"prompt_id": job, "nodes": ["p"]}, False
)
else:
# What PromptManager.encode_prompt records for this text
record_runtime_context("PromptManager", self.TEXT, "", "", self.TEXT)
self.runs.record_item(
"p", (self.TEXT,), stored_facts(self.TEXT), ((self.TEXT, "", ""),)
)
self.cache_entries["p"] = self.entry_type(None, [object(), [self.TEXT]])
self.context = None
self.queue.task_done(self.item_id, {}, status(job=job))
self.runs.end_attempt(self.item_id)
def tracked_runs(self):
with self.db.recorded_history_snapshot():
return self.db.list_history_window(self.prompt_id)["recorded_run_count"]
def test_three_jobs_of_runtime_text_are_three_tracked_runs(self):
registration = self.register()
for cached in (False, True, True):
self.job(cached)
self.drain(registration)
self.assertEqual(self.tracked_runs(), 3)
rows = self.rows()
for row in rows:
contexts = self.db.get_execution_contexts(row["run_id"])["contexts"]
self.assertEqual(contexts[0]["positive"]["raw"], self.TEXT)
self.assertEqual(contexts[0]["positive_prompt_id"], self.prompt_id)
cached = self.db.get_execution_contexts(rows[1]["run_id"])["contexts"][0]
self.assertEqual(
cached["evidence"]["positive"]["raw"],
{"provenance": "inferred", "source": "graph_semantics", "reason": None},
)
self.assertEqual(
cached["evidence"]["positive"]["effective"]["source"], "host_cache_output"
)
# The stored history passes the whole-database check older builds run too
from database.execution_history_schema import history_schema_error
self.assertIsNone(history_schema_error(self.db.model.get_connection()))
# An image of a cached job can now be attributed through its run (#342)
from database.run_attribution import named_prompt
self.assertEqual(
[named_prompt(self.db, row["run_id"]) for row in rows],
[self.prompt_id] * 3,
)
def test_without_a_receipt_a_cached_job_stays_untracked(self):
registration = self.register()
self.job(False)
self.runs._receipts.clear() # as after a restart with a provider entry
self.job(True)
self.drain(registration)
self.assertEqual(self.tracked_runs(), 1)
cached = self.db.get_execution_contexts(self.rows()[1]["run_id"])["contexts"]
self.assertIsNone(cached[0]["positive"]["raw"])
self.assertIsNone(cached[0]["positive_prompt_id"])
from database.run_attribution import named_prompt
self.assertIsNone(named_prompt(self.db, self.rows()[1]["run_id"]))
+32
View File
@@ -209,6 +209,38 @@ class TestMove(_MoveCase):
)
self.assertEqual(ll.read_marker(self.target)["state"], ll.MARKER_MOVED)
def test_the_workspace_key_moves_with_the_library(self):
# #430: open pages keep their scopes after the restart that follows.
from utils.workspace_keys import KEY_FILE_NAME, load_install_secret
secret = load_install_secret(self.ext)
result = self.move()
self.assertIn(KEY_FILE_NAME, result.files)
self.assertEqual(load_install_secret(self.target), secret)
if os.name == "posix":
mode = os.stat(os.path.join(self.target, KEY_FILE_NAME)).st_mode
self.assertEqual(mode & 0o777, 0o600)
def test_a_key_that_cannot_be_copied_never_fails_the_move(self):
from utils.workspace_keys import KEY_FILE_NAME, load_install_secret
load_install_secret(self.ext)
real = lm._hash_copy
def failing(source, destination):
if os.path.basename(source) == KEY_FILE_NAME:
raise OSError("disk error")
return real(source, destination)
with mock.patch.object(lm, "_hash_copy", failing):
with self.assertLogs("prompt_manager.database.library_move", "WARNING"):
result = self.move()
self.assertNotIn(KEY_FILE_NAME, result.files)
self.assertEqual(_rows(self.new_db), self.before["rows"])
self.assertFalse(
any(n.startswith(lm.TEMP_PREFIX) for n in os.listdir(self.target))
)
def test_new_files_are_private(self):
if os.name != "posix":
self.skipTest("POSIX permissions")
+4 -1
View File
@@ -99,7 +99,10 @@ class TestPairUsageTracking(unittest.TestCase):
unique_id="1",
)
node.cached_runs.record_item.assert_called_once_with(
"1", ("cat", ""), Facts(usage_tracking.generate_prompt_hash("cat"), None)
"1",
("cat", ""),
Facts(usage_tracking.generate_prompt_hash("cat"), None),
(("cat", "", ""), ("", "", "")),
)
def test_validation_rejection_never_counts_or_saves(self):
+8 -2
View File
@@ -308,7 +308,11 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
"typed text", prepend_text="pre", prompt=self.GRAPH, unique_id="1"
)
node.cached_runs.record_item.assert_called_once_with(
"1", ("pre typed text",), Facts(generate_prompt_hash("typed text"))
"1",
("pre typed text",),
Facts(generate_prompt_hash("typed text")),
# #403: the texts the history capture got, for a cached job's history
(("typed text", "pre", ""),),
)
# Hook-visible text: the node counts nothing itself
row = self.db.get_prompt_by_hash(generate_prompt_hash("typed text"))
@@ -319,7 +323,9 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
node = self._node()
node.process_text(" ", prompt=self.GRAPH, unique_id="1")
node.cached_runs.record_item.assert_called_once_with("1", (" ",), Facts(None))
node.cached_runs.record_item.assert_called_once_with(
"1", (" ",), Facts(None), ((" ", "", ""),)
)
if __name__ == "__main__":
+247
View File
@@ -0,0 +1,247 @@
"""The per-install workspace secret (#430, utils/workspace_keys.py).
A ComfyUI restart must not change the gallery root scopes or the workspace
cursors while the output roots stay the same, so the secret that keys them is
persisted beside the database instead of drawn per process.
"""
import importlib
import json
import logging
import os
import stat
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import utils.workspace_keys as workspace_keys # noqa: E402
from utils.workspace_keys import ( # noqa: E402
KEY_FILE_NAME,
WorkspaceKeys,
gallery_root_scope,
load_install_secret,
workspace_keys_for,
)
REPO = Path(__file__).resolve().parents[1]
class TempFolderCase(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(folder.cleanup)
self.folder = Path(os.path.realpath(folder.name))
self.key_file = self.folder / KEY_FILE_NAME
def leftovers(self):
return sorted(p.name for p in self.folder.iterdir() if p.name != KEY_FILE_NAME)
class TestLoadInstallSecret(TempFolderCase):
def test_a_missing_file_is_created_with_64_hex_and_owner_only_mode(self):
secret = load_install_secret(str(self.folder))
self.assertEqual(len(secret), 32)
text = self.key_file.read_text(encoding="ascii")
self.assertEqual(text, secret.hex() + "\n")
self.assertEqual(self.leftovers(), [], "no temp file is left behind")
if os.name == "posix":
self.assertEqual(stat.S_IMODE(self.key_file.stat().st_mode) & 0o077, 0)
def test_an_existing_file_is_reused(self):
first = load_install_secret(str(self.folder))
self.assertEqual(load_install_secret(str(self.folder)), first)
def test_an_uppercase_or_unterminated_file_is_still_read(self):
self.key_file.write_text("AB" * 32, encoding="ascii")
self.assertEqual(
load_install_secret(str(self.folder)), bytes.fromhex("ab" * 32)
)
def test_a_corrupt_file_is_replaced_with_a_warning(self):
for content in (b"", b"short", b"zz" * 32, b"ab" * 33, b"\xff" * 64):
with self.subTest(content=content):
self.key_file.write_bytes(content)
with self.assertLogs("prompt_manager.api", logging.WARNING) as logs:
secret = load_install_secret(str(self.folder))
self.assertEqual(len(secret), 32)
self.assertEqual(
self.key_file.read_text(encoding="ascii"), secret.hex() + "\n"
)
self.assertIn("replaced", "\n".join(logs.output))
self.assertNotIn(secret.hex(), "\n".join(logs.output))
self.assertEqual(self.leftovers(), [])
def test_a_lost_creation_race_reads_the_winners_file(self):
winner = "cd" * 32 + "\n"
real_link = os.link
def racing_link(source, target, *args, **kwargs):
Path(target).write_text(winner, encoding="ascii")
return real_link(source, target, *args, **kwargs) # FileExistsError
with patch.object(workspace_keys.os, "link", racing_link):
secret = load_install_secret(str(self.folder))
self.assertEqual(secret, bytes.fromhex("cd" * 32))
self.assertEqual(self.key_file.read_text(encoding="ascii"), winner)
self.assertEqual(self.leftovers(), [])
def test_a_file_system_without_hard_links_falls_back_to_replace(self):
def no_link(*args, **kwargs):
raise OSError(1, "Operation not permitted")
with patch.object(workspace_keys.os, "link", no_link):
secret = load_install_secret(str(self.folder))
self.assertEqual(self.key_file.read_text(encoding="ascii"), secret.hex() + "\n")
self.assertEqual(self.leftovers(), [])
def test_an_unwritable_folder_returns_none_and_leaves_nothing(self):
def refuse(*args, **kwargs):
raise PermissionError(13, "Permission denied")
with patch.object(workspace_keys.os, "open", refuse):
with self.assertLogs("prompt_manager.api", logging.WARNING):
self.assertIsNone(load_install_secret(str(self.folder)))
self.assertFalse(self.key_file.exists())
with self.assertLogs("prompt_manager.api", logging.WARNING):
self.assertIsNone(load_install_secret(str(self.folder / "missing")))
def test_an_unreadable_key_path_returns_none(self):
self.key_file.mkdir() # a folder where the file should be
with self.assertLogs("prompt_manager.api", logging.WARNING):
self.assertIsNone(load_install_secret(str(self.folder)))
def test_a_refused_replace_of_a_corrupt_key_falls_back(self):
# Windows refuses os.replace while another process holds the file open.
self.key_file.write_text("broken", encoding="ascii")
def refuse(*args, **kwargs):
raise PermissionError(13, "Access is denied")
with patch.object(workspace_keys.os, "replace", refuse):
with self.assertLogs("prompt_manager.api", logging.WARNING):
self.assertIsNone(load_install_secret(str(self.folder)))
self.assertEqual(self.key_file.read_text(encoding="ascii"), "broken")
self.assertEqual(self.leftovers(), [])
def test_a_lost_race_against_an_unfinished_file_is_unusable_not_replaced(self):
def racing_link(source, target, *args, **kwargs):
Path(target).write_text("ab", encoding="ascii") # still being written
raise FileExistsError(target)
with patch.object(workspace_keys.os, "link", racing_link):
with self.assertLogs("prompt_manager.api", logging.WARNING):
self.assertIsNone(load_install_secret(str(self.folder)))
self.assertEqual(self.key_file.read_text(encoding="ascii"), "ab")
@unittest.skipUnless(os.name == "posix", "POSIX permission bits")
def test_an_existing_key_readable_by_others_is_tightened(self):
secret = load_install_secret(str(self.folder))
os.chmod(self.key_file, 0o644)
self.assertEqual(load_install_secret(str(self.folder)), secret)
self.assertEqual(stat.S_IMODE(self.key_file.stat().st_mode), 0o600)
def test_old_temp_files_are_swept_and_fresh_ones_kept(self):
old = self.folder / f".{KEY_FILE_NAME}.dead.tmp"
fresh = self.folder / f".{KEY_FILE_NAME}.live.tmp"
other = self.folder / "prompts.db"
for path in (old, fresh, other):
path.write_text("x")
os.utime(old, (1, 1))
load_install_secret(str(self.folder))
self.assertEqual(self.leftovers(), sorted([fresh.name, other.name]))
class TestWorkspaceKeys(TempFolderCase):
def test_purposes_are_domain_separated_and_stable(self):
keys = workspace_keys_for(str(self.folder / "prompts.db"))
self.assertTrue(keys.persisted)
self.assertNotEqual(keys.root_scope, keys.cursor)
again = workspace_keys_for(str(self.folder / "prompts.db"))
self.assertEqual(
(again.root_scope, again.cursor), (keys.root_scope, keys.cursor)
)
self.assertNotIn(
self.key_file.read_text().strip(),
(keys.root_scope.hex(), keys.cursor.hex()),
)
def test_another_database_folder_gets_other_keys(self):
other = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(other.cleanup)
first = workspace_keys_for(str(self.folder / "prompts.db"))
second = workspace_keys_for(os.path.join(other.name, "prompts.db"))
self.assertNotEqual(first.root_scope, second.root_scope)
def test_an_unusable_folder_falls_back_to_a_process_key_with_a_warning(self):
with patch.object(workspace_keys, "load_install_secret", return_value=None):
with self.assertLogs("prompt_manager.api", logging.WARNING) as logs:
keys = workspace_keys_for(str(self.folder / "prompts.db"))
self.assertFalse(keys.persisted)
self.assertEqual(len(keys.root_scope), 32)
self.assertIn("restart", "\n".join(logs.output))
def test_memory_and_blank_paths_never_write_into_the_working_folder(self):
for path in (":memory:", "", None):
with self.subTest(path=path):
with patch.object(workspace_keys, "load_install_secret") as load:
with self.assertLogs("prompt_manager.api", logging.WARNING):
keys = workspace_keys_for(path)
load.assert_not_called()
self.assertFalse(keys.persisted)
def test_the_scope_is_the_historic_payload_under_the_root_scope_key(self):
import hashlib
import hmac
keys = WorkspaceKeys(b"r" * 32, b"c" * 32, True)
payload = json.dumps(
["prompt-manager-gallery-root-v1", 3, "/x/ü"],
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
self.assertEqual(
gallery_root_scope(keys, "/x/ü", 3),
hmac.new(b"r" * 32, payload, hashlib.sha256).hexdigest(),
)
class TestRestartKeepsScopes(TempFolderCase):
"""A new process or a reloaded module derives the same scope (#430)."""
def test_a_module_reload_keeps_the_scope(self):
db = str(self.folder / "prompts.db")
before = gallery_root_scope(workspace_keys_for(db), "/out", 0)
reloaded = importlib.reload(workspace_keys)
self.addCleanup(importlib.reload, workspace_keys)
self.assertEqual(
reloaded.gallery_root_scope(reloaded.workspace_keys_for(db), "/out", 0),
before,
)
def test_a_new_process_derives_the_same_scope(self):
db = str(self.folder / "prompts.db")
before = gallery_root_scope(workspace_keys_for(db), "/out", 0)
code = (
"import sys\n"
"from utils.workspace_keys import gallery_root_scope, workspace_keys_for\n"
"print(gallery_root_scope(workspace_keys_for(sys.argv[1]), '/out', 0))\n"
)
# -I would drop the repo from sys.path; the child imports only stdlib code.
result = subprocess.run(
[sys.executable, "-c", code, db],
cwd=REPO,
capture_output=True,
text=True,
timeout=60,
check=True,
)
self.assertEqual(result.stdout.strip(), before)
if __name__ == "__main__":
unittest.main()