The Library bar's Delete… previews the selection and opens the shared review: what is deleted (in no saved pair), archived (paired: hidden, restorable) or kept (tagged __protected__), the safety backup and what cannot be undone. Show archived prompts (N) lists archived prompts with Restore on their bar; an archived prompt's page shows an Archived notice with Restore. TESTING.md records D152; the route snapshot gains the three workspace routes.
296 lines
21 KiB
JavaScript
296 lines
21 KiB
JavaScript
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
|
import {test,expect} from './fixtures.mjs';
|
|
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
|
|
|
// #437 D148: select prompts on the Library shelf and set their category in one step (the legacy admin's Set Category).
|
|
const route='/prompt_manager/workspace#/library';
|
|
const cards=page=>page.locator('article[data-prompt-id]');
|
|
const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`);
|
|
const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]');
|
|
const bar=page=>page.locator('#library-bulkbar > .bulkbar');
|
|
const dialog=page=>page.getByRole('dialog',{name:'Categorize selected prompts',exact:true});
|
|
const toast=page=>page.locator('.toast');
|
|
const heading=page=>page.locator('#workspace-main h1');
|
|
const tagsDialog=page=>page.getByRole('dialog',{name:'Tag selected prompts',exact:true});
|
|
const chips=(page,id)=>card(page,id).locator('.tags > .chip');
|
|
async function addTags(page,value) {
|
|
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
|
|
await expect(tagsDialog(page)).toBeVisible();
|
|
await tagsDialog(page).locator('#action-input').fill(value);
|
|
await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
|
|
}
|
|
|
|
async function shelf(request) {
|
|
const body=await(await request.get('/prompt_manager/library?limit=100')).json();
|
|
return body.items;
|
|
}
|
|
/** Every POST to the bulk category route, with its body. */
|
|
function recordWrites(page) {
|
|
const writes=[];
|
|
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/category')writes.push(item.postDataJSON());});
|
|
return writes;
|
|
}
|
|
/** The first mounted card's index once two frames in a row agree (the shelf's window follows scrolling a frame later). */
|
|
async function stableFirstIndex(page) {
|
|
return page.evaluate(()=>new Promise(resolve=>{
|
|
const first=()=>document.querySelector('article[data-prompt-id]')?.dataset.index;
|
|
let previous=first();
|
|
const check=()=>requestAnimationFrame(()=>requestAnimationFrame(()=>{const now=first();if(now===previous)resolve(Number(now));else{previous=now;check();}}));
|
|
check();
|
|
}));
|
|
}
|
|
async function setCategory(page,value) {
|
|
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
|
|
await expect(dialog(page)).toBeVisible();
|
|
await dialog(page).locator('#action-input').fill(value);
|
|
await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
|
|
}
|
|
|
|
test.beforeEach(async({page,request,browserName})=>{
|
|
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
|
|
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
|
|
});
|
|
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
|
|
|
|
test('Set category writes the selected prompts on the server, reloads the shelf and clears the selection',async({page,request})=>{
|
|
const items=await shelf(request);const [first,second,third]=items;
|
|
const writes=recordWrites(page);
|
|
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
|
|
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
|
// The checkbox is named after the card's title, as the Images overlay names its file.
|
|
await expect(box(page,first.id)).toHaveAccessibleName(new RegExp(`^Select `));
|
|
await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected');
|
|
await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected');
|
|
// Two of three loaded are checked, so Select all N loaded follows the count (D151, #444 ruling).
|
|
// D152 (#443) adds Delete… before Clear.
|
|
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Delete…','Clear']);
|
|
await setCategory(page,' Studio set ');
|
|
await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.');
|
|
await expect(dialog(page)).toHaveCount(0);
|
|
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
|
for(const item of [first,second]) await expect(card(page,item.id).locator('.card-title .chip')).toHaveText('Studio set');
|
|
await expect(card(page,third.id).locator('.card-title .chip')).toHaveText(third.category || 'Uncategorized');
|
|
await expect(box(page,first.id)).not.toBeChecked();
|
|
const after=await shelf(request);
|
|
expect(after.filter(item=>item.category==='Studio set').map(item=>item.id).sort()).toEqual([first.id,second.id].sort());
|
|
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
|
expect(writes).toEqual([{prompt_ids:[first.id,second.id],category:'Studio set',expected_epoch:epoch}]);
|
|
// The toolbar's category list now offers the new value, and so does the next dialog.
|
|
await expect(page.locator('#category-filter option',{hasText:'Studio set'})).toHaveCount(1);
|
|
await box(page,third.id).check();
|
|
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
|
|
await expect(dialog(page).locator('datalist option[value="Studio set"]')).toHaveCount(1);
|
|
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
|
|
expect(writes).toHaveLength(1);
|
|
});
|
|
|
|
test('a blank or control-character category keeps the dialog open; Clear and Escape clear the selection',async({page,request})=>{
|
|
const items=await shelf(request);const writes=recordWrites(page);
|
|
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
|
|
await box(page,items[0].id).check();
|
|
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
|
|
const field=dialog(page).locator('#action-input');
|
|
await field.fill(' ');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
|
|
await expect(dialog(page)).toBeVisible();await expect(field).toBeFocused();
|
|
await field.fill('bad\tvalue');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
|
|
await expect(dialog(page)).toBeVisible();
|
|
expect(await field.evaluate(node=>node.validationMessage)).toBe('A category cannot contain control characters.');
|
|
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
|
|
expect(writes).toHaveLength(0);
|
|
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
|
|
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
|
|
await expect(box(page,items[0].id)).not.toBeChecked();
|
|
await box(page,items[1].id).check();
|
|
await bar(page).getByRole('button',{name:'Set category',exact:true}).focus();await page.keyboard.press('Escape');
|
|
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
|
|
// A filter change replaces the shelf and clears the selection too.
|
|
await box(page,items[1].id).check();await expect(bar(page)).toBeVisible();
|
|
await page.locator('#search').fill('portrait');
|
|
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
|
});
|
|
|
|
test('Shift+click selects a range across windowed cards, Tab reaches the checkbox at the window edge, and a return keeps the fence',async({page,request,isMobile})=>{
|
|
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
|
|
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
|
|
const writes=recordWrites(page);
|
|
await page.goto(route);await expect(cards(page).first()).toBeVisible();
|
|
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
|
for(let index=0;index<3;index++) {
|
|
await expect(more).toHaveAttribute('aria-disabled','false');
|
|
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
|
|
await more.evaluate(node=>node.click());await response;
|
|
}
|
|
await expect(more).toHaveAttribute('aria-disabled','false');
|
|
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
|
|
await box(page,firstId).check();
|
|
// Windowed: fewer cards are mounted than are loaded.
|
|
expect(await cards(page).count()).toBeLessThan(160);
|
|
// Tab from the last mounted card's last control lands on the next (unmounted) card's checkbox.
|
|
const last=cards(page).last();const lastIndex=Number(await last.getAttribute('data-index'));
|
|
await last.locator('button').last().focus();await page.keyboard.press('Tab');
|
|
const next=page.locator(`article[data-index="${lastIndex+1}"]`);
|
|
await expect(next.locator('.image-overlays input')).toBeFocused();
|
|
// Shift+click that card's checkbox: every card from the first to it is selected, mounted or not.
|
|
await next.locator('.image-overlays > label').click({modifiers:['Shift']});
|
|
await expect(bar(page).locator('strong')).toHaveText(`${lastIndex+2} selected`);
|
|
// Shift+Tab from a checkbox whose previous card is unmounted lands on that card's last control.
|
|
// Halfway down the loaded cards (short of the continuation, so no page loads), once the window stops moving.
|
|
await page.evaluate(()=>{const grid=document.querySelector('.library-grid');scrollTo(0,grid.getBoundingClientRect().top+scrollY+grid.offsetHeight/2);});
|
|
await expect.poll(()=>page.locator('article[data-index="0"]').count()).toBe(0);
|
|
const topIndex=await stableFirstIndex(page);
|
|
await page.locator(`article[data-index="${topIndex}"] .image-overlays input`).focus();await page.keyboard.press('Shift+Tab');
|
|
await expect(page.locator(`article[data-index="${topIndex-1}"] .card-footer .row-actions > button`).last()).toBeFocused();
|
|
// Leaving for a prompt's history and coming back restores the shelf without a read; its epoch still fences.
|
|
await page.locator(`article[data-index="${topIndex}"]`).getByRole('button',{name:'Explore history',exact:true}).click();
|
|
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
|
await expect(cards(page).first()).toBeVisible();await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
|
const visible=Number(await cards(page).first().getAttribute('data-prompt-id'));
|
|
await box(page,visible).check();await setCategory(page,'Returned');
|
|
await expect(toast(page)).toHaveText('Category “Returned” set on 1 prompt.');
|
|
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
|
expect(writes).toEqual([{prompt_ids:[visible],category:'Returned',expected_epoch:epoch}]);
|
|
});
|
|
|
|
test('a restore the page did not see refuses the write, changes nothing and reloads the shelf',async({page,request})=>{
|
|
await armUnseenRestores(page);
|
|
const items=await shelf(request);
|
|
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
|
|
await box(page,items[0].id).check();
|
|
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
|
|
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
|
|
await expect(restore).toBeOK();
|
|
const reload=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&!url.searchParams.has('cursor');});
|
|
await setCategory(page,'After restore');
|
|
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No category was changed.');
|
|
await reload;await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
|
expect((await shelf(request)).filter(item=>item.category==='After restore')).toEqual([]);
|
|
// The reloaded shelf reads the new epoch: the next write goes through.
|
|
await box(page,items[0].id).check();await setCategory(page,'After reload');
|
|
await expect(toast(page)).toHaveText('Category “After reload” set on 1 prompt.');
|
|
});
|
|
|
|
test('an answer that may follow a committed write says so and reloads; a refused write keeps the selection',async({page,request})=>{
|
|
const items=await shelf(request);
|
|
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
|
|
await box(page,items[0].id).check();
|
|
await page.route('**/prompt_manager/bulk/category',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1});
|
|
await setCategory(page,'Refused');
|
|
await expect(toast(page)).toHaveText('Category could not be set. Try again.');
|
|
await expect(bar(page).locator('strong')).toHaveText('1 selected');
|
|
let sent=false;
|
|
await page.route('**/prompt_manager/bulk/category',async route=>{sent=true;await route.fetch();await route.abort('failed');},{times:1});
|
|
await setCategory(page,'Unconfirmed');
|
|
await expect(toast(page)).toHaveText('The category change could not be confirmed. The Library was reloaded.');
|
|
expect(sent).toBe(true);
|
|
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
|
await expect(card(page,items[0].id).locator('.card-title .chip')).toHaveText('Unconfirmed');
|
|
});
|
|
|
|
// #442: Add tags writes prompt tags (never image tags) on the selected prompts.
|
|
function recordTagWrites(page) {
|
|
const writes=[];
|
|
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/tags')writes.push(item.postDataJSON());});
|
|
return writes;
|
|
}
|
|
async function promptTags(request,id) {
|
|
return (await shelf(request)).find(item=>item.id===id).tags;
|
|
}
|
|
|
|
test('Add tags adds prompt tags to the selected prompts, suggests the vocabulary and reloads the shelf',async({page,request})=>{
|
|
const items=await shelf(request);const [first,second,third]=items;const writes=recordTagWrites(page);
|
|
const vocabulary=(await(await request.get('/prompt_manager/tags')).json()).tags;
|
|
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
|
|
await box(page,first.id).check();await box(page,second.id).check();
|
|
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
|
|
await expect(tagsDialog(page).locator('datalist option')).toHaveCount(vocabulary.length);
|
|
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(tagsDialog(page)).toHaveCount(0);
|
|
await addTags(page,' studio-lit , Night ,studio-lit');
|
|
await expect(toast(page)).toHaveText('Added “studio-lit, Night” to 2 prompts.');
|
|
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
|
for(const item of [first,second]) {
|
|
await expect(chips(page,item.id).filter({hasText:'studio-lit'})).toHaveCount(1);
|
|
expect(await promptTags(request,item.id)).toEqual(expect.arrayContaining(['studio-lit','Night']));
|
|
}
|
|
expect(await promptTags(request,third.id)).not.toContain('studio-lit');
|
|
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
|
expect(writes).toEqual([{prompt_ids:[first.id,second.id],tags:['studio-lit','Night'],expected_epoch:epoch}]);
|
|
// A second add of the same names changes nothing and says so.
|
|
await box(page,first.id).check();await addTags(page,'Studio-Lit');
|
|
await expect(toast(page)).toHaveText('1 prompt already had “Studio-Lit”.');
|
|
});
|
|
|
|
test('the Add tags field states what it refuses, and a restore the page did not see adds nothing',async({page,request})=>{
|
|
await armUnseenRestores(page);
|
|
const items=await shelf(request);const writes=recordTagWrites(page);
|
|
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
|
|
await box(page,items[0].id).check();
|
|
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
|
|
const field=tagsDialog(page).locator('#action-input');
|
|
for(const [value,message] of [[' , ','Enter at least one tag.'],['x'.repeat(51),'A tag can have at most 50 characters.'],
|
|
[Array.from({length:21},(_,i)=>`t${i}`).join(','),'Enter at most 20 tags.']]) {
|
|
await field.fill(value);await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
|
|
await expect(tagsDialog(page)).toBeVisible();
|
|
expect(await field.evaluate(node=>node.validationMessage)).toBe(message);
|
|
}
|
|
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
|
expect(writes).toHaveLength(0);
|
|
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
|
|
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
|
|
await expect(restore).toBeOK();
|
|
await addTags(page,'after-restore');
|
|
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No tags were added.');
|
|
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
|
expect((await(await request.get('/prompt_manager/tags')).json()).tags).not.toContain('after-restore');
|
|
});
|
|
|
|
// #444 ruling (D151): select every loaded prompt, as the legacy page's select-all checkbox did for its page.
|
|
test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed ones included',async({page,request,isMobile})=>{
|
|
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
|
|
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
|
|
const writes=recordTagWrites(page);
|
|
await page.goto(route);await expect(cards(page).first()).toBeVisible();
|
|
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
|
for(let index=0;index<3;index++) {
|
|
await expect(more).toHaveAttribute('aria-disabled','false');
|
|
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
|
|
await more.evaluate(node=>node.click());await response;
|
|
}
|
|
await expect(more).toHaveAttribute('aria-disabled','false');
|
|
const loaded=160;expect(await cards(page).count()).toBeLessThan(loaded);
|
|
// In the search box Ctrl/Cmd+A keeps its text meaning: it selects the box's text, no prompt.
|
|
await page.locator('#search').focus();await page.keyboard.press('ControlOrMeta+a');
|
|
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
|
// One checked card offers the rest; the offer selects every loaded prompt and focus moves to the first action.
|
|
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
|
|
await box(page,firstId).check();
|
|
const offer=bar(page).getByRole('button',{name:`Select all ${loaded} loaded`,exact:true});
|
|
await expect(bar(page).locator(':scope > *').nth(1)).toHaveText(`Select all ${loaded} loaded`);
|
|
await offer.click();
|
|
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
|
|
await expect(offer).toHaveCount(0);
|
|
await expect(bar(page).getByRole('button',{name:'Add tags',exact:true})).toBeFocused();
|
|
// Another batch arrives unchecked: the offer comes back with the new N.
|
|
{
|
|
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
|
|
await more.evaluate(node=>node.click());await response;
|
|
}
|
|
await expect(bar(page).getByRole('button',{name:`Select all ${loaded+40} loaded`,exact:true})).toBeVisible();
|
|
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
|
|
// Escape in the grid clears; Ctrl/Cmd+A in the grid selects them all again.
|
|
const title=cards(page).first().locator('.card-title > button');
|
|
await title.focus();await page.keyboard.press('Escape');
|
|
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(title).toBeFocused();
|
|
await page.keyboard.press('ControlOrMeta+a');
|
|
const all=loaded+40;
|
|
await expect(bar(page).locator('strong')).toHaveText(`${all} selected`);
|
|
// While the write runs, every bar action and the offer are aria-disabled.
|
|
await box(page,firstId).uncheck();
|
|
let release;const held=new Promise(resolve=>{release=resolve;});
|
|
await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1});
|
|
await addTags(page,'everyone');
|
|
for(const name of ['Add tags','Set category','Delete…','Clear',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
|
|
release();
|
|
await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`);
|
|
expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1);
|
|
});
|