Files
preland/admin/tests/ui-utm.cjs
T
2026-10-25 11:33:56 +03:00

86 lines
9.4 KiB
JavaScript

/* 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<originals.length;i++)try{await open(['a','b'][i]);await setWeights(originals[i].config.utm.indpic.weights);await page.getByLabel('indpic default',{exact:true}).selectOption(String(originals[i].config.utm.indpic.default));const restored=await submit();assert.equal(restored.job.results[0].status,'success',restored.job.results[0].message);assert.deepEqual((await read(['a','b'][i])).config,originals[i].config,'UI restored full original config');}catch(error){cleanupErrors.push(error);}
fs.writeFileSync(output+'/admin-utm-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2));
try{await browser.close();}catch(error){cleanupErrors.push(error);}
if(primaryError&&cleanupErrors.length)throw new AggregateError([primaryError,...cleanupErrors],'UTM scenario failed; separate UI restore errors',{cause:primaryError});
if(primaryError)throw primaryError;if(cleanupErrors.length)throw new AggregateError(cleanupErrors,'UI restore failed');
}
console.log('PASS real UI bulk ZIP3 / rejected default without weight / weights3=0+default3 to a+b / reread / preserve other fields / public origins / UI restore');
})().catch(error=>{console.error(error);process.exit(1);});