* feat(workspace): shared concept toolbar and dialogs
Add the concept's shared page toolbar (search.toolbar with the search
field, category filter, sort and Filters) and its dialogs in one modal
shell: "Refine this view" (Source / Folder / Media / Tags, Apply filters),
"Import & inspect" with only the real operations (Open inspector, Rescan),
and the "Copy prompt" select-to-copy fallback. The sort lists the
concept's three options first (D14); there is no Save view (D16). Every
dialog returns focus to its trigger on close or Escape.
Refs #249
* fix(workspace): bind shared dialogs to the page and its route
- Drop the invented Refine note; every field is real (D14).
- Ignore a late close event once the shell was reopened or replaced, so
an action that opens the next dialog keeps it open.
- Dialog openers require the page scope; disposing it closes and removes
the shell unless a newer page's dialog is open. Every route change
closes the shared dialog before the next page mounts.
- Clear filters also reports category '' so the page clears the toolbar
category, as the concept does.
- The toolbar requires its scope and falls back to the first sort for an
unknown value; dialog callbacks are validated up front.
Refs #249
* feat(images): concept toolbar, filter row and caption
The Images page now renders the approved mock's heading, shared toolbar,
filter row, section caption and infinite continuation (#249, Task C2).
- Heading: "Your generations, their prompts, and the next idea." with
Import (upload icon) before New prompt (primary).
- Toolbar (shared mountToolbar): search over filenames, linked prompt
text, titles and tags; category (the Library's categories); sort
Recently added / Name A–Z / Highest rated, then Oldest first and
Name Z–A (D14).
- Filter row: Grid/List/Masonry segmented control, Thumbnail size,
the scope chip ("All output folders" or "<source> · <folder>") and
"Group by positive prompt →".
- Caption: "N of M images · K positive prompts" from positive_count; a
plain cursor page keeps the first page's count and an unknown count
hides the "· K positive prompts" part. The date span shows
"<Today|Yesterday|weekday> · <Month D>" of the newest loaded result
under a date sort and is hidden otherwise (D17).
- Continuation: "N of M images · more load as you scroll" with Load
more, the concept's end copy, Retry for a failed page, and the
concept's "K more images loaded. N of M." toast.
- The listing sends category, tags and sort=rating (PR B params).
Old control -> new control:
- "Search filenames" field -> toolbar search "Search workspace"
(placeholder "Search prompts, tags, filenames…"), also matching
prompt text and tags
- Source select -> Filters -> "Refine this view" Source
- Folder select -> Refine Folder (all sources' folders and ancestors)
- Media select -> Refine Media
- (new) Tags -> Refine Tags; Category -> toolbar category select
- Sort select (Newest/Oldest/Filename A–Z/Z–A) -> toolbar sort
(Recently added/Oldest first/Name A–Z/Name Z–A, plus Highest rated)
- Layout buttons -> .segmented Grid/List/Masonry in the filter row
- Thumbnail size field -> label.small + select#image-size
- "Group by positive prompt" link -> button.chip "Group by positive
prompt →" (#/library)
- Refresh -> Import -> "Scan an output folder" -> Rescan (D15)
- "Current gallery" link -> removed; Overview's tool cards link the
legacy gallery (D29)
- "All images" h2 + count -> .section-caption #result-count
- .image-continuation -> .infinite-continuation (Retry kept)
Legacy behavior kept: video items, keyboard grid navigation, scroll and
filter restoration, bounded DOM, continuation retry, source-change
recovery (now disabling the toolbar, Filters and Import). The Filters
and Import dialogs close on route change and browser Back.
The shared toolbar (C1) called .some() on select.options, which is an
HTMLOptionsCollection in a browser (only the unit tests' fake DOM gave
an array); setValue and setCategories now spread it first. The layout
segmented control may wrap at extreme zoom (D31) instead of scrolling
the 390px page sideways.
The retired .image-filters, .image-view-controls, .image-toolbar,
.image-layouts, .image-collection, .image-continuation and duplicated
.image-workspace grid rules are gone from images.css.
* test(workspace): model select.options as a browser collection
The toolbar units' fake DOM returned select.options as an Array, so the
toolbar's .some() call passed there while it threw in every browser
(fixed in 12228e2). options is now an indexed, iterable, frozen
HTMLOptionsCollection-like object without Array methods; against the C1
toolbar three units fail with "sort.options.some is not a function".
The units also assert that an unknown sort falls back to the first
option and that a still-listed category survives a category refresh.
* fix(images): concept Load more focus, empty state and review rulings
Review of #249 C2:
- Load more and Retry focus the first appended image, as the concept
does (app.js loadMoreImages). Focus moves without scrolling so the
visible cards keep their place (bounded-DOM anchor), and only while
focus is still on the pressed control: a viewer or a field the user
moved to keeps it. When a last page adds nothing and the button hides,
focus goes to the last image, never to the page; a failed page focuses
Retry.
- The concept's empty state beside the grid: .empty "No matching
images. Try another search or category."
- D17: the caption date shows only under Newest first (", YYYY" outside
the current year) and is hidden under Oldest first and the name and
rating sorts.
- en-US number grouping in the embedded count too.
- Refine "Folder" defaults to the concept's "All output folders". It is
not narrowed by Source: the concept has no Source field, and the list
holds every source's folders, so any pair can be chosen in one dialog.
- The shared toast moves to toast.mjs; library-editor re-exports it.
- The D32 overlay (extreme zoom) wraps the layout segmented control and
the continuation row instead of pushing them out of the 390px column.
- The legacy gallery checks moved to Overview's "Open existing image
gallery" card (D29).
Mapping additions: grid with no results -> .empty concept copy beside
the grid; Load more press -> focus the first new image (concept).
* feat(images): concept image cards; image opens the viewer
Cards are the concept media card: .media-art > button.image-open with the
image or video, and .media-info with the D10 title (linked title only when
the link is confirmed, else the filename; title attribute = path), the D11
subtitle "W x H · EXT · Seed N" (seed shown as the listing's string) and an
Inspect info button. The byte size and the Preview button are gone.
Activating the image (click, Enter, Space) opens the viewer and focus
returns to it on Escape; Info selects the image into the panel (D19).
Arrow, Home and End navigation, bounded rendering and scroll restoration
are kept.
Columns and gap now come from the concept grid's computed style
(gridMetrics) instead of a production breakpoint table, so the concept
breakpoints apply; a 120px minimum card bounds extreme zoom (D3, D31).
images.css card rules are retired into marked overlay rules. The scope
chip reads "All output folders" when no folder is chosen (D14).
* fix(images): pair-linked cards take the positive's title; concept Info, alt and size
- Pair links title the card, inspector and viewer from the linked positive's
Library title or confirmed first line, never the pair title (the negative
variant's name). The card now equals the concept card, title included.
- Info drops aria-pressed; the card's .selected carries selection, as in the
concept.
- Card images take the card title as alt and the listing's width/height.
- One shared minimum card width for layout and the narrow-card class.
- A debounced search that lands after a sort change no longer reloads page 1.
- 200% zoom test also runs at an 800px viewport and device scale 2.
Refs #249
* feat(images): concept hover zoom
* feat(images): favorite images from the card
* feat(images): concept viewer shell
* fix(images): one favorite request per file across leaving and returning
The pending set and the receiver of each answer now live at module level
beside the saved visit. A press on a returned Images page waits for the
request the earlier page sent, and its success or rollback reaches the
card on screen, or the saved visit when no Images page is mounted.
* fix(images): mark a star busy while its favorite request is pending
The star carries aria-disabled="true" until the answer arrives, also on a
card rendered after returning to Images, so the ignored second press is
announced as unavailable instead of doing nothing silently.
* perf(images): repaint one card on a favorite toggle instead of the collection
stream.updateItem tells onChange which item it patched, and the Images
page then skips the full layout pass (computed style, grid metrics and
layout over every loaded item) because no card changed size or place.
* docs(images): say the lone-star overlay rule is interim until the checkbox PR
* test(images): concept comparison for the Images page
images-concept.spec.mjs compares the production Images page with the
immutable concept on the concept-shaped fixture: heading, toolbar, filter
row, caption, grid, the first six cards, continuation and panel box in
Grid, List and Masonry, at 1600x1100 and 390x844, dark and light, normal
and reduced motion; plus hover zoom, the viewer title, focus restore,
Info selection, search and Load more with its toast. mapImagesConcept
applies only recorded deviations (D3, D8 and D16 follow-up PRs, D9, D10,
D14, D15, D17, D29, D31, D32); removed row items flow again from the
concept's own boxes. A mutation check proves the comparison fails on a
.media-info padding change and on removed Inspect buttons.
Corrections the comparison demonstrated:
- the grid and continuation carry the concept's ids (image-grid,
gallery-continuation);
- bounded rendering reserves the card footer at its fractional height:
offsetHeight rounded a wrapped 67.5px row up, so at 390px every card
was a pixel taller and the grid 6px taller than the concept.
Two mismatches wait for a ruling and are pinned on both sides: the
category order (concept prompt order, production alphabetical) and the
singular count line ("1 positive prompt").
* fix(images): ignore sub-pixel noise when the measured card footer grows
Under page zoom the fractional footer height (rect / scale) can carry float
noise above the stored value, which relaid the grid and scrolled for nothing.
* test(images): map D35-D37 and widen the Images concept comparison
- D35 (singular counts) and D36 (alphabetical categories) are mapped
deviations instead of pinned values; a search for exactly one image
checks both singular nouns, and a one-image Load more checks the toast
and continuation.
- Grid, cards and panel at 1200x900 (2 columns, 275px panel) and
1800x1100 (4 columns); Large and Compact join the layout loop.
- Masonry tops and heights compare unrounded within one layout unit per
card above in the column (D3), not as rounded integers.
- The Save view and sample-tag re-layout is checked against the concept
page with those items removed at runtime.
- The Load more row is compared before the press in both documents (D37).
- Two seedless fixture images (seedless_images on the concept reset)
prove every row reserves the tallest footer (D3).
- D10 paths come from the fixture manifest; the D8 star margin is mapped;
list-row derivations cite D3 and D9.
* fix(images): search never matches a pair's own variant title
Search covers the filename, the linked positive's text and Library title,
and its tags (#249 D12). The pair title names a negative variant, so a
variant name alone no longer admits the positive's images.
* fix(viewer): keep the Media controls group and drop a stale position description
The concept viewer toolbar is again a named group (#249 D34 #8), and
Previous/Next are described by the position line only while a single
item is loaded.
* docs(history): drop the remaining-polish notes the concept viewer and cards closed
The viewer now takes the concept stage and toolbar, and Images cards open
the viewer, select into the panel and favorite. Only the select checkbox
(#249 D8 follow-up) and the tag button (#249 D9) stay outside #209.
* test(images): the singular copy when scrolling loads one image; countCopy in the shared inventory helper
* refactor(images): retire the replaced images.css rules and lint the kept page sheets
The unused .image-facts rules go. The three prompt-block overrides on
concept selectors are marked #249 D22 interim until PR D deletes the
file, and every kept page stylesheet now passes the overlay lint.
* test(images): the extreme-zoom anchor probe reads the fractional footer measurement
Since the concept comparison (#249 C7) the grid measures footers with
getBoundingClientRect, so the probe that hooked offsetHeight never fired.
* test(images): cover the masonry fallback when a finite size underflows its ratio
* docs(images): concept Images page and recorded deviations
TESTING.md documents the Images page controls, the recorded #249
deviations and how the earlier workspace filters moved, and notes that
every kept page stylesheet is linted. The frontend coverage baseline
adds the toast, toolbar and workspace dialog modules.
* fix(images): show that Import → Rescan runs, and how it ended
The old Refresh button disabled itself while /gallery/rescan ran; Import →
Rescan closed its dialog and showed nothing. Now a toast says the rescan
started ("Rescanning output folders…") and how it ended ("Rescan finished.
N files in M output folders.", from the scan's own total and roots, or
"Rescan could not finish. Try again."). A reopened Import dialog shows
Rescan as "Rescanning…" and aria-disabled, and ignores a press on it.
Failing before: the held-rescan browser test saw no toast and no busy
state; the dialog unit test found no "Rescanning…" button.
* fix(images): rewrite the live status only when its text changes
A star press republishes the stream, and the textContent setter replaced
the continuation status, the count and the caption date with the same
text, so a polite role="status" region could announce it again. Text and
the role attribute are now written only when they change.
Failing before: a MutationObserver saw 10 records on those nodes after a
favorite toggle; now none.
* fix(images): Refine's Folder list follows the chosen Source
The workspace subfolder discovery returned only the union of every
source's folders, so "Source: temp" could be paired with a folder that
exists only under output and silently match nothing. In workspace mode
each root now lists its own folders, and "Refine this view" offers only
the chosen Source's folders: changing Source keeps a folder it also has
and otherwise returns Folder to "All output folders", as the old field
row did when it reloaded a Source's folders.
Failing before: the API test found no per-root folders (KeyError), the
dialog unit test and the browser test still offered output-only folders
under the input source.
* fix(images): an unchanged Refine apply keeps the loaded page
"Apply filters" (and "Clear filters" with nothing set) reset the stream,
refetched the first page and dropped the reader's scroll position even
when no filter changed. Like the toolbar's equality guard, an unchanged
refinement now does nothing.
Failing before: the browser test counted one listing fetch after an
unchanged Apply; now none, and the loaded cards stay in place.
* fix(images): one discovery alert when sources and categories both fail
With the server unreachable on load, the sources and the categories
status paragraphs each fired role="alert" back to back. The page now
keeps one alert, written once both first loads settled, naming whatever
failed ("Sources, folders and categories could not be loaded. Rescan
from Import to retry." when both did).
Failing before: getByRole('alert') resolved to two elements.
* fix(images): sort toolbar categories alphabetically on the client
D36 promises alphabetical real categories, which held only while the
server sorted them and a restored category was among its list. The page
now sorts them case-insensitively itself, a restored category included.
Failing before: the toolbar listed a server's [zeta, Alpha, beta] in
that order.
* refactor(db): drop the unused pair title from the link summary query
_link_summary has not read the pair's own title since the card took the
linked positive's Library title (D10), so the query no longer selects
it; the row indexes and the stale comment are updated. All values stay
bound.
Failing before: the summary SQL still selected pp.title.
* test(images): end negative checks on real signals, not fixed sleeps
The "no second request" checks waited a fixed 150 ms or 1000 ms and the
hover probe 450 ms, which can pass vacuously on a slow Firefox or WebKit
run. They now read the fetches the page's code started (the API client
calls fetch in the same task as the press), wait for a timer of the
toolbar's own debounce delay set after the debounced report, and wait
for the hover transition to finish.
Checked by mutation: without the favorite pending guard or the toolbar
equality guard, the rewritten checks fail.
* docs(testing): record the Rescan feedback, Source-bound folders and category sort
The Images contract now names the Rescan toasts and busy state (D15),
the Folder list that follows Source and the no-op unchanged refinement
(D14), and the page-side category sort (D36).
* test(viewer): escape every regex metacharacter in the filename match
* fix(viewer): title a linked card at once instead of waiting for its inspection
On a slow runner the viewer showed only the filename until the inspection
answered, while the concept titles it immediately. The card's linked
title is already loaded, so the header uses it on open.
The favorite test now waits for the first request to settle before the
Space press, which the one-request-per-file guard otherwise ignores.
* fix(images): cap the search at the listing API's 500 characters
A longer paste was sent as is, the API rejected it and the collection
showed "Images could not be loaded". The legacy search capped it.
892 lines
79 KiB
JavaScript
892 lines
79 KiB
JavaScript
import {writeFile} from 'node:fs/promises';
|
|
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
|
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
|
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
|
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
|
|
|
|
// Dialog-level contracts mount the editor through a page import; the user flows below use the real entry actions.
|
|
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
|
const workspace='/prompt_manager/workspace';
|
|
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
|
|
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Rating','Notes'];
|
|
const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 / 5'],['0','Unrated']];
|
|
const editorDialog=page=>page.locator('dialog.library-editor');
|
|
|
|
test.beforeEach(async({page,request,browserName})=>{
|
|
await startCoverage(page,browserName);
|
|
const reset=await request.post('/__test__/library-editor/reset',{data:{}});await expect(reset).toBeOK();page.fixture=await reset.json();
|
|
page.editorErrors=[];page.on('pageerror',error=>page.editorErrors.push(error.message));
|
|
page.writes=[];page.on('request',item=>{if(item.method()!=='GET')page.writes.push({method:item.method(),url:new URL(item.url()).pathname,body:item.postDataJSON()});});
|
|
});
|
|
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.editorErrors).toEqual([]);});
|
|
|
|
async function mountEditor(page,theme) {
|
|
await page.goto(`${workspace}#/overview`);await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
|
if(theme)await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
|
await page.evaluate(async()=>{
|
|
const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger);
|
|
window.editorEvents={committed:[],reconciled:[]};
|
|
try {
|
|
const {createLibraryEditor,showWorkspaceToast}=await import('/prompt_manager/js/workspace/library-editor.mjs');
|
|
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
|
|
const scope=createScope();
|
|
const editor=createLibraryEditor({api:window.ApiClient.createApiClient(),parentScope:scope,
|
|
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result)});
|
|
window.editorHarness={scope,editor,showWorkspaceToast};
|
|
} catch(error) {window.editorHarness={missing:String(error)};}
|
|
});
|
|
}
|
|
async function openEditor(page,options) {
|
|
await page.evaluate(options=>{const trigger=document.getElementById('editor-trigger');trigger.focus();window.editorHarness.editor?.open({...options,trigger});},options);
|
|
const dialog=editorDialog(page);await expect(dialog).toBeVisible();return dialog;
|
|
}
|
|
const field=(dialog,label)=>dialog.getByLabel(label,{exact:true});
|
|
const button=(dialog,name)=>dialog.getByRole('button',{name,exact:true});
|
|
const note=dialog=>dialog.locator('.warning-note');
|
|
async function ready(dialog) {await expect(button(dialog,'Save')).toBeEnabled();}
|
|
async function actionNames(dialog) {return dialog.locator('.dialog-actions button').evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));}
|
|
const detail=async(request,id,archive='all')=>(await(await request.get(`/prompt_manager/library/${id}?archive=${archive}`)).json());
|
|
const pairRead=async(request,id)=>(await(await request.get(`/prompt_manager/pairs/${id}`)).json()).pair;
|
|
|
|
test('edit recipe metadata shows the concept fields with the selected saved negative read-only',async({page})=>{
|
|
const f=page.fixture;await mountEditor(page);
|
|
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
|
|
await expect(dialog.getByRole('heading',{name:'Edit recipe metadata',exact:true})).toBeVisible();
|
|
expect(await dialog.evaluate(node=>node.matches(':modal'))).toBe(true);
|
|
await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog);
|
|
expect(await dialog.locator('.field label').evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(LABELS);
|
|
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);
|
|
await expect(field(dialog,'Positive prompt')).toHaveValue(f.positive_text);
|
|
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
|
|
for(const label of ['Positive prompt','Negative prompt (optional)'])await expect(field(dialog,label)).toHaveJSProperty('readOnly',true);
|
|
expect(await dialog.locator('textarea[readonly]').evaluateAll(nodes=>nodes.map(node=>node.tagName))).toEqual(['TEXTAREA','TEXTAREA']);
|
|
await expect(dialog.getByRole('button',{name:/copy|send/i})).toHaveCount(0);
|
|
await expect(dialog.getByRole('region')).toHaveCount(0);
|
|
await expect(field(dialog,'Category')).toHaveValue('Portraits');
|
|
await expect(field(dialog,'Tags, separated by commas')).toHaveValue('studio, window light');
|
|
await expect(field(dialog,'Rating')).toHaveValue('4');
|
|
await expect(field(dialog,'Notes')).toHaveValue(f.metadata.notes);
|
|
expect(await field(dialog,'Rating').locator('option').evaluateAll(nodes=>nodes.map(node=>[node.value,node.textContent]))).toEqual(RATINGS);
|
|
expect(await field(dialog,'Category').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['Portraits','Sketches']);
|
|
expect(await field(dialog,'Tags, separated by commas').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['draft,','studio,','window light,']);
|
|
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
|
|
await expect(dialog.locator('.demo-note')).toHaveCount(0);
|
|
expect(await actionNames(dialog)).toEqual(ACTIONS.metadata);
|
|
await expect(button(dialog,'Archive…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
|
|
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
|
await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0);
|
|
});
|
|
|
|
test('editor modes persist through reload with selected saved negative',async({page,request})=>{
|
|
const f=page.fixture;await mountEditor(page);
|
|
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await field(dialog,'Title').fill('Edited portrait');await field(dialog,'Category').fill('');
|
|
await field(dialog,'Tags, separated by commas').fill('studio, moody');await field(dialog,'Rating').selectOption('0');
|
|
await field(dialog,'Notes').fill('Edited notes');await button(dialog,'Save').click();
|
|
await expect(dialog).toHaveCount(0);
|
|
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/library/${f.positive_id}`]);
|
|
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],rating:null,notes:'Edited notes'});
|
|
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
|
|
const saved=(await detail(request,f.positive_id)).positive;
|
|
expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:null,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
|
|
|
|
await page.reload();await mountEditor(page);
|
|
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await expect(field(dialog,'Title')).toHaveValue('Edited portrait');await expect(field(dialog,'Category')).toHaveValue('');
|
|
await expect(field(dialog,'Rating')).toHaveValue('0');await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
|
|
await button(dialog,'Create text revision').click();
|
|
await expect(dialog.getByRole('heading',{name:'Create text revision',exact:true})).toBeVisible();
|
|
await expect(editorDialog(page)).toHaveCount(1);expect(await dialog.evaluate(node=>node.open&&node.matches(':modal'))).toBe(true);
|
|
await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog);
|
|
await expect(field(dialog,'Positive prompt')).toHaveJSProperty('readOnly',false);
|
|
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
|
|
expect(await actionNames(dialog)).toEqual(ACTIONS.revision);
|
|
await field(dialog,'Positive prompt').fill('Editor portrait, revised dusk light');await field(dialog,'Category').fill('');
|
|
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
|
const create=page.writes.at(-1);expect(`${create.method} ${create.url}`).toBe('POST /prompt_manager/library');
|
|
expect(create.body).toMatchObject({positive_text:'Editor portrait, revised dusk light',negative_text:f.selected_negative,
|
|
parent_metadata:{title:'Edited portrait',category:'Uncategorized',rating:null,notes:'Edited notes'}});
|
|
expect([...create.body.parent_metadata.tags].sort()).toEqual(['moody','studio']);
|
|
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
|
|
const revision=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
|
|
expect(revision).toMatchObject({positive_created:true,pair_created:true});expect(revision.positive.id).not.toBe(f.positive_id);
|
|
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized'});
|
|
|
|
dialog=await openEditor(page,{mode:'new'});await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
|
expect(await actionNames(dialog)).toEqual(ACTIONS.new);
|
|
for(const label of ['Title','Positive prompt'])await expect(field(dialog,label)).toHaveJSProperty('required',true);
|
|
const before=page.writes.length;await button(dialog,'Save').click();
|
|
await expect(field(dialog,'Title')).toBeFocused();expect(await field(dialog,'Title').evaluate(node=>node.validity.valueMissing)).toBe(true);
|
|
expect(page.writes).toHaveLength(before);
|
|
await field(dialog,'Title').fill('Fresh idea');await field(dialog,'Positive prompt').fill('A fresh lighthouse at dawn');
|
|
await field(dialog,'Rating').selectOption('5');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
|
expect(page.writes.at(-1).body).toEqual({expected_epoch:expect.any(String),positive_text:'A fresh lighthouse at dawn',negative_text:'',
|
|
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],rating:5,notes:''}});
|
|
|
|
dialog=await openEditor(page,{mode:'negative',positiveId:f.positive_id});
|
|
await expect(dialog.getByRole('heading',{name:'Add a negative variant',exact:true})).toBeVisible();await ready(dialog);
|
|
expect(await dialog.locator('.field label').evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Negative prompt']);
|
|
await expect(dialog.locator('p.small')).toHaveText('Leave empty for an explicitly empty negative. The positive stays unchanged.');
|
|
expect(await actionNames(dialog)).toEqual(ACTIONS.negative);
|
|
await field(dialog,'Negative prompt').fill('grain, dust');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
|
expect(page.writes.at(-1)).toMatchObject({method:'POST',url:`/prompt_manager/library/${f.positive_id}/variants`,body:{negative_text:'grain, dust'}});
|
|
const added=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
|
|
expect(added).toMatchObject({positive_created:false,pair_created:true,pair:{negative_text:'grain, dust'}});
|
|
await expect(page.locator('#editor-trigger')).toBeFocused();
|
|
});
|
|
|
|
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
|
|
const f=page.fixture;await mountEditor(page);
|
|
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.standalone_id,pairId:null});await ready(dialog);
|
|
const archive=button(dialog,'Archive…');await expect(archive).toBeDisabled();
|
|
await expect(archive).toHaveAccessibleDescription('Archive needs a selected saved negative variant.');
|
|
await expect(note(dialog)).toHaveText('Archive needs a selected saved negative variant.');
|
|
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();
|
|
|
|
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await button(dialog,'Archive…').click();
|
|
await expect(dialog.getByRole('heading',{name:'Archive negative variant',exact:true})).toBeVisible();
|
|
await expect(button(dialog,'Close dialog')).toBeFocused();
|
|
const block=dialog.getByRole('region',{name:'Negative prompt',exact:true});
|
|
await expect(block).toHaveClass(/\bprompt-negative\b/);await expect(block.locator('pre.prompt-text')).toHaveText(f.selected_negative);
|
|
await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
|
await expect(dialog).toContainText('You can restore it from Show archived in Negative variants.');
|
|
expect(await actionNames(dialog)).toEqual(['Cancel','Archive']);await expect(button(dialog,'Archive')).toHaveClass(/\bdanger\b/);
|
|
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
|
|
await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0);
|
|
expect((await pairRead(request,f.pair_id)).archived).toBe(false);
|
|
|
|
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
|
await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
|
expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:true}}]);
|
|
expect((await pairRead(request,f.pair_id)).archived).toBe(true);
|
|
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.at(-1)?.commit.kind)).toBe('archive-pair');
|
|
});
|
|
|
|
test('a failed archive keeps focus on the Archive confirmation inside the dialog',async({page})=>{
|
|
const f=page.fixture;await mountEditor(page);
|
|
let failed=false;
|
|
await page.route(`**/prompt_manager/pairs/${f.pair_id}`,route=>{
|
|
if(route.request().method()!=='PATCH'||failed)return route.continue();
|
|
failed=true;return route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Cannot archive.","code":null}'});
|
|
});
|
|
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
|
await button(dialog,'Archive').click();
|
|
await expect(note(dialog)).toContainText('Cannot archive.');expect(page.writes).toHaveLength(1);
|
|
await expect(button(dialog,'Archive')).toBeFocused();
|
|
expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true);
|
|
});
|
|
|
|
test('an unconfirmed archive moves focus to Retry instead of dropping it out of the dialog',async({page,request})=>{
|
|
const f=page.fixture;await mountEditor(page);let dropped=false;
|
|
await page.route(`**/prompt_manager/pairs/${f.pair_id}`,async route=>{
|
|
if(route.request().method()==='PATCH'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;}
|
|
await route.continue();
|
|
});
|
|
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
|
await button(dialog,'Archive').click();
|
|
await expect(note(dialog)).toHaveText('Archive could not be confirmed. Retry sends the same archive once.');
|
|
await expect(button(dialog,'Archive')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused();
|
|
expect(page.writes).toHaveLength(1);
|
|
await page.keyboard.press('Enter');await expect(dialog).toHaveCount(0);
|
|
expect(page.writes).toHaveLength(2);expect((await pairRead(request,f.pair_id)).archived).toBe(true);
|
|
});
|
|
|
|
test('an archive conflict offers an explicit Reload that re-reads and never replays the archive',async({page,request})=>{
|
|
const f=page.fixture;await mountEditor(page);
|
|
let conflicted=false;
|
|
await page.route(`**/prompt_manager/pairs/${f.pair_id}`,route=>{
|
|
if(route.request().method()!=='PATCH'||conflicted)return route.continue();
|
|
conflicted=true;return route.fulfill({status:409,contentType:'application/json',body:'{"success":false,"error":"changed","code":"metadata-conflict"}'});
|
|
});
|
|
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
|
await button(dialog,'Archive').click();
|
|
await expect(note(dialog)).toHaveText('This negative variant changed since it was loaded. Reload it and review it before archiving.');
|
|
await expect(button(dialog,'Archive')).toBeDisabled();expect(page.writes).toHaveLength(1);
|
|
expect(await actionNames(dialog)).toEqual(['Reload','Cancel','Archive']);
|
|
await button(dialog,'Reload').click();
|
|
await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review them, then archive.');
|
|
await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
|
await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText(f.selected_negative);
|
|
expect(page.writes).toHaveLength(1);expect((await pairRead(request,f.pair_id)).archived).toBe(false);
|
|
await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
|
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/pairs/${f.pair_id}`,`PATCH /prompt_manager/pairs/${f.pair_id}`]);
|
|
expect((await pairRead(request,f.pair_id)).archived).toBe(true);
|
|
});
|
|
|
|
test('cancel and busy submit have honest write counts',async({page})=>{
|
|
const f=page.fixture;await mountEditor(page);
|
|
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(0);
|
|
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await field(dialog,'Notes').fill('changed then cancelled');await button(dialog,'Close dialog').click();
|
|
await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0);
|
|
|
|
let release;const held=new Promise(resolve=>{release=resolve;});
|
|
await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{if(route.request().method()==='PATCH')await held;await route.continue();});
|
|
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await field(dialog,'Notes').fill('busy save');await button(dialog,'Save').click();
|
|
await expect(note(dialog)).toHaveText('Saving… The dialog stays open until the server answers.');
|
|
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
|
|
await field(dialog,'Title').press('Enter');await page.keyboard.press('Escape');
|
|
await expect(dialog).toBeVisible();expect(page.writes).toHaveLength(1);
|
|
release();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(1);
|
|
await expect(page.locator('#editor-trigger')).toBeFocused();
|
|
});
|
|
|
|
test('recovery states render inline and never replay a write',async({page,request})=>{
|
|
const f=page.fixture;await mountEditor(page);
|
|
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.negative_only_id,pairId:null});
|
|
await expect(note(dialog)).toHaveText('The latest saved values could not be loaded. Nothing was saved.');
|
|
await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeVisible();
|
|
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
|
|
|
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
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:{notes:'Changed elsewhere'}}})).toBeOK();
|
|
await field(dialog,'Title').fill('Conflicting title');await button(dialog,'Save').click();
|
|
await expect(note(dialog)).toHaveText('This prompt changed since it was loaded. Reload it and review your entries before saving.');
|
|
await expect(button(dialog,'Save')).toBeDisabled();await button(dialog,'Reload').click();
|
|
await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.');
|
|
await expect(field(dialog,'Title')).toHaveValue('Conflicting title');await expect(field(dialog,'Notes')).toHaveValue('Changed elsewhere');
|
|
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
|
expect(page.writes.filter(write=>write.method==='PATCH')).toHaveLength(2);
|
|
expect((await detail(request,f.positive_id)).positive).toMatchObject({title:'Conflicting title',notes:'Changed elsewhere'});
|
|
|
|
page.writes.length=0;
|
|
dialog=await openEditor(page,{mode:'new'});await ready(dialog);
|
|
const snapshot=await request.get('/__test__/library-editor/snapshot');await expect(snapshot).toBeOK();
|
|
const restore=await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:await snapshot.body()}}});
|
|
await expect(restore).toBeOK();page.writes.length=0;
|
|
await field(dialog,'Title').fill('After restore');await field(dialog,'Positive prompt').fill('Restored storage prompt');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,'Reload Library state').click();
|
|
await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.');
|
|
await expect(field(dialog,'Title')).toHaveValue('After restore');expect(page.writes).toHaveLength(1);
|
|
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
|
|
|
|
page.writes.length=0;let dropped=false;
|
|
await page.route('**/prompt_manager/library',async route=>{
|
|
if(route.request().method()==='POST'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;}
|
|
await route.continue();
|
|
});
|
|
dialog=await openEditor(page,{mode:'new'});await ready(dialog);
|
|
await field(dialog,'Title').fill('Lost response');await field(dialog,'Positive prompt').fill('Lost response prompt');await button(dialog,'Save').click();
|
|
await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry sends the same save once.');
|
|
await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused();
|
|
await page.unroute('**/prompt_manager/library');
|
|
await page.route('**/prompt_manager/library/*?variant_limit=40',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1});
|
|
await button(dialog,'Retry').click();
|
|
await expect(note(dialog)).toHaveText('Saved; Library refresh failed.');
|
|
expect(await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt)).toMatchObject({positive_created:false,pair_created:false});
|
|
expect(page.writes.map(write=>write.body.positive_text)).toEqual(['Lost response prompt','Lost response prompt']);
|
|
await button(dialog,'Retry refresh').click();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(2);
|
|
const rows=(await(await request.get('/prompt_manager/library?q=Lost%20response')).json()).items.filter(item=>item.text==='Lost response prompt');
|
|
expect(rows).toHaveLength(1);
|
|
});
|
|
|
|
test('edge states keep drafts, explain disabled archive and clean up on dispose',async({page,request})=>{
|
|
const f=page.fixture;
|
|
await page.route('**/prompt_manager/tags',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'}));
|
|
await mountEditor(page);
|
|
let dialog=await openEditor(page,{mode:'new',draft:{title:'Drafted title',notes:'Drafted notes'}});await ready(dialog);
|
|
await expect(field(dialog,'Title')).toHaveValue('Drafted title');await expect(field(dialog,'Notes')).toHaveValue('Drafted notes');
|
|
expect(await field(dialog,'Tags, separated by commas').evaluate(node=>node.list.options.length)).toBe(0);
|
|
await field(dialog,'Title').fill(' ');await field(dialog,'Positive prompt').fill('Spaces only title');await button(dialog,'Save').click();
|
|
await expect(note(dialog)).toHaveText('Title is required');expect(page.writes).toHaveLength(0);
|
|
await page.route('**/prompt_manager/library',route=>route.request().method()==='POST'
|
|
?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Title is too long.","code":null}'}):route.continue(),{times:1});
|
|
await field(dialog,'Title').fill('Rejected title');await button(dialog,'Save').click();
|
|
await expect(note(dialog)).toHaveText('Not saved: Title is too long. Your entries are kept.');
|
|
await expect(button(dialog,'Save')).toBeEnabled();await expect(field(dialog,'Title')).toHaveValue('Rejected title');
|
|
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
|
|
|
dialog=await openEditor(page,{mode:'metadata',positiveId:f.negative_only_id,pairId:null});
|
|
await expect(button(dialog,'Retry')).toBeVisible();await button(dialog,'Retry').click();
|
|
await expect(note(dialog)).toHaveText('The latest saved values could not be loaded. Nothing was saved.');await button(dialog,'Cancel').click();
|
|
|
|
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.archived_pair_id});await ready(dialog);
|
|
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.archived_negative);
|
|
await expect(button(dialog,'Archive…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
|
|
await expect(button(dialog,'Archive…')).toHaveAccessibleDescription('This negative variant is already archived.');
|
|
await button(dialog,'Cancel').click();
|
|
|
|
let failed=true;
|
|
await page.route(`**/prompt_manager/pairs/${f.empty_pair_id}`,route=>{if(!failed&&route.request().method()==='GET'){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'});}return route.continue();});
|
|
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.empty_pair_id});await ready(dialog);
|
|
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue('');
|
|
failed=false;await button(dialog,'Archive…').click();
|
|
await expect(note(dialog)).toHaveText('Linked images and recorded runs could not be read. Nothing was archived.');
|
|
await expect(button(dialog,'Archive')).toBeDisabled();
|
|
await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
|
|
await button(dialog,'Retry').click();await expect(dialog).toContainText('0 images and 0 recorded runs stay linked.');
|
|
await expect(button(dialog,'Archive')).toBeEnabled();expect(page.writes).toHaveLength(1);// only the rejected create
|
|
|
|
await page.evaluate(()=>window.editorHarness.scope.dispose());
|
|
await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();
|
|
await page.evaluate(()=>window.editorHarness.editor.open({mode:'new'}));await expect(editorDialog(page)).toHaveCount(0);
|
|
expect(page.writes).toHaveLength(1);expect((await pairRead(request,f.empty_pair_id)).archived).toBe(false);
|
|
});
|
|
|
|
test('the archive confirmation does not inherit a failed or invalid Edit save message',async({page})=>{
|
|
const f=page.fixture;await mountEditor(page);
|
|
await page.route(`**/prompt_manager/library/${f.positive_id}`,route=>route.request().method()==='PATCH'
|
|
?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Notes are too long.","code":null}'}):route.continue());
|
|
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await field(dialog,'Notes').fill('Rejected notes');await button(dialog,'Save').click();
|
|
await expect(note(dialog)).toHaveText('Not saved: Notes are too long. Your entries are kept.');
|
|
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
|
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
|
|
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
|
|
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await field(dialog,'Title').fill(' ');await button(dialog,'Save').click();await expect(note(dialog)).toHaveText('Title is required');
|
|
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
|
|
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
|
|
expect(page.writes).toHaveLength(1);
|
|
});
|
|
|
|
test('an unconfirmed Edit save says Retry reloads for review and resends nothing',async({page})=>{
|
|
const f=page.fixture;await mountEditor(page);let dropped=false;
|
|
await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{
|
|
if(route.request().method()==='PATCH'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;}
|
|
await route.continue();
|
|
});
|
|
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
await field(dialog,'Notes').fill('Unconfirmed notes');await button(dialog,'Save').click();
|
|
await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.');
|
|
// Save is disabled now; focus moves to the one recovery action instead of falling out of the modal.
|
|
await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused();
|
|
await page.keyboard.press('Enter');
|
|
await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.');
|
|
expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true);
|
|
await expect(field(dialog,'Notes')).toHaveValue('Unconfirmed notes');expect(page.writes).toHaveLength(1);
|
|
});
|
|
|
|
function toastStyle(node) {
|
|
const style=getComputedStyle(node);
|
|
return Object.fromEntries(['position','bottom','left','transform','backgroundColor','borderTopWidth','borderTopColor','paddingTop','paddingLeft',
|
|
'borderTopLeftRadius','boxShadow','zIndex','maxWidth','fontSize','display'].map(key=>[key,style[key]]));
|
|
}
|
|
for(const width of [1600,390])test(`the shared workspace toast is one polite status matching the concept ${width}`,async({page})=>{
|
|
await page.setViewportSize({width,height:844});
|
|
await page.goto('/__test__/canonical/workspace#library');await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption('dark');
|
|
await page.locator('#toast').evaluate(node=>{node.textContent='Negative saved without adding another positive or gallery.';});const reference=await page.locator('#toast').evaluate(toastStyle);
|
|
await mountEditor(page,'dark');
|
|
const toast=page.locator('#workspace-toast');await expect(toast).toHaveCount(1);
|
|
await expect(toast).toHaveAttribute('role','status');await expect(toast).toHaveAttribute('aria-live','polite');await expect(toast).toBeHidden();
|
|
await page.evaluate(()=>window.editorHarness.showWorkspaceToast('Negative saved without adding another positive or gallery.'));
|
|
await expect(page.getByRole('status').filter({hasText:'Negative saved without adding another positive or gallery.'})).toBeVisible();
|
|
expect(await toast.evaluate(toastStyle)).toEqual(reference);
|
|
expect(reference).toMatchObject({position:'fixed',bottom:width<=700?'12px':'22px',borderTopWidth:'1px',paddingTop:'12px',paddingLeft:'18px',borderTopLeftRadius:'6px',fontSize:'12px'});
|
|
});
|
|
|
|
const conceptModes={
|
|
new:['library',async page=>page.getByRole('button',{name:'+ New prompt',exact:true}).click()],
|
|
metadata:['prompt/alpine',async page=>page.getByRole('button',{name:'Edit metadata',exact:true}).click()],
|
|
revision:['prompt/alpine',async page=>{await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await page.locator('#modal').getByRole('button',{name:'Create text revision',exact:true}).click();}],
|
|
negative:['prompt/alpine',async page=>page.getByRole('button',{name:'+ Add negative',exact:true}).click()],
|
|
};
|
|
|
|
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`dialog geometry, tokens and controls match the concept ${width} ${theme}`,async({page},testInfo)=>{
|
|
await page.setViewportSize({width,height});const f=page.fixture;const result={};
|
|
for(const mode of Object.keys(conceptModes)) {
|
|
const [hash,openMode]=conceptModes[mode];
|
|
await page.goto('about:blank');await page.goto(`/__test__/canonical/workspace#${hash}`);
|
|
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openMode(page);const modal=page.locator('#modal');await expect(modal).toBeVisible();
|
|
if(mode==='revision')await page.locator('#modal-close').focus();
|
|
const reference=await modal.evaluate(inventoryOf);
|
|
await captureCanonicalState(page,testInfo,`concept-editor-${mode}-${width}-${theme}`,'#modal');
|
|
result[mode]={reference};
|
|
}
|
|
await mountEditor(page,theme);
|
|
const options={new:{mode:'new'},metadata:{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id},revision:{mode:'revision',positiveId:f.positive_id,pairId:f.pair_id},negative:{mode:'negative',positiveId:f.positive_id}};
|
|
for(const mode of Object.keys(conceptModes)) {
|
|
const dialog=await openEditor(page,options[mode]);await ready(dialog);
|
|
result[mode].actual=await dialog.evaluate(inventoryOf);
|
|
await captureCanonicalState(page,testInfo,`production-editor-${mode}-${width}-${theme}`,'dialog.library-editor');
|
|
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
|
}
|
|
const path=testInfo.outputPath('editor-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
|
await testInfo.attach('editor-geometry',{path,contentType:'application/json'});
|
|
for(const [mode,{reference,actual}] of Object.entries(result)) {
|
|
const expected=mapConcept(reference);
|
|
expect.soft(reference.demo,`${mode} concept demo note`).toBe(true);
|
|
expect.soft(expected.actions.buttons.map(item=>item.text),`${mode} actions`).toEqual(ACTIONS[mode]);
|
|
expect.soft(actual.dialog,`${mode} dialog`).toEqual(expected.dialog);
|
|
for(const key of ['header','title','close','fields','twoCol','small','actions','demo'])expect.soft(actual[key],`${mode} ${key}`).toEqual(expected[key]);
|
|
// The UA modal max-inline-size (100% - 6px - 2em) also applies to the concept at 390px.
|
|
expect.soft(actual.dialog).toMatchObject({paddingTop:'24px',borderTopLeftRadius:'9px',borderTopWidth:'1px',width:Math.min(650,width-24,width-34),modal:true});
|
|
expect.soft(actual.title.fontSize).toBe('18px');
|
|
expect.soft(actual.actions).toMatchObject({justifyContent:'flex-end',columnGap:'8px',flexWrap:'wrap',marginTop:'23px'});
|
|
for(const item of actual.fields)expect.soft(item.field).toMatchObject({rowGap:'7px',fontSize:'12px'});
|
|
for(const item of actual.fields.filter(item=>item.tag==='textarea'))expect.soft(item.control.minHeight).toBe('100px');
|
|
if(actual.twoCol)expect.soft(actual.twoCol.columns,`${mode} two columns`).toBe(width<=700?1:2);
|
|
if(testInfo.project.name==='chromium')expect.soft(actual.dialog.backdrop).toBe('rgba(8, 11, 18, 0.72)');
|
|
}
|
|
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
// Real entry actions: Library, Images and prompt history headings, and the Negative variants panel.
|
|
const historyRegion=page=>page.getByRole('region',{name:'Prompt history',exact:true});
|
|
const headingActions=page=>page.locator('#workspace-main > .heading > .heading-actions');
|
|
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
|
|
const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true});
|
|
const toast=page=>page.locator('#workspace-toast');
|
|
const visibleNames=locator=>locator.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
|
|
const pressedVariant=page=>variantsPanel(page).locator('.library-variant-dot[aria-pressed="true"]');
|
|
async function openHistory(page,id) {
|
|
await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible();
|
|
}
|
|
async function selectPair(page,name) {
|
|
const dot=variantsPanel(page).getByRole('button',{name,exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
|
|
}
|
|
/** In-app navigation away and back, as the concept's hash router does (app.js navigate: state.variant = 0). */
|
|
async function leaveAndReturn(page,id) {
|
|
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
|
await page.evaluate(id=>{location.hash=`#/library/${id}`;},id);await expect(variantsPanel(page)).toBeVisible();await expect(pressedVariant(page)).toHaveCount(1);
|
|
}
|
|
const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text});
|
|
|
|
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');
|
|
expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
|
|
// One linked image is counted in the singular.
|
|
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 linked image',{exact:true})).toHaveCount(1);
|
|
await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
|
await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
|
|
for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
|
|
await actions.nth(1).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
|
await field(dialog,'Title').fill('Harbor at dawn');await field(dialog,'Positive prompt').fill('A quiet harbor at dawn');await field(dialog,'Negative prompt (optional)').fill('fog, noise');
|
|
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
|
const created=(await(await request.get('/prompt_manager/library?text=quiet%20harbor')).json()).items[0];
|
|
await expect(page).toHaveURL(new RegExp(`#/library/${created.id}$`));
|
|
await expect(page.locator('#workspace-main h1')).toHaveText('Harbor at dawn');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
|
await expect(toast(page)).toHaveText('Saved.');
|
|
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('fog, noise');
|
|
// Exactly one saved negative is counted in the singular, in history and on the shelf card.
|
|
await expect(variantsPanel(page).getByText('1 of 1 saved negative variant',{exact:true})).toHaveCount(1);
|
|
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
|
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('1 saved negative variant',{exact:true})).toHaveCount(1);
|
|
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']);
|
|
});
|
|
|
|
test('new from Images cancel preserves browse',async({page})=>{
|
|
await page.goto(`${workspace}#/images`);await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
|
// #249 C2: the concept's Images heading puts Import before New prompt.
|
|
const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['Import','New prompt']);
|
|
const trigger=actions.last();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
|
|
await trigger.click();let dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
|
await field(dialog,'Title').fill('Discarded');await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
|
|
await expect(trigger).toBeFocused();await expect(page).toHaveURL(/#\/images$/);await expect(page.locator('.library-shelf, .library-history')).toHaveCount(0);
|
|
expect(page.writes).toHaveLength(0);
|
|
await trigger.click();dialog=editorDialog(page);await ready(dialog);
|
|
await field(dialog,'Title').fill('From images');await field(dialog,'Positive prompt').fill('A prompt saved from Images');await button(dialog,'Save').click();
|
|
await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('From images');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
|
});
|
|
|
|
test('history heading follows the concept and sends the selected saved pair',async({page})=>{
|
|
const f=page.fixture;await openHistory(page,f.positive_id);
|
|
const actions=headingActions(page).locator('button');
|
|
expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
|
|
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
|
|
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
|
|
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
|
|
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']);
|
|
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false');
|
|
// The concept opens a prompt history on its first negative variant (app.js navigate: state.variant = 0), so Send starts enabled.
|
|
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(send).toBeEnabled();
|
|
await selectPair(page,'Select negative variant 2');await expect(send).toBeEnabled();
|
|
await expect(savedTexts(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0);
|
|
await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
|
|
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);await expect(send).toBeFocused();
|
|
await actions.nth(0).click();await expect(page).toHaveURL(/#\/library$/);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
|
expect(page.writes).toHaveLength(0);
|
|
});
|
|
|
|
test('returning to a history opens on the first negative, forgetting a clicked variant or All images',async({page})=>{
|
|
const f=page.fixture;await openHistory(page,f.positive_id);
|
|
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
|
await selectPair(page,'Select negative variant 2');
|
|
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
|
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
|
|
await all.click();await expect(all).toHaveAttribute('aria-pressed','true');await expect(pressedVariant(page)).toHaveCount(0);
|
|
await leaveAndReturn(page,f.positive_id);
|
|
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false');
|
|
await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0);
|
|
});
|
|
|
|
test('add negative re-renders history with the saved or reused pair selected',async({page})=>{
|
|
const f=page.fixture;await openHistory(page,f.positive_id);
|
|
const add=variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true});
|
|
await add.click();let dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill('grain, dust');await button(dialog,'Save').click();
|
|
await expect(dialog).toHaveCount(0);await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
|
await expect(toast(page)).toHaveText('Negative saved without adding another positive or gallery.');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
|
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('grain, dust');
|
|
await expect(laneLabel(page,'Negative variant 1 ·')).toHaveCount(1);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
|
await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click();
|
|
await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.');
|
|
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
|
await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1);
|
|
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3');
|
|
// The saved selection applies to that save only; returning opens on the first negative, as in the concept.
|
|
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
|
expect(page.writes.map(write=>write.url)).toEqual([`/prompt_manager/library/${f.positive_id}/variants`,`/prompt_manager/library/${f.positive_id}/variants`]);
|
|
});
|
|
|
|
test('reused pair outside first forty remains selected without loading every page',async({page,request})=>{
|
|
test.setTimeout(90000);
|
|
const f=page.fixture;let current=await detail(request,f.positive_id,'active');
|
|
for(let index=0;index<41;index++) {
|
|
const response=await request.post(`/prompt_manager/library/${f.positive_id}/variants`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,negative_text:`bulk negative ${index}`}});
|
|
await expect(response).toBeOK();current={...current,positive:(await response.json()).positive};
|
|
}
|
|
const variantCursorRequests=[];page.on('request',item=>{if(item.url().includes('variant_cursor'))variantCursorRequests.push(item.url());});
|
|
await openHistory(page,f.positive_id);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');page.writes.length=0;
|
|
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();
|
|
const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
|
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.first_negative);
|
|
await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');
|
|
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41);
|
|
expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1);
|
|
});
|
|
|
|
test('edit metadata stays on the page and re-reads the selected pair',async({page,request})=>{
|
|
const f=page.fixture;await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2');
|
|
const pairReads=[];page.on('request',item=>{if(item.method()==='GET'&&new URL(item.url()).pathname===`/prompt_manager/pairs/${f.pair_id}`)pairReads.push(item.url());});
|
|
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
|
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
|
|
const before=pairReads.length;
|
|
await field(dialog,'Title').fill('Edited in place');await field(dialog,'Category').fill('Studio work');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
|
await expect(page.locator('#workspace-main h1')).toHaveText('Edited in place');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
|
await expect(toast(page)).toHaveText('Saved.');
|
|
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('.library-surface .library-chip').first()).toHaveText('Studio work');
|
|
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 2');
|
|
await expect.poll(()=>pairReads.length).toBeGreaterThan(before);
|
|
await expect(headingActions(page).locator('button.send')).toHaveAccessibleName('Send prompt pair to ComfyUI: Edited in place');
|
|
expect((await detail(request,f.positive_id)).positive).toMatchObject({title:'Edited in place',category:'Studio work'});
|
|
});
|
|
|
|
test('saved refresh failure retries only reads',async({page})=>{
|
|
const f=page.fixture;await openHistory(page,f.positive_id);
|
|
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
|
let failed=false;
|
|
await page.route(`**/prompt_manager/library/${f.positive_id}?variant_limit=40`,route=>{if(page.writes.length&&!failed){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'});}return route.continue();});
|
|
await field(dialog,'Notes').fill('Refresh after failure');await button(dialog,'Save').click();
|
|
await expect(page.getByText('Saved; Library refresh failed.',{exact:true})).toBeVisible();
|
|
const writeRequests=page.writes;expect(writeRequests).toHaveLength(1);
|
|
await button(dialog,'Retry refresh').click();await expect(dialog).toHaveCount(0);expect(writeRequests).toHaveLength(1);
|
|
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
|
});
|
|
|
|
test('restored storage after a confirmed save or before an archive offers an explicit Library reload',async({page,request})=>{
|
|
const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body();
|
|
const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}});
|
|
await openHistory(page,f.positive_id);
|
|
await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{
|
|
if(route.request().method()!=='PATCH')return route.continue();
|
|
const response=await route.fetch();await expect(await restore()).toBeOK();return route.fulfill({response});
|
|
});
|
|
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
|
await field(dialog,'Notes').fill('Lost to a restore');await button(dialog,'Save').click();
|
|
await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.');
|
|
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
|
|
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
|
|
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);
|
|
await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
|
|
await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1);
|
|
await selectPair(page,'Select negative variant 2');
|
|
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
|
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
|
|
await expect(await restore()).toBeOK();await button(dialog,'Archive').click();
|
|
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was archived.');
|
|
expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Archive']);expect(page.writes).toHaveLength(1);
|
|
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
|
|
expect((await pairRead(request,f.pair_id)).archived).toBe(false);
|
|
});
|
|
|
|
const DISCARDS='Leaving this dialog discards your entries.';
|
|
test('restored storage before an Edit or Add negative save offers only the Library reload exit',async({page,request})=>{
|
|
const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body();
|
|
const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}});
|
|
await openHistory(page,f.positive_id);
|
|
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
|
await expect(await restore()).toBeOK();
|
|
await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click();
|
|
await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`);
|
|
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
|
|
await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled();
|
|
// Revise and archive would re-read the old numeric IDs, which may now name another prompt.
|
|
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
|
|
await expect(button(dialog,'Reload Library')).toBeFocused();
|
|
expect(page.writes).toHaveLength(1);// the refused save
|
|
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
|
|
expect(page.writes).toHaveLength(1);expect((await detail(request,f.positive_id)).positive.notes).toBe(f.metadata.notes);
|
|
|
|
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
|
await expect(await restore()).toBeOK();
|
|
await field(dialog,'Negative prompt').fill('typed before the restore');await button(dialog,'Save').click();
|
|
await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`);
|
|
expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Save']);
|
|
await expect(button(dialog,'Reload Library state')).toHaveCount(0);expect(page.writes).toHaveLength(2);
|
|
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
|
|
expect(page.writes).toHaveLength(2);
|
|
expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false);
|
|
});
|
|
|
|
test('Restore stays disabled until the restore settles and sends one PATCH',async({page,request})=>{
|
|
const f=page.fixture;await openHistory(page,f.positive_id);
|
|
let release;const held=new Promise(resolve=>{release=resolve;});
|
|
await page.route(`**/prompt_manager/pairs/${f.archived_pair_id}`,async route=>{if(route.request().method()==='PATCH')await held;await route.continue();});
|
|
const toggle=()=>variantsPanel(page).getByRole('button',{name:/^Show archived/});
|
|
const restoreButton=()=>variantsPanel(page).locator('.library-variant-archived').getByRole('button',{name:/^Restore/});
|
|
await toggle().click();await expect(restoreButton()).toBeEnabled();
|
|
await restoreButton().click();await expect.poll(()=>page.writes.length).toBe(1);await expect(restoreButton()).toBeDisabled();
|
|
// Hiding and showing archived variants rebuilds the lane while the restore is in flight.
|
|
await toggle().click();await toggle().click();
|
|
await expect(restoreButton()).toBeDisabled();await restoreButton().evaluate(node=>node.click());
|
|
release();
|
|
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0);
|
|
expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.archived_pair_id}`,body:{archived:false}}]);
|
|
expect((await pairRead(request,f.archived_pair_id)).archived).toBe(false);
|
|
});
|
|
|
|
test('archive hides a pair, Show archived finds it and Restore keeps it selected with links and counts unchanged',async({page,request})=>{
|
|
const f=page.fixture;
|
|
const evidence=async()=>{
|
|
const pair=await pairRead(request,f.pair_id);const gallery=await(await request.get(`/prompt_manager/library/${f.positive_id}/images?limit=50`)).json();
|
|
const history=await(await request.get(`/prompt_manager/library/${f.positive_id}/history`)).json();const parent=(await detail(request,f.positive_id,'all')).positive;
|
|
return {counts:{images:pair.image_count,runs:pair.run_count},gallery:gallery.images.map(item=>item.id),history:history.recorded_run_count,parentImages:parent.image_count};
|
|
};
|
|
const before=await evidence();expect(before.counts).toEqual({images:1,runs:2});
|
|
await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2');
|
|
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
|
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
|
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
|
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
|
|
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
|
|
expect(await evidence()).toEqual(before);
|
|
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false');
|
|
await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
|
|
const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2);
|
|
const lane=archived.filter({hasText:'· 1 linked image · Archived'});await expect(lane).toHaveCount(1);
|
|
const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
|
|
const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);});
|
|
await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
|
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
|
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
|
|
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible();
|
|
expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id));
|
|
await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled();
|
|
const writes=page.writes.length;
|
|
await lane.getByRole('button',{name:/^Restore/}).click();
|
|
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toBeVisible();
|
|
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1);
|
|
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
|
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:false}}]);
|
|
expect((await pairRead(request,f.pair_id)).archived).toBe(false);expect(await evidence()).toEqual(before);
|
|
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
|
|
expect(galleryReads.at(-1).get('archive')).toBeNull();
|
|
});
|
|
|
|
test('filter hidden save resets shelf cursor and measured heights retaining anchor',async({page})=>{
|
|
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Standalone');
|
|
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
|
const shelfReads=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&item.method()==='GET')shelfReads.push(url.searchParams);});
|
|
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
|
await field(dialog,'Title').fill('Hidden by filter');await field(dialog,'Positive prompt').fill('Lighthouse in heavy rain');await button(dialog,'Save').click();
|
|
await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('Hidden by filter');
|
|
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
|
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Standalone');
|
|
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
|
await expect(page.getByText('The saved prompt is hidden by the current filters.',{exact:false})).toBeVisible();
|
|
expect(shelfReads.length).toBeGreaterThan(0);expect(shelfReads.every(params=>!params.has('cursor'))).toBe(true);
|
|
expect(shelfReads.at(-1).get('text')).toBe('Standalone');
|
|
});
|
|
|
|
test('export downloads the full Library JSON without a toast and reports a failure before any download',async({page,request})=>{
|
|
await page.addInitScript(()=>{window.revokedUrls=[];const revoke=URL.revokeObjectURL.bind(URL);URL.revokeObjectURL=url=>{window.revokedUrls.push(url);revoke(url);};});
|
|
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
|
|
const exportButton=page.getByRole('button',{name:'Export full Library JSON',exact:true});
|
|
const [download]=await Promise.all([page.waitForEvent('download'),exportButton.click()]);
|
|
expect(download.suggestedFilename()).toBe('prompt-manager-library-v1.json');
|
|
const saved=JSON.parse(await (await import('node:fs/promises')).readFile(await download.path(),'utf8'));
|
|
expect(saved).toEqual(await(await request.get('/prompt_manager/library/export')).json());
|
|
expect(saved).toMatchObject({schema_version:1,kind:'prompt-manager-library'});
|
|
await expect.poll(()=>page.evaluate(()=>window.revokedUrls.length)).toBe(1);await expect(toast(page)).toBeHidden();
|
|
await page.route('**/prompt_manager/library/export',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'}));
|
|
let downloads=0;page.on('download',()=>downloads++);await exportButton.click();
|
|
await expect(page.getByRole('alert').filter({hasText:'The Library export could not be downloaded.'})).toBeVisible();
|
|
expect(downloads).toBe(0);await expect(toast(page)).toBeHidden();expect(page.writes).toHaveLength(0);
|
|
});
|
|
|
|
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.library-variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
|
|
|
|
test('archived reuse is reported, shown under Show archived and selected read-only from Add negative and New',async({page})=>{
|
|
const f=page.fixture;await openHistory(page,f.positive_id);
|
|
const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);});
|
|
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
|
await field(dialog,'Negative prompt').fill(f.archived_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
|
const message='This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.';
|
|
await expect(toast(page)).toHaveText(message);
|
|
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true');
|
|
const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1);
|
|
await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
|
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.archived_negative);
|
|
await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id));
|
|
await uniqueDotNames(page);
|
|
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
|
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(f.archived_negative);
|
|
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
|
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
|
await expect(toast(page)).toHaveText(message);
|
|
await expect(variantsPanel(page).locator('.library-variant-archived .library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
|
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
|
|
});
|
|
|
|
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page})=>{
|
|
const f=page.fixture;await page.goto(`${workspace}#/library`);
|
|
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
|
await field(dialog,'Title').fill('A different title');await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('brand new negative');
|
|
await field(dialog,'Notes').fill('Draft notes');await button(dialog,'Save').click();
|
|
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
|
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
|
|
await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
|
|
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
|
await expect(field(dialog,'Title')).toHaveValue('A different title');await expect(field(dialog,'Notes')).toHaveValue('Draft notes');
|
|
await button(dialog,'Cancel').click();
|
|
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
|
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click();
|
|
expect(page.writes).toHaveLength(1);
|
|
});
|
|
|
|
test('reuse with the stored parent metadata entered does not claim it was not applied',async({page})=>{
|
|
const f=page.fixture;await page.goto(`${workspace}#/library`);
|
|
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
|
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('another brand new negative');
|
|
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
|
|
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
|
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
|
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
|
|
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
|
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click();
|
|
expect(page.writes).toHaveLength(1);
|
|
});
|
|
|
|
test('reuse of an uncategorized prompt with the category left blank is not unapplied metadata, a different category is',async({page,request})=>{
|
|
const f=page.fixture;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:{category:''}}})).toBeOK();
|
|
expect((await detail(request,f.positive_id)).positive.category).toBeNull();
|
|
const reuse=async(category,negative)=>{
|
|
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
|
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(negative);
|
|
await field(dialog,'Category').fill(category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
|
|
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
|
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
|
};
|
|
await reuse('','blank category negative');
|
|
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
|
|
await reuse('Landscapes','different category negative');
|
|
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
|
|
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library','POST /prompt_manager/library']);
|
|
});
|
|
|
|
test('turning Show archived on again after a hidden archive shows the newly archived pair',async({page})=>{
|
|
const f=page.fixture;await openHistory(page,f.positive_id);
|
|
const toggle=()=>variantsPanel(page).getByRole('button',{name:/^Show archived/});
|
|
await toggle().click();await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1);
|
|
await toggle().click();await expect(toggle()).toHaveAttribute('aria-pressed','false');await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0);
|
|
await selectPair(page,'Select negative variant 2');
|
|
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
|
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
|
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
|
|
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
|
|
await expect(laneLabel(page,'· 1 linked image · Archived')).toHaveCount(1);await uniqueDotNames(page);
|
|
});
|
|
|
|
test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{
|
|
const f=page.fixture;await mountEditor(page,'dark');
|
|
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
|
// Work on a copy of the production dialog so the live editor is untouched; ids are renamed to keep label/list wiring valid.
|
|
await dialog.evaluate(node=>{
|
|
const copy=node.cloneNode(true);copy.removeAttribute('open');copy.id='mutant';copy.classList.add('mutant');
|
|
const ids=new Set([...copy.querySelectorAll('[id]')].map(item=>item.id));
|
|
for(const item of copy.querySelectorAll('[id]'))item.id=`${item.id}-copy`;
|
|
for(const item of copy.querySelectorAll('[for],[list],[aria-labelledby],[aria-describedby]'))for(const attr of ['for','list','aria-labelledby','aria-describedby']) {
|
|
const value=item.getAttribute(attr);if(value&&ids.has(value))item.setAttribute(attr,`${value}-copy`);
|
|
}
|
|
copy.setAttribute('open','');node.parentElement.append(copy);
|
|
});
|
|
const mutant=page.locator('dialog.mutant');await expect(mutant).toBeVisible();
|
|
const baseline=await mutant.evaluate(inventoryOf);
|
|
expect(diffInventory(baseline,await mutant.evaluate(inventoryOf))).toEqual([]);
|
|
await page.addStyleTag({content:'dialog.mutant .danger{color:rgb(1,2,3)!important}dialog.mutant .field input{color:rgb(4,5,6)!important;background-color:rgb(7,8,9)!important}'});
|
|
await mutant.getByRole('button',{name:'Save',exact:true}).evaluate(node=>node.remove());
|
|
const paths=diffInventory(baseline,await mutant.evaluate(inventoryOf)).map(item=>item.path);
|
|
expect(paths).toContain('actions.buttons.length');
|
|
expect(paths).toContain('actions.buttons.1.color');
|
|
expect(paths).toContain('fields.0.control.color');
|
|
expect(paths).toContain('fields.0.control.backgroundColor');
|
|
await page.keyboard.press('Escape');
|
|
});
|
|
|
|
test('long and error states retain footer focus and reflow',async({page})=>{
|
|
await page.setViewportSize({width:390,height:844});
|
|
await mountEditor(page,'light');
|
|
const dialog=await openEditor(page,{mode:'new'});await ready(dialog);
|
|
const unbroken='W'.repeat(240);
|
|
await field(dialog,'Title').fill(`A very long title that keeps going ${unbroken} and going`);
|
|
await field(dialog,'Positive prompt').fill('A quiet harbor at dawn');
|
|
await field(dialog,'Notes').fill(Array.from({length:60},(_,index)=>`Long note line ${index} ${unbroken.slice(0,40)}`).join('\n'));
|
|
await page.route('**/prompt_manager/library',route=>route.request().method()==='POST'
|
|
?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Title is too long.","code":null}'}):route.continue(),{times:1});
|
|
await button(dialog,'Save').click();
|
|
await expect(note(dialog)).toHaveText('Not saved: Title is too long. Your entries are kept.');
|
|
// Save had focus when the write started and keeps it after the failure; focus never falls out of the modal.
|
|
await expect(button(dialog,'Save')).toBeFocused();
|
|
expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true);
|
|
const state=await dialog.evaluate(node=>({scrolls:node.scrollHeight>node.clientHeight,height:node.getBoundingClientRect().height,innerHeight,overflowY:getComputedStyle(node).overflowY,
|
|
pageOverflow:document.documentElement.scrollWidth-document.documentElement.clientWidth,dialogOverflow:node.scrollWidth-node.clientWidth,
|
|
left:node.getBoundingClientRect().left,right:node.getBoundingClientRect().right,viewport:innerWidth}));
|
|
expect(state.scrolls).toBe(true);expect(state.height).toBeLessThanOrEqual(state.innerHeight*.9+1);expect(['auto','scroll']).toContain(state.overflowY);
|
|
expect(state.pageOverflow).toBeLessThanOrEqual(1);expect(state.dialogOverflow).toBeLessThanOrEqual(1);expect(state.left).toBeGreaterThanOrEqual(0);expect(state.right).toBeLessThanOrEqual(state.viewport);
|
|
for(const name of ['Cancel','Save']) {
|
|
const item=button(dialog,name);await item.scrollIntoViewIfNeeded();await expect(item).toBeVisible();await expect(item).toBeEnabled();
|
|
await item.focus();await expect(item).toBeFocused();
|
|
const box=await item.boundingBox();expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(390);
|
|
}
|
|
// Keyboard: Tab from Cancel reaches Save inside the dialog; the draft survives the error.
|
|
await button(dialog,'Cancel').focus();await page.keyboard.press('Tab');await expect(button(dialog,'Save')).toBeFocused();
|
|
await expect(field(dialog,'Positive prompt')).toHaveValue('A quiet harbor at dawn');
|
|
expect(page.writes.filter(write=>write.method==='POST')).toHaveLength(1);
|
|
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
|
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
|
});
|
|
|
|
// Task A2: the editor dialogs keep matching the concept on the concept base stylesheet, with library-editor.css retired.
|
|
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
|
|
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
|
|
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
|
|
await page.setViewportSize({width,height});const f=page.fixture;const references={};
|
|
for(const mode of Object.keys(conceptModes)) {
|
|
const [hash,openMode]=conceptModes[mode];
|
|
await page.goto('about:blank');await page.goto(`/__test__/canonical/workspace#${hash}`);
|
|
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openMode(page);await expect(page.locator('#modal')).toBeVisible();
|
|
if(mode==='revision')await page.locator('#modal-close').focus();
|
|
references[mode]=mapConcept(await page.locator('#modal').evaluate(inventoryOf));
|
|
}
|
|
await mountEditor(page,theme);
|
|
expect(await page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')))).toEqual(CONCEPT_BASE);
|
|
const options={new:{mode:'new'},metadata:{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id},revision:{mode:'revision',positiveId:f.positive_id,pairId:f.pair_id},negative:{mode:'negative',positiveId:f.positive_id}};
|
|
for(const mode of Object.keys(conceptModes)) {
|
|
const dialog=await openEditor(page,options[mode]);await ready(dialog);
|
|
expect(diffInventory(references[mode],await dialog.evaluate(inventoryOf)),`${mode} dialog`).toEqual([]);
|
|
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
|
}
|
|
});
|