/* Real UI UTM regression. Disposable Docker fixtures a/b only. * Browser plugin not available. No route mocks or host-installed runtimes. * VALID_ZIP must contain set3; PUBLIC_A/B are optional fixture origins. */ const {chromium}=require('playwright-core'); const assert=require('node:assert/strict'); const fs=require('node:fs'); (async()=>{ const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']}); const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}}); const output=process.env.UI_OUTPUT||'/tmp'; const errors=[],consoleMessages=[]; page.on('pageerror',e=>errors.push(e.message)); page.on('console',m=>{if(['error','warning'].includes(m.type()))consoleMessages.push({type:m.type(),text:m.text()});}); page.on('dialog',dialog=>dialog.accept()); const read=async id=>{const r=await page.request.get('/api/config?target='+encodeURIComponent(id)).then(r=>r.json());assert.ok(r.ok,r.error);assert.ok(r.config,'config missing');return r;}; const poll=async id=>{for(let n=0;n<480;n++){const r=await page.request.get('/api/jobs?id='+id).then(r=>r.json());assert.ok(r.ok,r.error);assert.ok(r.job,'job missing: '+id);if(r.job.status==='completed')return r.job;await new Promise(resolve=>setTimeout(resolve,250));}throw Error('job timeout: '+id);}; const group=()=>page.locator('section.field-block').filter({has:page.getByRole('heading',{name:'indpic= UTM',exact:true})}); const open=async id=>{await page.locator('#source-target').selectOption(id);await page.getByLabel('indpic default',{exact:true}).waitFor();await page.waitForFunction(()=>document.querySelector('#read-state').textContent.includes('версия'));}; const chooseBoth=async()=>{await page.locator('#targets input[value="b"]').check();await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии'));}; const submit=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST');await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click();const accepted=await(await response).json();assert.ok(accepted.ok,accepted.error);assert.ok(accepted.job);const job=await poll(accepted.job.id);const rendered=page.locator('.job').filter({hasText:job.id});await rendered.getByText('Завершено',{exact:false}).first().waitFor();return {job,rendered};}; const setWeights=async weights=>{const section=group();const rows=section.locator('.weight-table tbody tr');while(await rows.count())await rows.last().getByRole('button',{name:'Удалить',exact:true}).click();for(const [variant,weight]of Object.entries(weights)){await section.getByRole('button',{name:'Добавить вариант',exact:true}).click();const row=rows.last();await row.getByLabel('indpic вариант',{exact:true}).selectOption(variant);await row.locator('input[type=number]').fill(String(weight));}}; let originals=[],primaryError=null; try{ await page.goto(process.env.ADMIN_URL||'http://admin'); await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin'); await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password'); await page.getByRole('button',{name:'Войти',exact:true}).click(); await page.locator('#workspace').waitFor({state:'visible'}); const state=await page.request.get('/api/state').then(r=>r.json());assert.ok(state.ok,state.error); for(const id of ['a','b'])assert.ok(state.targets.some(t=>t.id===id&&t.enabled),'enabled fixture '+id+' required'); originals=await Promise.all(['a','b'].map(read)); for(const original of originals){assert.ok(!Object.hasOwn(original.config.utm.indpic.weights,'3'),'original fixture must omit weights3 for rejection scenario');assert.notEqual(String(original.config.utm.indpic.default),'3');} await open('a');await chooseBoth(); // Delivery is a real two-target ZIP form submission, not an API shortcut. assert.ok(process.env.VALID_ZIP,'VALID_ZIP=/fixture/valid-set3.zip required'); await page.locator('#set-add input[name=number]').fill('3'); await page.locator('#set-add input[name=name]').fill('UI UTM — набор 3'); await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP); const uploadResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload')); await page.locator('#set-add button').click(); const upload=await(await uploadResponse).json();assert.ok(upload.ok,upload.error); const uploaded=await poll(upload.job.id);assert.deepEqual(uploaded.results.map(r=>r.status),['success','success']); await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor(); await page.getByRole('button',{name:'Готово',exact:true}).click(); // Wrong path must fail clearly, without altering either runtime config. await page.getByLabel('indpic default',{exact:true}).selectOption('3'); const rejected=await submit();assert.deepEqual(rejected.job.results.map(r=>r.status),['error','error']); for(const result of rejected.job.results)assert.match(result.message,/Добавьте вариант 3 в таблицу весов indpic.*вес 0/); await rejected.rendered.getByText(/Добавьте вариант 3 в таблицу весов indpic/).first().waitFor(); assert.deepEqual((await read('a')).config,originals[0].config);assert.deepEqual((await read('b')).config,originals[1].config); await page.screenshot({path:output+'/admin-utm-rejected-default.png',fullPage:true}); // Correct path: a zero-weight row and explicit default3, only these two fields. await group().getByRole('button',{name:'Добавить вариант',exact:true}).click(); const added=group().locator('.weight-table tbody tr').last(); await added.getByLabel('indpic вариант',{exact:true}).selectOption('3'); await added.locator('input[type=number]').fill('0'); // Rejected submission cleared explicit field selection; select default again. await page.getByLabel('indpic default',{exact:true}).selectOption('3'); assert.equal(await page.locator('#targets input:checked').count(),2); assert.match(await page.locator('#change-summary').innerText(),/utm.indpic.weights/); const applied=await submit();assert.deepEqual(applied.job.results.map(r=>r.status),['success','success']); const actuals=await Promise.all(['a','b'].map(read)); for(let i=0;i<2;i++){const expected=structuredClone(originals[i].config);expected.utm.indpic.default=typeof originals[0].config.utm.indpic.default==='number'?3:'3';expected.utm.indpic.weights={...originals[0].config.utm.indpic.weights,'3':0};assert.deepEqual(actuals[i].config,expected,'only selected indpic fields changed on '+['a','b'][i]);} fs.writeFileSync(output+'/admin-utm-before-restore.json',JSON.stringify({job:applied.job.id,configs:{a:actuals[0].config,b:actuals[1].config}},null,2)); await page.getByRole('button',{name:'Перечитать',exact:true}).click();await page.waitForFunction(()=>document.querySelector('[aria-label="indpic default"]')?.value==='3'); assert.equal(await group().locator('.weight-table tbody tr').filter({has:page.locator('option[value="3"]:checked')}).count(),1); await page.screenshot({path:output+'/admin-utm-applied.png',fullPage:true}); const publicEvidence=[]; for(const origin of [process.env.PUBLIC_A,process.env.PUBLIC_B].filter(Boolean)){ const response=await page.request.get(new URL('/?indpic=abcd3zzzz',origin).href);assert.equal(response.status(),200,'public HTML '+origin);const html=await response.text();assert.match(html,/data-source="[^\"]*\/3\/01_desk_1-source\.avif/,'public resolver selects uploaded set3'); const image=await page.request.get(new URL('/3/01_desk_1-source.jpg',origin).href);assert.equal(image.status(),200,'public uploaded JPEG '+origin);const bytes=await image.body();assert.equal(bytes[0],0xff);assert.equal(bytes[1],0xd8);publicEvidence.push({origin,htmlStatus:response.status(),imageStatus:image.status(),bytes:bytes.length}); } fs.writeFileSync(output+'/admin-utm-public.json',JSON.stringify(publicEvidence,null,2)); assert.deepEqual(errors,[]); }catch(error){primaryError=error;}finally{ const cleanupErrors=[]; // The restoration itself uses actual form row removal/addition/default controls. for(let i=0;i{console.error(error);process.exit(1);});