import test from 'node:test' import assert from 'node:assert/strict' import {readFileSync} from 'node:fs' import ts from 'typescript' import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs' import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs' import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs' import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs' import {cachedLoras} from '../shared/studio2/lora-cache.mjs' test('deleted library files never return from job outputs; selection falls back or clears',()=>{ const deleted={id:'gone',folderId:'f'},keep={id:'keep',folderId:'f',createdAt:2};const jobs=[{request:{folderId:'f'},outputs:[deleted]}] let assets=folderAssets({stills:[keep],clips:[]},jobs,'f');assert.deepEqual(assets.map(a=>a.id),['keep']);assert.equal(reconcileSelection(deleted,assets).id,'keep');assert.equal(reconcileSelection(deleted,[]),null) }) test('bench includes only active execution phases and clear finished retains internal cleanup records',()=>{ assert.deepEqual(activeJobs(['waiting','waking','submitting','rendering','saving','complete','cancelled','failed'].map(state=>({state}))).map(j=>j.state),['waking','submitting','rendering','saving']) const rows=[{id:'done',state:'complete',request:{folderId:'f'},cleanupPending:[{}]},{id:'active',state:'rendering',request:{folderId:'f'}}],saved=[] const src=readFileSync(new URL('../server/api/studio-2/clear-finished.post.ts',import.meta.url),'utf8').replace(/^import .*;\r?\n/gm,'').replace('export default ','const handler=') const js=ts.transpileModule(src,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText const fn=new Function('defineEventHandler','assertLibraryOwner','listStudioJobs','records','assertFolderAccess','saveRecord',js+';return handler')(f=>f,()=>({owner:'o'}),()=>[],()=>rows,()=>{},r=>saved.push(r)) assert.equal(fn({}).cleared,1);assert.equal(rows[0].dismissed,true);assert.equal(rows[0].cleanupPending.length,1);assert.equal(rows[1].dismissed,undefined) }) test('hidden xAIGen canvas and family render no image or video elements',async()=>{ const revealed=new Set() assert.equal(previewHidden({id:'a',role:'input'},revealed,true),true) assert.equal(previewHidden({id:'a',role:'input'},new Set(['a']),true),false) assert.equal(previewHidden({id:'a'},revealed,false),false) const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8') assert.match(src,/aria-label="Current family outputs"/) assert.match(src,/preview hidden/) assert.match(src,/Reveal all/) assert.match(src,/pickerRevealAll/) assert.doesNotMatch(src,/Hide input stills/) assert.doesNotMatch(src,/Hide output preview/) }) test('cached LoRAs survive sleeping GPU and failed explicit refresh',async()=>{ const cache={image:['Klein/portrait.safetensors'],video:['motion.safetensors']};let queried=0 const opts={read:()=>cache,write:()=>assert.fail('Do not erase cache'),live:async()=>{queried++;throw Error('Comfy asleep')}} assert.deepEqual(await cachedLoras(opts),cache);assert.equal(queried,1) assert.deepEqual(await cachedLoras({...opts,fresh:true}),cache);assert.equal(queried,2) }) test('bench load and Refresh always live-scan and replace disk so new Comfy names appear',async()=>{ let store={image:['old.safetensors'],video:['old-vid.safetensors']} const next=await cachedLoras({ read:()=>store, write:value=>{store=value}, live:async()=>({image:['old.safetensors','klein-pokemon_anime_style_klein9b.safetensors'],video:['old-vid.safetensors']}), fresh:false }) assert.ok(next.image.includes('klein-pokemon_anime_style_klein9b.safetensors')) assert.deepEqual(store.image,next.image) const removed=await cachedLoras({ read:()=>store, write:value=>{store=value}, live:async()=>({image:['klein-pokemon_anime_style_klein9b.safetensors'],video:[]}), fresh:true }) assert.deepEqual(removed,{image:['klein-pokemon_anime_style_klein9b.safetensors'],video:[]}) }) test('bench mounts and Refresh LoRAs request a live scan',()=>{ const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8') assert.match(src,/void loadLoras\(true\)/) assert.match(src,/loadLoras\(true\)/) assert.match(src,/\/api\/studio-2\/loras'\+\(fresh\?'\?fresh=1':''\)/) }) test('source/hero actions are independent and full input restores structured settings',()=>{ const form={identityStillId:'hero',imageAId:'source',promptSections:{action:'old'},settings:{}} assert.equal(useAsHero(form,{id:'new'}).imageAId,'source');assert.equal(useAsSource(form,{id:'new'}).identityStillId,'hero') const saved={mode:'edit',identityStillId:'h',imageAId:'s',imageStyles:{positive:['Comic'],negative:['Skin pores']},promptSections:{action:'New',cameraBeats:[{moves:['hold']}]},settings:{steps:24,loraStack:[{name:'example.safetensors',strengthModel:.4,strengthClip:.6}]}} const restored=restoreAll(form,{id:'result',studio2:saved});assert.equal(restored.imageAId,'result');assert.equal(restored.identityStillId,'h');assert.deepEqual(restored.settings.loraStack,saved.settings.loraStack);assert.deepEqual(restored.imageStyles,saved.imageStyles);assert.equal(restored.promptSections.action,'New');const clip=restoreAll(form,{id:'clip',kind:'clip',studio2:{...saved,mode:'video',engine:'minimax'}});assert.equal(clip.startClipId,'clip');assert.equal(clip.mode,'extend');assert.equal(clip.promptSections.action,'New') }) test('image chips compile positive and negative independently; video chips do not leak',()=>{ let styles=toggleStyle({},'Comic');styles=toggleStyle(styles,'Skin pores',true);styles=toggleStyle(styles,'Comic',true);assert.equal(stylePrompt(styles),'');assert.match(stylePrompt(styles,true),/Comic/) styles=toggleStyle(styles,'Photorealistic');const q=validateRequest({mode:'edit',engine:'flux',folderId:'f',imageAId:'s',imageStyles:styles,promptSections:{action:'Restaurant',cameraBeats:[{moves:['hold']}],locks:'VIDEO ONLY'}}) assert.match(q.compiledPrompt,/Photorealistic/);assert.doesNotMatch(q.compiledPrompt,/Comic|pores|tripod|hold|VIDEO ONLY/i);assert.deepEqual(q.imageStyles,styles) assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/) assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third']) }) test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{ const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}}) assert.equal(q.engine,'qwen21');assert.equal(q.settings.steps,25);assert.equal(q.settings.cfg,1) assert.equal(q.settings.width,1024);assert.equal(q.settings.height,1024) assert.equal(q.lockFace,false);assert.equal(q.lockOutfit,false);assert.deepEqual(q.settings.loraStack,[]) const edit=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'change the sky'},settings:{aspect:'auto'}}) assert.equal(edit.mode,'edit');assert.equal(edit.settings.steps,25);assert.equal(edit.settings.cfg,1) assert.equal(edit.lockFace,false);assert.deepEqual(edit.settings.loraStack,[]) assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',promptSections:{action:'x'}}),/source still/) assert.throws(()=>validateRequest({mode:'compose',engine:'qwen21',folderId:'f',imageAId:'s',imageBId:'b',promptSections:{action:'x'}}),/Generate and Edit/) assert.throws(()=>validateRequest({mode:'iterate',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/Generate and Edit/) }) test('hydrateStudio2Job restores LoRA strengths, locked seed, and clears missing stills',()=>{ const form={mode:'generate',engine:'flux',identityStillId:'',imageAId:'',promptSections:{action:''},imageStyles:{positive:[],negative:[]},settings:{aspect:'auto',seed:null,seedMode:'random',loraStack:[]},guides:[]} const saved={mode:'edit',engine:'flux',identityStillId:'hero1',imageAId:'gone',lockFace:true,lockOutfit:false,promptSections:{action:'pokemon trainer'},imageStyles:{positive:[],negative:['blurry','custom bad']},settings:{aspect:'16:9',seed:42,steps:24,cfg:4,loraStack:[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}]}} const {form:next,missing}=hydrateStudio2Job(form,saved,{stillIds:new Set(['hero1']),clipIds:new Set()}) assert.equal(next.mode,'edit');assert.equal(next.identityStillId,'hero1');assert.equal(next.imageAId,'') assert.equal(next.settings.seed,42);assert.equal(next.settings.seedMode,'locked');assert.equal(next.settings.aspect,'16:9') assert.deepEqual(next.settings.loraStack,[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}]) assert.deepEqual(next.imageStyles.negative,['blurry','custom bad']) assert.match(missing,/start missing/) }) test('custom negative chips survive normalizeStyles and bench exposes Load actions',()=>{ let styles={positive:[],negative:['blurry','my watermark']} styles=toggleStyle(styles,'deformed',true) assert.deepEqual(styles.negative,['blurry','my watermark','deformed']) const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8') assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Edit source/) assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/) })