feat: единая админка runtime-конфигов и indpic (#22)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 11:33:56 +03:00
1 parent 5b6b2d2053
commit 9515c1c62b
52 files changed
+2459 -34

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
/* Real HTTP + rendered regression: stale version, independent partial result,
* invalid ZIP recovery, safe DOM. Run only against disposable Docker fixtures.
* No browser network mocking. Browser plugin not available. */
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 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',d=>d.accept());
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());
const api=async(path,data)=>{const response=await page.request.post('/api/'+path,{data,headers:{'X-CSRF-Token':state.csrf}});const result=await response.json();assert.equal(result.ok,true,result.error);return result;};
const read=async(id)=>page.request.get('/api/config?target='+encodeURIComponent(id)).then(r=>r.json());
const poll=async(id)=>{for(let n=0;n<120;n++){const r=await page.request.get('/api/jobs?id='+id).then(r=>r.json());assert.ok(r.ok,r.error||'GET jobs вернул ошибку без описания');assert.ok(r.job,'GET jobs не вернул задание '+id);if(r.job.status==='completed')return r.job;await new Promise(r=>setTimeout(r,250));}throw Error('job timeout '+id);};
const ids=state.targets.filter(t=>t.enabled).slice(0,2).map(t=>t.id);assert.equal(ids.length,2,'two disposable targets required');
const originals=await Promise.all(ids.map(read));
const externalHost='external-'+Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10)+'.example.org';
let primaryError=null;
const restore=async()=>{const targets=await Promise.all(ids.map(async id=>({id,version:(await read(id)).version})));for(let i=0;i<ids.length;i++){const r=await api('apply',{targets:[targets[i]],changes:[{path:['host'],value:originals[i].config.host}]});const job=await poll(r.job.id);assert.equal(job.results[0].status,'success','fixture host restored');}};
try{
await page.locator('#source-target').selectOption(ids[0]);
await page.getByLabel('Основной сайт',{exact:true}).waitFor();
await page.locator('#targets input[value="'+ids[1]+'"]').check();
await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии'));
const altered=await api('apply',{targets:[{id:ids[1],version:originals[1].version}],changes:[{path:['host'],value:externalHost}]});
assert.equal((await poll(altered.job.id)).results[0].status,'success');
await page.getByLabel('Основной сайт',{exact:true}).fill('partial-check.example.org');
const responsePromise=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST');
await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click();
const submitted=await (await responsePromise).json();
const result=await poll(submitted.job.id);
assert.deepEqual(result.results.map(r=>r.status),['success','conflict']);
const job=page.locator('.job').filter({hasText:submitted.job.id});
await job.getByText('Конфликт версии — перечитайте домен',{exact:true}).waitFor();
assert.equal((await read(ids[0])).config.host,'partial-check.example.org');
assert.equal((await read(ids[1])).config.host,externalHost,'stale UI did not overwrite external writer');
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-conflict-partial.png',fullPage:true});
await page.reload();
await page.locator('#workspace').waitFor({state:'visible'});
await page.locator('.job').filter({hasText:submitted.job.id}).waitFor();
await page.locator('#source-target').selectOption(ids[0]);
await page.getByLabel('Основной сайт',{exact:true}).waitFor();
await page.locator('#set-add input[name=number]').fill(process.env.ZIP_NUMBER||'9');
await page.locator('#set-add input[name=name]').fill('Тест ZIP');
await page.locator('#set-add input[type=file]').setInputFiles({name:'invalid.zip',mimeType:'application/zip',buffer:Buffer.from('not a ZIP')});
const uploadResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));
await page.locator('#set-add button').click();
const invalidResult=await (await uploadResponse).json();
if(invalidResult.ok){const invalidJob=await poll(invalidResult.job.id);assert.equal(invalidJob.results[0].status,'error');await page.locator('#indpic-result-modal').waitFor({state:'visible'});await page.getByRole('button',{name:'Готово',exact:true}).click();}
else await page.locator('#notice.alert-danger').waitFor({state:'visible'});
assert.equal((await read(ids[0])).config.host,'partial-check.example.org','invalid ZIP kept working config');
if(process.env.VALID_ZIP){
await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);
const response=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));
await page.locator('#set-add button').click();const accepted=await(await response).json();assert.equal(accepted.ok,true,accepted.error);
const done=await poll(accepted.job.id);assert.equal(done.results[0].status,'success',done.results[0].message);
await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor();
await page.getByRole('button',{name:'Готово',exact:true}).click();
}
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-zip-recovery.png',fullPage:true});
fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/admin-regression-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2));
assert.deepEqual(errors,[]);console.log('PASS real partial success/conflict/external state/persistent history/invalid ZIP'+(process.env.VALID_ZIP?'/valid ZIP recovery':''));
}catch(error){primaryError=error;}finally{
const cleanupErrors=[];
try{await restore();}catch(error){cleanupErrors.push(error);}
try{await browser.close();}catch(error){cleanupErrors.push(error);}
if(primaryError&&cleanupErrors.length)throw new AggregateError([primaryError,...cleanupErrors],'Основная ошибка browser-сценария; отдельно — ошибки восстановления fixture / закрытия browser',{cause:primaryError});
if(primaryError)throw primaryError;
if(cleanupErrors.length)throw new AggregateError(cleanupErrors,'Ошибка восстановления fixture / закрытия browser');
}
})().catch(e=>{console.error(e);process.exit(1);});