feat(admin): edit domain settings inline and copy saved configurations
deploy-preland / deploy zoomstage.online (push) Successful in 57s

This commit is contained in:
Panfilov Konstantin committed 2026-10-26 03:06:11 +03:00
1 parent 84f70b1c71
commit dd6d221dd7
19 files changed
+402 -409

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,Store,Transport,Jobs,Copy,Problem,IndpicSetStorage};
function copyCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
$root=sys_get_temp_dir().'/preland-copy-'.bin2hex(random_bytes(6));mkdir($root,0700,true);
$store=new Store($root.'/state');$paths=[];
foreach(['source','a','b','c'] as $id){$paths[$id]=$root.'/'.$id;mkdir($paths[$id],0700);}
putenv('ADMIN_LOCAL_ROOTS='.implode(',',$paths));
foreach($paths as $id=>$path){$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$path,'enabled'=>true]);$store->verifyTarget($id);}
function fixture(string $id,string $json): void {global $paths;file_put_contents($paths[$id].'/config.json',$json);}
function readCopy(string $id): array {global $store;return (new Transport($store,$store->target($id)))->readConfig();}
function selection(string $id): object {return (object)['id'=>$id,'version'=>readCopy($id)['version']];}
function copyBody(array $ids=['a']): array {return ['source'=>selection('source'),'targets'=>array_map('selection',$ids)];}
$source='{"host":"saved.example","requisites":["source"],"extra":{"source":true},"utm":{"registration":{"default":"after","weights":{"before":3,"after":1},"source_only":true},"theme":{"default":2,"weights":{"1":0,"2":7}},"bchoice":{"default":"a","weights":{"b":2,"a":1}},"focus":{"default":"face","weights":{"face":0,"center":0}},"unknown":{"raw":"source"}}}';
$target='{"host":"old.example","requisites":["target"],"extra":{"empty":{},"array":[],"flag":false,"zero":0},"utm":{"reg":{"default":1,"weights":{"1":1},"target_only":{"keep":true}},"theme":{"default":"dark","weights":{"dark":1}},"chob":{"default":"b","weights":{"b":1}},"indpic":{"default":"9","weights":{"9":0}},"unknown":{"raw":"target"}}}';
// Missing source indpic must preserve it; use a valid recipient without indpic for initial success.
$target=str_replace(',"indpic":{"default":"9","weights":{"9":0}}','',$target);
fixture('source',$source);foreach(['a','b','c'] as $id)fixture($id,$target);
$port=random_int(19000,29000);$env=getenv();$env['ADMIN_STATE_DIR']=$store->dir;$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='copy-admin';$env['ADMIN_PASSWORD_HASH']=password_hash('copy-fixture-password',PASSWORD_DEFAULT);
$log=fopen($root.'/http.log','a');$server=proc_open([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);$cookie='';
function copyRequest(array $body,?string $csrf=null,string $path='/api/copy'): array {global $port,$cookie;$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;$ctx=stream_context_create(['http'=>['method'=>'POST','header'=>implode("\r\n",$headers),'content'=>json_encode($body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);preg_match('~HTTP/\S+ (\d+)~',$http_response_header[0]??'',$match);foreach($http_response_header as $header)if(str_starts_with(strtolower($header),'set-cookie: '))$cookie=explode(';',substr($header,12))[0];return [(int)($match[1]??0),json_decode((string)$raw,true,64,JSON_THROW_ON_ERROR)];}
try{
$deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(20000);}while(microtime(true)<$deadline);
[$status]=copyRequest(copyBody());copyCheck($status===401,'copy requires authentication');
[$status,$body]=copyRequest(['username'=>'copy-admin','password'=>'copy-fixture-password'],null,'/api/login');$csrf=$body['csrf'];
[$status]=copyRequest(copyBody());copyCheck($status===403,'copy requires CSRF');
[$status,$body]=copyRequest(copyBody(['a','b']),$csrf);copyCheck($status===202&&isset($body['job']['id']),'HTTP copy queues server-owned saved snapshot');$job=$body['job'];$jobs=new Jobs($store);
fixture('source',str_replace('saved.example','changed.example',$source));
$staleTarget=readCopy('b');fixture('b',str_replace('old.example','foreign.example',$target));
$jobs->run($job['id']);$done=$jobs->get($job['id']);copyCheck($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='conflict','copy isolates success and target CAS conflict');
$a=readCopy('a')['config'];copyCheck($a->host==='saved.example'&&readCopy('b')['config']->host==='foreign.example','queued source snapshot is immutable and conflict target untouched');
copyCheck($a->utm->reg->default==='2'&&$a->utm->reg->weights->{'1'}===3&&$a->utm->reg->weights->{'2'}===1&&$a->utm->reg->target_only->keep===true,'source semantic registration projects to target numeric reg and preserves target group metadata');
copyCheck($a->utm->theme->default==='light'&&$a->utm->theme->weights->dark===0&&$a->utm->theme->weights->light===7&&$a->utm->chob->default==='a'&&!property_exists($a->utm,'bchoice'),'target semantic schema and alias preserved');
copyCheck($a->utm->focus->default==='1'&&$a->utm->focus->weights->{'1'}===0&&$a->utm->focus->weights->{'2'}===0,'missing recipient group added canonically and all-zero weights retained');
copyCheck($a->requisites===['target']&&$a->extra->empty instanceof stdClass&&$a->extra->array===[]&&$a->extra->flag===false&&$a->extra->zero===0&&$a->utm->unknown->raw==='target'&&!property_exists($a->utm->reg,'source_only'),'noncopy fields and JSON types preserved, source unknown fields excluded');
copyCheck(count($store->backups('a'))===1&&$store->backupContent('a',$store->backups('a')[0]['id'])===$target&&$store->backups('b')===[],'successful target gets exact backup and conflict gets none');
$old=copyBody();fixture('source',str_replace('changed.example','newer.example',file_get_contents($paths['source'].'/config.json')));$before=count(glob($store->dir.'/jobs/*.json'));[$status]=copyRequest($old,$csrf);copyCheck($status===409&&count(glob($store->dir.'/jobs/*.json'))===$before,'source version conflict returns HTTP 409 before enqueue');
$valid=copyBody();$invalid=[[],array_merge($valid,['config'=>(object)[]]),array_replace($valid,['source'=>['source']]),array_replace($valid,['targets'=>(object)['a'=>selection('a')]]),array_replace($valid,['targets'=>[]]),array_replace($valid,['targets'=>[selection('a'),selection('a')]]),array_replace($valid,['targets'=>[selection('source')]]),array_replace($valid,['targets'=>array_fill(0,101,selection('a'))])];
foreach(['id'=>1,'version'=>123,'extra'=>true] as $key=>$value){$bad=clone $valid['source'];$bad->$key=$value;$invalid[]=array_replace($valid,['source'=>$bad]);$bad=clone $valid['targets'][0];$bad->$key=$value;$invalid[]=array_replace($valid,['targets'=>[$bad]]);}
foreach(['','abc',str_repeat('G',64)] as $version){$bad=clone $valid['source'];$bad->version=$version;$invalid[]=array_replace($valid,['source'=>$bad]);}
foreach($invalid as $i=>$request){[$status]=copyRequest($request,$csrf);copyCheck($status===422,'strict copy JSON rejects invalid shape/types/unknown keys/duplicates '.$i);}
foreach(['source','a'] as $id)foreach(['enabled','verified'] as $field){$registry=$store->read('targets.json');$registry[$id][$field]=false;$store->write('targets.json',$registry);[$status]=copyRequest(copyBody(),$csrf);copyCheck($status===422,'copy rejects '.$field.' false for '.$id);$registry[$id][$field]=true;$store->write('targets.json',$registry);}
foreach(['{"utm":{"chob":{"default":"a","weights":{"a":1}},"bchoice":{"default":"a","weights":{"a":1}}}}','{"utm":{"reg":null}}','{"utm":{"theme":{"default":"dark","weights":{"dark":1,"1":2}}}}','{"utm":{"focus":{"default":true,"weights":{"1":1}}}}','{"host":null}','{"utm":[]}'] as $json){fixture('source',$json);[$status]=copyRequest(copyBody(),$csrf);copyCheck($status===422,'source invalid/ambiguous canonical structure rejected before job');}
fixture('source','{"utm":{"reg":{"default":"before","weights":{"before":1}}}}');fixture('c','{"host":"keep.example","utm":{"focus":{"default":2,"weights":{"2":0},"meta":[]}},"x":{}}');
[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);$c=readCopy('c')['config'];copyCheck($c->host==='keep.example'&&$c->utm->focus->default===2&&$c->utm->focus->meta===[]&&$c->x instanceof stdClass,'missing source host/groups leave recipient values and types unchanged');
fixture('source','{"utm":{"indpic":{"default":"1","weights":{"1":1}}}}');fixture('c','{"utm":{}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobId=$body['job']['id'];$before=readCopy('c')['content'];$backupCount=count($store->backups('c'));$jobs->run($jobId);copyCheck($jobs->get($jobId)['results'][0]['status']==='error'&&readCopy('c')['content']===$before&&count($store->backups('c'))===$backupCount,'copy validates physical indpic on recipient and fails before write/backup');
mkdir($paths['c'].'/1',0700);foreach(IndpicSetStorage::expectedDimensions() as $name=>$size){$image=new Imagick();$image->newImage($size['width'],$size['height'],'#777777');$image->setImageFormat(pathinfo($name,PATHINFO_EXTENSION));file_put_contents($paths['c'].'/1/'.$name,$image->getImagesBlob());$image->clear();}
$jobs->retry($jobId,['c']);$jobs->run($jobId);copyCheck($jobs->get($jobId)['results'][0]['status']==='success','failed copy retries same snapshot after missing physical set is repaired');
fixture('source','{"host":"both.example","utm":{"tp":{"default":"2","weights":{"2":3}},"cons":{"default":1,"weights":{"1":0}},"chb":null}}');
[$status,$body]=copyRequest(copyBody(['a','c']),$csrf);$both=$body['job']['id'];$cBefore=readCopy('c');$jobs->run($both);$done=$jobs->get($both);$c=readCopy('c');
copyCheck($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='success'&&readCopy('a')['config']->utm->tp->weights->{'2'}===3&&$c['config']->utm->cons->default===1,'one saved snapshot copies to two successful recipients including tp/cons');
copyCheck($c['config']->utm->indpic->default==='1'&&$c['config']->utm->indpic->weights->{'1'}===1&&!property_exists($c['config']->utm,'chb')&&is_dir($paths['c'].'/1'),'absent source indpic preserves valid recipient group/set; source chb is excluded');
$backupCount=count($store->backups('c'));$record=$store->read('jobs/'.$both.'.json');$record['status']='running';$record['results'][1]=['target'=>'c','status'=>'running','desired_hash'=>$c['version']];$store->write('jobs/'.$both.'.json',$record);$jobs->run($both);
copyCheck($jobs->get($both)['results'][1]['status']==='success'&&readCopy('c')['content']===$c['content']&&count($store->backups('c'))===$backupCount,'copy desired-hash recovery confirms already-written config without second write or backup');
$rejected=false;try{$jobs->retry($both,['a','c']);}catch(Problem $e){$rejected=$e->status===422;}
copyCheck($rejected&&count($store->backups('c'))===$backupCount,'successful copy recipients cannot be retried and do not acquire duplicate backups');
fixture('source','{"host":"catalog.example","utm":{"degr":{"default":"2","weights":{"2":1}}}}');fixture('c','{"host":"old.example","utm":{}}');$store->write('catalog.json',['host'=>'catalog.example','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[],'cons'=>[]]]);[$status,$body]=copyRequest(copyBody(['c']),$csrf);$before=readCopy('c')['content'];$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error'&&readCopy('c')['content']===$before,'copy validates target result against fresh catalog for copied host');
fixture('source','{"host":"safe.example"}');fixture('c','{"utm":{"chb":null}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error'&&property_exists(readCopy('c')['config']->utm,'chb'),'copy preserves obsolete chb write guard rather than silently deleting it');
fixture('c','{"utm":{"chob":{"default":"a","weights":{"a":1}},"bchoice":{"default":"b","weights":{"b":1}}}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error','ambiguous target aliases rejected per domain even if source group absent');
fixture('c','{"host":"safe.example"}');$backup=$store->backup('c','{"utm":{"chb":null}}');$restore=$jobs->create('restore',['target'=>'c','version'=>readCopy('c')['version'],'id'=>$backup]);$jobs->run($restore['id']);copyCheck($jobs->get($restore['id'])['results'][0]['status']==='error'&&readCopy('c')['config']->host==='safe.example','restore retains chb publication guard after copy integration');
echo "Copy HTTP/jobs contract complete\n";
}finally{proc_terminate($server);proc_close($server);fclose($log);}
+4 -4
View File
@@ -28,11 +28,11 @@ const fs=require('node:fs');
const nextReg=selectionA.selection.reg==='rnd'?'1':'rnd';await page.locator('#builder-fields select[data-marker=reg]').selectOption(nextReg);
assert.equal(await page.locator('#builder-result').isVisible(),false,'old saved URL hidden for unsaved constructor changes');
const updated=await generate();const saved=await page.request.get('/api/link-builder?target=a').then(r=>r.json());assert.equal(saved.selection.reg,nextReg);for(const marker of Object.keys(selectionA.selection))if(marker!=='reg')assert.equal(saved.selection[marker],selectionA.selection[marker]);assert.notEqual(updated,first);
await page.locator('#source-target').selectOption('a');await page.getByLabel('Основной сайт',{exact:true}).waitFor();await page.getByLabel('Основной сайт',{exact:true}).fill('draft-only.example.org');
const version=await page.locator('#read-state').innerText();
await page.getByRole('button',{name:'Обновить сводку',exact:true}).click();
await page.locator('#source-target').selectOption('a');await page.getByLabel('Основной сайт a',{exact:true}).waitFor();await page.getByLabel('Основной сайт a',{exact:true}).fill('draft-only.example.org');
await page.locator('#read-state').getByText('Домен прочитан.',{exact:true}).waitFor();const version=await page.locator('#read-state').innerText();
await page.getByRole('button',{name:'Обновить настройки',exact:true}).click();
await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);
assert.equal(await page.getByLabel('Основной сайт',{exact:true}).inputValue(),'draft-only.example.org');assert.equal(await page.locator('#read-state').innerText(),version);assert.match(await page.locator('#change-summary').innerText(),/host/);
assert.equal(await page.getByLabel('Основной сайт a',{exact:true}).inputValue(),'draft-only.example.org');assert.equal(await page.locator('#read-state').innerText(),version);assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/Есть изменения/);
assert.equal((await read('a')).config.host,before[0].config.host,'overview/constructor did not save editor draft');
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/overview-builder-desktop.png',fullPage:true});
await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await page.locator('#builder-target').selectOption('a');await page.locator('#builder-url').waitFor({state:'visible'});assert.equal(await page.locator('#builder-url').inputValue(),updated);assert.equal(await page.locator('#builder-fields select[data-marker=theme]').inputValue(),'rnd');assert.equal(await page.locator('#builder-fields select[data-marker=reg]').inputValue(),nextReg);
+38
View File
@@ -0,0 +1,38 @@
/* preland#25: real UI, actual CAS/jobs and isolated fixture. Browser plugin not available. */
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');const fs=require('node:fs');
(async()=>{
const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',original=new Map(['a','b','c','d'].map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});const context=await browser.newContext({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());page.setDefaultTimeout(15000);let csrf='';
const host=id=>page.locator('[data-matrix-target="'+id+'"] input[data-host]'),cell=(id,m)=>page.locator('[data-matrix-target="'+id+'"][data-marker="'+m+'"]'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}),cancel=id=>page.getByRole('button',{name:'Отменить настройки '+id,exact:true}),header=id=>page.locator('[data-overview-target="'+id+'"]');
const request=async(route,data)=>{const response=data===undefined?await page.request.get('/api/'+route):await page.request.post('/api/'+route,{data,headers:{'X-CSRF-Token':csrf}});const result=await response.json();assert.ok(result.ok,result.error);return result;};
const get=id=>request('config?target='+id);const readFile=id=>JSON.parse(fs.readFileSync(root+'/'+id+'/config.json','utf8'));const writeFile=(id,c)=>fs.writeFileSync(root+'/'+id+'/config.json',JSON.stringify(c));
const saved=async id=>{await page.waitForFunction(id=>document.querySelector('[data-overview-target="'+id+'"] .matrix-status').textContent==='Сохранено',id,{timeout:60000});};
const refresh=async()=>{await page.locator('#overview-refresh').click();await page.waitForFunction(()=>!document.querySelector('#overview-refresh').disabled);};
try{
const d=readFile('d');delete d.utm.cons;writeFile('d',d);
await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await saved(id);csrf=(await request('state')).csrf;
assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('#overview [class*=status]').filter({hasText:/версия|МСК/}).count(),0);assert.equal(await page.locator('#overview').getByText(/requisites|chb/).count(),0);
const weights=cell('a','theme').locator('input[type=number]');await weights.nth(0).fill('3');await weights.nth(1).fill('1');assert.deepEqual(await cell('a','theme').locator('.matrix-percent').allTextContents(),['75%','25%']);await weights.nth(0).fill('0');await weights.nth(1).fill('0');assert.deepEqual(await cell('a','theme').locator('.matrix-percent').allTextContents(),['0%','0%']);await cancel('a').click();assert.deepEqual(await cell('a','theme').locator('.matrix-percent').allTextContents(),['25%','75%']);
await cell('a','degr').getByRole('button',{name:'+ Вариант',exact:true}).click();assert.equal(await cell('a','degr').locator('.matrix-variant').count(),3);await cell('a','degr').getByRole('button',{name:'Удалить вариант degr 3 a',exact:true}).click();assert.equal(await cell('a','degr').locator('.matrix-variant').count(),2);await cancel('a').click();
const radio=cell('a','theme').locator('input[type=radio]:checked');const def=await radio.getAttribute('aria-label');await cell('a','theme').getByRole('button',{name:'Удалить вариант theme '+def.split(' ')[2]+' a',exact:true}).click();assert.equal(await cell('a','theme').locator('input[type=radio]:checked').count(),0);assert.match(await header('a').innerText(),/выберите default/);assert.equal(await save('a').isDisabled(),true);await cell('a','theme').locator('input[type=radio]').check();assert.equal(await save('a').isEnabled(),true);await cancel('a').click();
await cell('d','cons').getByRole('button',{name:'+ Добавить группу',exact:true}).click();assert.equal(await cell('d','cons').locator('input[type=radio]:checked').count(),1);assert.equal(readFile('d').utm.cons,undefined);await save('d').click();await saved('d');assert.deepEqual(readFile('d').utm.cons,{default:'1',weights:{'1':1}});
console.log('PASS edit/default/missing'); // Two independent drafts and note cursor remain through foreign-column save and read.
await host('a').fill('draft-a.invalid');const handle=await host('a').elementHandle();await host('a').evaluate(n=>{n.focus();n.setSelectionRange(2,5);});await cell('b','theme').locator('input[type=number]').first().fill('7');await save('b').click();await saved('b');assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);assert.equal(readFile('b').utm.theme.weights.dark,7);assert.equal(readFile('b').utm.theme.default,'light');assert.deepEqual(readFile('b').custom,JSON.parse(original.get('b')).custom);assert.deepEqual(readFile('b').requisites,JSON.parse(original.get('b')).requisites);await refresh();assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);await cancel('a').click();
console.log('PASS foreign save'); // A late force GET must not erase input typed after reload starts.
let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);let held=false;const delayed=async route=>{if(!held){held=true;const response=await route.fetch();started();await gate;await route.fulfill({response});}else await route.continue();};await page.route('**/api/config?target=a',delayed);await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click({noWaitAfter:true});await seen;await host('a').fill('after-get.invalid');release();await page.waitForTimeout(200);assert.equal(await host('a').inputValue(),'after-get.invalid');await page.unroute('**/api/config?target=a',delayed);await cancel('a').click();
console.log('PASS late get'); // Real remote version conflict, explicit reload and offline recovery preserve drafts.
await host('a').fill('my-draft.invalid');const external=readFile('a');external.custom.external='concurrent';writeFile('a',external);await save('a').click();await header('a').getByText(/измен|верс|конфликт/i).waitFor();assert.equal(await host('a').inputValue(),'my-draft.invalid');await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');assert.equal(await host('a').inputValue(),external.host);
await cell('b','theme').locator('input[type=number]').first().fill('5');await context.setOffline(true);await save('b').click();await header('b').getByText(/Связь/).waitFor();assert.equal(await cell('b','theme').locator('input[type=number]').first().inputValue(),'5');await context.setOffline(false);await save('b').click();await saved('b');assert.equal(readFile('b').utm.theme.weights.dark,5);
console.log('PASS conflict/offline'); // Source and recipient drafts block copying. Copy selected recipients only, semantic aliases preserved.
await page.locator('#copy-source').selectOption('a');assert.equal(await page.getByLabel('Копировать в a.invalid',{exact:true}).count(),0);await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.getByLabel('Копировать в c.invalid',{exact:true}).check();await host('a').fill('source-draft.invalid');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/источника/);await cancel('a').click();await host('b').fill('target-draft.invalid');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/получателей/);await cancel('b').click();
const beforeD=fs.readFileSync(root+'/d/config.json','utf8'),beforeB=readFile('b'),source=(await get('a')).config;await page.locator('#copy-submit').click();assert.equal(await page.locator('#copy-all').isDisabled(),true);assert.equal(await page.locator('#copy-none').isDisabled(),true);await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});assert.equal(await page.locator('#copy-results .status-success').count(),2);await saved('b');await saved('c');const copiedB=readFile('b');assert.deepEqual(copiedB.utm.theme.weights,{dark:source.utm.theme.weights['1'],light:source.utm.theme.weights['2']});assert.deepEqual(copiedB.requisites,beforeB.requisites);assert.deepEqual(copiedB.custom,beforeB.custom);assert.equal(fs.readFileSync(root+'/d/config.json','utf8'),beforeD);
// Real target CAS partial failure and source CAS preflight failure.
const staleC=readFile('c');staleC.custom.concurrent='target';writeFile('c',staleC);const partialResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();const partial=(await(await partialResponse).json()).job;await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});const partialJob=(await request('jobs?id='+partial.id)).job;assert.deepEqual(partialJob.results.map(r=>r.status),['success','conflict']);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.equal(await page.locator('#copy-results .status-conflict').count(),1);assert.equal(readFile('c').custom.concurrent,'target');await header('c').getByRole('button',{name:'Обновить настройки c',exact:true}).click();await saved('c');const staleSource=readFile('a');staleSource.custom.concurrent='source';writeFile('a',staleSource);const sourceResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();assert.equal((await sourceResponse).status(),409);await page.locator('#copy-results .overview-error').waitFor();await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');console.log('PASS actual partial copy and source version conflict'); await page.locator('#copy-none').click();assert.equal(await page.locator('#copy-targets input:checked').count(),0);await page.locator('#copy-all').click();assert.equal(await page.locator('#copy-targets input:checked').count(),3);await page.locator('#copy-none').click();
console.log('PASS copy'); // Operations keep their own selection independent from copy UI.
await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#targets input[value=b]').check();await page.locator('#copy-source').selectOption('c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['a','b']);await page.locator('#source-target').selectOption('c');assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['c']);await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();
const unchangedBeforeZip=(await get('a')).version;await page.locator('#set-add input[name=number]').fill('9');await page.locator('#set-add input[name=name]').fill('Invalid ZIP #25');await page.locator('#set-add input[type=file]').setInputFiles({name:'invalid.zip',mimeType:'application/zip',buffer:Buffer.from('not ZIP')});const zipResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));await page.locator('#set-add button').click();const invalidZip=await(await zipResponse).json();if(invalidZip.ok){await page.locator('#indpic-result-modal').waitFor({state:'visible'});await page.locator('#result-body').getByText(/Ошибка/).waitFor({timeout:60000});await page.getByRole('button',{name:'Готово',exact:true}).click();}else await page.locator('#notice.alert-danger').waitFor();assert.equal((await get('a')).version,unchangedBeforeZip);console.log('PASS actual invalid ZIP preserves runtime'); await page.locator('#builder-target').selectOption('a');await page.locator('#builder-fields select').first().waitFor();await page.locator('#builder-save').click();await page.locator('#builder-result').waitFor({state:'visible'});assert.match(await page.locator('#builder-url').inputValue(),/^https:\/\/a.invalid/);
// Notes preserve the same textarea node, caret and draft during matrix refresh.
const note=page.locator('[data-utm-note=reg]');await note.getByRole('button',{name:/(Добавить|Редактировать) заметку reg/}).click();const textarea=note.getByLabel('Заметка reg',{exact:true});await textarea.fill('Заметка #25\nПроверка курсора');const noteHandle=await textarea.elementHandle();await textarea.evaluate(n=>{n.focus();n.setSelectionRange(3,7);document.querySelector('#overview-refresh').click();});await page.waitForFunction(()=>!document.querySelector('#overview-refresh').disabled);assert.deepEqual(await noteHandle.evaluate(n=>({connected:n.isConnected,focus:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focus:true,start:3,end:7});await note.getByRole('button',{name:'Отменить заметку reg',exact:true}).click();
await page.locator('#overview').screenshot({path:out+'/matrix-desktop.png'});await page.locator('#copy-panel').screenshot({path:out+'/copy-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/matrix-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.locator('#copy-panel').screenshot({path:out+'/copy-mobile.png'});assert.deepEqual(errors,[]);console.log('PASS inline weights/default/add/remove/missing/save/cancel; multi drafts/late GET/CAS/offline; saved copy selected-only/aliases/unknown preservation; independent operations/builder/notes/mobile/no JS errors');
}finally{await context.setOffline(false);await browser.close();for(const [id,content]of original)fs.writeFileSync(root+'/'+id+'/config.json',content);}
})().catch(e=>{console.error(e);process.exit(1);});
+2 -3
View File
@@ -23,13 +23,12 @@ const {chromium}=require('playwright-core');const assert=require('node:assert/st
const save=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();const body=await(await response).json();assert.ok(body.ok,body.error);await textarea().waitFor({state:'hidden'});return body.note;};
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'});
await page.locator('#overview-table tr[data-overview-utm=reg] td .overview-percent').first().waitFor({timeout:6000});
await page.locator('#overview-table tr[data-overview-utm=reg] td .matrix-percent').first().waitFor({timeout:6000});
for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const header=page.locator('tr[data-utm-note='+marker+']'),data=page.locator('tr[data-overview-utm='+marker+']');assert.equal(await header.locator('th').innerText(),marker);assert.equal(await data.locator('th').innerText(),'');assert.equal(await data.evaluate(n=>n.previousElementSibling.dataset.utmNote),marker);}
const state=await request('state');csrf=state.csrf;const snapshots=await Promise.all(state.targets.map(async t=>{const r=await page.request.get('/api/config?target='+t.id).then(r=>r.json());return {id:t.id,...r};}));const jobsBefore=(await request('jobs')).jobs.map(j=>j.id).sort();
const enumMap={theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},banch:{b:'1',a:'2'},focus:{face:'1',center:'2'}};
const alias={reg:['registration'],banch:['chob','bchoice']};
for(let index=0;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);items.sort((a,b)=>Number(a[0])-Number(b[0]));const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index);const expected=items.map(([v,w])=>v+' ('+(sum>0?Number((w/sum*100).toFixed(2)):0)+'%)'+(v===def?' - default':''));assert.deepEqual(await cell.locator('.overview-percent').allTextContents(),expected);assert.doesNotMatch(await cell.innerText(),/Default:|вес|тёмная|светлая|имейл|pop-up|центр/);for(let i=0;i<expected.length;i++){const weight=await cell.locator('.overview-value').nth(i).evaluate(n=>Number(getComputedStyle(n).fontWeight));if(/\(0%\)/.test(expected[i]))assert.ok(weight<=400,'zero percentages use regular weight');else assert.ok(weight>=600,'positive percentages use bold weight');}for(const suffix of await cell.locator('.overview-default').all()){assert.equal(await suffix.innerText(),' - default');assert.ok(await suffix.evaluate(n=>Number(getComputedStyle(n).fontWeight))<=400,'default suffix is regular');}}}
await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'});
for(let index=0;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index),expected=items.map(([,w])=>(sum>0?Number((w/sum*100).toFixed(2)):0)+'%');assert.deepEqual(await cell.locator('.matrix-percent').allTextContents(),expected);assert.equal(await cell.locator('input[type=radio]:checked').count(),1);for(const label of await cell.locator('.matrix-default').all())assert.ok(await label.evaluate(n=>Number(getComputedStyle(n).fontWeight))<=400,'default normal weight');}} await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'});
await enterEdit();const draft='Заметка UI — '+Date.now()+'\n<img src=x onerror=alert(1)>\nТолько обычный текст';await textarea().fill(draft);const handle=await textarea().elementHandle();await textarea().evaluate(n=>{document.querySelector('#overview-refresh').click();n.focus();n.setSelectionRange(7,11);});await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);assert.equal(await textarea().inputValue(),draft);assert.deepEqual(await handle.evaluate(n=>({connected:n.isConnected,focused:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focused:true,start:7,end:11});
await save();assert.equal((await request('utm-notes')).notes.reg.text,draft);assert.equal(await row().locator('img').count(),0);await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await row().getByText(draft,{exact:true}).waitFor();
const newer=draft+'\nСохранено после начала старого GET';
+3 -76
View File
@@ -1,76 +1,3 @@
/* 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);});
/* preland#25 consolidates partial copy/CAS/offline/ZIP-error regressions in the
* actual new matrix flow. This compatibility entry executes that full scenario. */
require('./ui-matrix.cjs');
+2 -2
View File
@@ -22,10 +22,10 @@ const fs=require('node:fs');const os=require('node:os');const path=require('node
const read=async()=>{const r=await page.request.get('/api/config?target='+target).then(r=>r.json());assert.ok(r.ok,r.error);return r;};const before=await read();
assert.ok(Array.isArray(before.config.requisites)&&before.config.requisites.length,'fixture requisites required');
await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();assert.equal(await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),0,'requisites overview hidden');
await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт',{exact:true}).waitFor();
await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт '+target,{exact:true}).waitFor();
assert.equal(await page.locator('#config-fields').getByLabel('Реквизиты',{exact:true}).count(),0,'requisites editor hidden');
assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites absent from editable fields');
await page.getByRole('button',{name:'Выбрать все поля формы',exact:true}).click();assert.doesNotMatch(await page.locator('#change-summary').innerText(),/requisites/);await page.getByRole('button',{name:'Снять выбор полей',exact:true}).click();
assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0,'old generic editor removed');
const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config,'runtime unchanged');
await page.screenshot({path:output+'/remember-requisites-editor.png',fullPage:true});
await context.close();context=await launch();page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(origin);await page.locator('#workspace').waitFor({state:'visible'});assert.equal(await page.locator('#login-panel').isVisible(),false,'reopen authenticates without password');
+3 -11
View File
@@ -1,11 +1,3 @@
/* preland#24: requisites/chb hidden; ordinary unknown fields retained; no writes. */
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});
try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});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 target=process.env.TARGET_ID||'a',read=()=>page.request.get('/api/config?target='+target).then(r=>r.json());const before=await read();assert.ok(before.ok,before.error);await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт',{exact:true}).waitFor();assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites not in editable fields');
const observed={requisites:await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),chb:await page.locator('#overview-table tr[data-overview-extra^="utm.chb"]').count(),chbEditor:await page.locator('#config-fields input[aria-label^="Применить поле utm.chb"]').count()};console.log('Observed hidden-field surface',observed);assert.deepEqual(observed,{requisites:0,chb:0,chbEditor:0});
if(before.config.utm.custom_visible){assert.equal(await page.locator('#overview-table tr[data-overview-extra="utm.custom_visible"]').count(),1);assert.ok(await page.locator('#config-fields input[aria-label^="Применить поле utm.custom_visible"]').count());}
await page.getByRole('button',{name:'Выбрать все поля формы',exact:true}).click();assert.doesNotMatch(await page.locator('#change-summary').innerText(),/requisites|utm\.chb/);await page.getByRole('button',{name:'Снять выбор полей',exact:true}).click();const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);console.log('PASS requisites/chb hidden / unknown visible / select all excludes / config unchanged');
}finally{await browser.close();}
})().catch(error=>{console.error(error);process.exit(1);});
/* preland#25: protection surface now uses only the inline matrix, unknown fields readonly. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});await page.goto('/');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();const id=process.env.TARGET_ID||'a',read=()=>page.request.get('/api/config?target='+id).then(r=>r.json()),before=await read();await page.getByLabel('Основной сайт '+id,{exact:true}).waitFor();assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('[data-matrix-extra^=requisites],[data-matrix-extra^="utm.chb"]').count(),0);if(before.config.utm.custom_visible){await page.locator('[data-matrix-extra="utm.custom_visible"]').waitFor();assert.equal(await page.locator('[data-matrix-extra="utm.custom_visible"] input').count(),0);}const after=await read();assert.deepEqual(after.config,before.config);assert.equal(after.version,before.version);console.log('PASS requisites/chb hidden / unknown fields readonly / unchanged runtime');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+10
View File
@@ -0,0 +1,10 @@
/* preland#25: real accepted jobs, delayed completed HTTP response across logout/login. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root='/fixture',original=new Map(['a','b','c','d'].map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),context=await browser.newContext({baseURL:'http://host.docker.internal:18093'}),page=await context.newPage();page.on('dialog',d=>d.accept());page.setDefaultTimeout(15000);const errors=[];page.on('pageerror',e=>errors.push(e.message));
const login=async()=>{await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await page.getByLabel('Основной сайт '+id,{exact:true}).waitFor();};
const logout=async()=>{await page.getByRole('button',{name:'Выйти',exact:true}).click();await page.locator('#login-panel').waitFor({state:'visible'});};
const host=id=>page.getByLabel('Основной сайт '+id,{exact:true});
const holdJob=async()=>{let release,ready,held=false;const gate=new Promise(r=>release=r),captured=new Promise(r=>ready=r);const handler=async route=>{if(held)return route.continue();held=true;const url=route.request().url();let body;for(let n=0;n<160;n++){body=await page.request.get(url).then(r=>r.json());assert.ok(body.ok,body.error);if(body.job.status==='completed')break;await new Promise(r=>setTimeout(r,100));}assert.equal(body.job.status,'completed');ready(body.job);await gate;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body)});};await page.route('**/api/jobs?id=*',handler);return {captured,release,stop:()=>page.unroute('**/api/jobs?id=*',handler)};};
try{await page.goto('/');await login();let held=await holdJob();const weight=page.locator('[data-matrix-target=a][data-marker=theme] input[type=number]').first();await weight.fill(String(Number(await weight.inputValue())+1));await page.getByRole('button',{name:'Сохранить настройки a',exact:true}).click();await held.captured;await logout();await login();await host('a').fill('new-session-draft.invalid');const node=await host('a').elementHandle(),notice=await page.locator('#notice').innerText();await host('a').evaluate(n=>{n.focus();n.setSelectionRange(2,5);});held.release();await page.waitForTimeout(400);assert.equal(await host('a').inputValue(),'new-session-draft.invalid','old Save completion cannot reset a new session draft');assert.deepEqual(await node.evaluate(n=>({connected:n.isConnected,focus:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focus:true,start:2,end:5});assert.equal(await page.locator('#notice').innerText(),notice);await held.stop();await page.getByRole('button',{name:'Отменить настройки a',exact:true}).click();console.log('PASS old accepted Save completion ignored after logout/login');
held=await holdJob();await page.locator('#copy-source').selectOption('a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await held.captured;await logout();await login();await host('b').fill('new-session-recipient-draft.invalid');await page.locator('#copy-source').selectOption('c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.equal(await page.locator('#copy-submit').isEnabled(),true,'new login is not blocked by old copyBusy');const current=await holdJob();await page.locator('#copy-submit').click();await current.captured;const currentNotice=await page.locator('#notice').innerText();held.release();await page.waitForTimeout(400);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');assert.equal(await page.locator('#copy-source').isDisabled(),true,'old finally cannot unlock new copy');assert.equal(await page.locator('#copy-results').innerText(),'','old outcomes cannot replace pending new operation');assert.equal(await page.locator('#notice').innerText(),currentNotice);current.release();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.match(await page.locator('#copy-results').innerText(),/d.invalid/);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');await current.stop();await held.stop();assert.deepEqual(errors,[]);console.log('PASS old accepted Copy completion/finally ignored; new copyBusy/outcomes/notice/draft preserved');
}finally{await browser.close();for(const [id,text]of original)fs.writeFileSync(root+'/'+id+'/config.json',text);}})().catch(e=>{console.error(e);process.exit(1)});
+3 -47
View File
@@ -1,47 +1,3 @@
/* Browser plugin not available; run only inside the supplied Docker Playwright image.
* Required env: ADMIN_URL, ADMIN_USER, ADMIN_PASSWORD, UI_OUTPUT; optional SECOND_TARGET.
*/
const { chromium } = require('playwright-core');
const assert = require('node:assert/strict');
const fs = require('node:fs');
(async () => {
const browser = await chromium.launch({headless:true,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()});});
await page.goto(process.env.ADMIN_URL || 'http://admin');
assert.match(await page.title(), /Прелендинги/);
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'});
await page.locator('#source-target').selectOption({index:1});
await page.getByLabel('Основной сайт', {exact:true}).waitFor();
const host = page.getByLabel('Основной сайт', {exact:true});
const original = await host.inputValue();
const secondOriginal=process.env.SECOND_TARGET?await page.request.get('/api/config?target='+encodeURIComponent(process.env.SECOND_TARGET)).then(r=>r.json()).then(r=>r.config.host):null;
await host.fill('ui-check.example.org');
assert.match(await page.locator('#change-summary').innerText(), /host/);
assert.equal(await page.locator('#targets input:checked').count(),1);
if (process.env.SECOND_TARGET) await page.locator('#targets input[value="'+process.env.SECOND_TARGET+'"]').check();
const apply = async () => {
const pending=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST');
await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click();
const accepted=await(await pending).json();assert.equal(accepted.ok,true,accepted.error);
await page.locator('.job').filter({hasText:accepted.job.id}).getByText('Сохранено и проверено',{exact:true}).first().waitFor();
};
await apply();
await page.getByRole('button',{name:'Перечитать',exact:true}).click();
await page.waitForFunction(()=>document.querySelector('[aria-label="Основной сайт"]')?.value==='ui-check.example.org');
await host.fill(original);
await apply();
if(process.env.SECOND_TARGET){await page.locator('#source-target').selectOption(process.env.SECOND_TARGET);await host.waitFor();await host.fill(secondOriginal);await apply();}
assert.equal(await page.locator('#config-fields textarea').count(),0,'JSON textarea absent from config editor');
assert.equal(await page.locator('.indpic-sets-table th').allTextContents().then(x=>x.join('|')),'Номер / название|Default|Проверка|ZIP|Действия');
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-desktop.png',fullPage:true});
await page.setViewportSize({width:390,height:844});
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-mobile.png',fullPage:true});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true,'no page-level horizontal overflow');
fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/admin-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2));
assert.deepEqual(errors,[]);assert.deepEqual(consoleMessages.filter(m=>!m.text.includes('status of 401')),[],'no unexpected browser console errors');
await browser.close(); console.log('PASS real login/read/edit/apply/reload/restore + mobile + console');
})().catch(e=>{console.error(e);process.exit(1);});
/* preland#25: readonly smoke + real local draft/cancel. Writes are covered by ui-matrix.cjs. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('/');assert.match(await page.title(),/Прелендинги/);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();const id=(await page.request.get('/api/state').then(r=>r.json())).targets[0].id,host=page.getByLabel('Основной сайт '+id,{exact:true});await host.waitFor();const original=await host.inputValue();await host.fill('draft-smoke.invalid');assert.match(await page.locator('[data-overview-target="'+id+'"] .matrix-status').innerText(),/Есть изменения/);await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await host.inputValue(),original);const theme=page.locator('[data-matrix-target="'+id+'"][data-marker=theme]'),add=theme.getByRole('button',{name:'+ Вариант',exact:true});assert.equal(await add.isDisabled(),true);const checked=theme.locator('.matrix-variant').filter({has:page.locator('input[type=radio]:checked')});await checked.locator('button').click();assert.equal(await add.isEnabled(),true);assert.match(await page.locator('.matrix-footer td').first().innerText(),/выберите default/);await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await add.isDisabled(),true);assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('.indpic-sets-table th').allTextContents().then(x=>x.join('|')),'Номер / название|Default|Проверка|ZIP|Действия');await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS login/nonblank/matrix input-cancel/ZIP table/mobile/no JS errors');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+8 -85
View File
@@ -1,85 +1,8 @@
/* 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);});
/* preland#25: UTM inline editor and two-target ZIP regression, disposable fixtures only. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict');
(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'});page.on('dialog',d=>d.accept());const errors=[];page.on('pageerror',e=>errors.push(e.message));const originals=new Map();
const cell=id=>page.locator('[data-matrix-target="'+id+'"][data-marker=indpic]');const saved=async id=>page.waitForFunction(id=>document.querySelector('[data-overview-target="'+id+'"] .matrix-status').textContent==='Сохранено',id,{timeout:90000});const save=async id=>{await page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}).click();await saved(id);};const read=async id=>{const r=await page.request.get('/api/config?target='+id).then(r=>r.json());assert.ok(r.ok,r.error);return r;};
try{await page.goto('/');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();for(const id of ['a','b']){await saved(id);originals.set(id,(await read(id)).config);assert.ok(!Object.hasOwn(originals.get(id).utm.indpic.weights,'3'),'fixture originally omits3');}
await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#targets input[value=b]').check();assert.ok(process.env.VALID_ZIP,'VALID_ZIP with set3 required');await page.locator('#set-add input[name=number]').fill('3');await page.locator('#set-add input[name=name]').fill('UI #25 — набор 3');await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);await page.locator('#set-add button').click();await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor({timeout:90000});await page.getByRole('button',{name:'Готово',exact:true}).click();for(const id of ['a','b']){assert.deepEqual((await read(id)).config,originals.get(id),'ZIP leaves config unchanged');assert.equal(await cell(id).locator('input[type=radio][aria-label="indpic default 3 '+id+'"]').count(),0,'missing variant cannot be silently selected as default');await cell(id).getByRole('button',{name:'+ Вариант',exact:true}).click();const row=cell(id).locator('.matrix-variant').last();await row.locator('select').selectOption('3');await row.locator('input[type=number]').fill('0');await row.locator('input[type=radio]').check();await save(id);const expected=structuredClone(originals.get(id));expected.utm.indpic.weights['3']=0;expected.utm.indpic.default=typeof expected.utm.indpic.default==='number'?3:'3';assert.deepEqual((await read(id)).config,expected);await page.getByRole('button',{name:'Обновить настройки '+id,exact:true}).click();await saved(id);assert.equal(await cell(id).locator('input[type=radio]:checked').evaluate(n=>n.closest('.matrix-variant').querySelector('select').value),'3');}
assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(n=>n.map(i=>i.value)),['a','b']);assert.deepEqual(errors,[]);console.log('PASS actual two-target ZIP / new variant3=0+explicit default / per-column Save+reread / otherfields preserved');
}finally{for(const [id,config]of originals){const rows=cell(id).locator('.matrix-variant');for(const row of await rows.all()){if(await row.locator('select').inputValue()==='3')await row.locator('button').click();}const def=String(config.utm.indpic.default);for(const row of await cell(id).locator('.matrix-variant').all())if(await row.locator('select').inputValue()===def)await row.locator('input[type=radio]').check();const button=page.getByRole('button',{name:'Сохранить настройки '+id,exact:true});if(await button.isEnabled()){await save(id);assert.deepEqual((await read(id)).config,config);}}await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+3 -3
View File
@@ -14,12 +14,12 @@ const {chromium}=require('playwright-core');const assert=require('node:assert/st
await page.reload();await page.locator('#workspace').waitFor({state:'visible'});current=await state();csrf=current.csrf;
const row=page.locator('#connection-list .connection').filter({hasText:original.domain}),header=page.locator('#overview-table th[data-overview-target="'+original.id+'"]');await header.waitFor();assert.match(await row.innerText(),/требуется проверка/);assert.match(await header.innerText(),/соединение не проверено/);
const before=await read(original.id);
await page.locator('#source-target').selectOption(original.id);await page.getByLabel('Основной сайт',{exact:true}).waitFor();await page.getByLabel('Основной сайт',{exact:true}).fill('unsaved-verification.example.org');await page.locator('#targets input[value=b]').check();await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии'));
const versionText=await page.locator('#read-state').innerText(),selected=await page.locator('#targets input:checked').evaluateAll(inputs=>inputs.map(i=>i.value));assert.equal(selected.length,2);
await page.locator('#source-target').selectOption(original.id);await page.getByLabel('Основной сайт a',{exact:true}).waitFor();await page.getByLabel('Основной сайт a',{exact:true}).fill('unsaved-verification.example.org');await page.locator('#targets input[value=b]').check();await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии'));
await page.locator('#read-state').getByText('Домен прочитан.',{exact:true}).waitFor();const versionText=await page.locator('#read-state').innerText(),selected=await page.locator('#targets input:checked').evaluateAll(inputs=>inputs.map(i=>i.value));assert.equal(selected.length,2);
await page.locator('#builder-target').selectOption(original.id);const theme=page.locator('#builder-fields select[data-marker=theme]');await theme.waitFor();await theme.selectOption((await theme.inputValue())==='rnd'?'1':'rnd');const constructorDraft=await page.locator('#builder-fields select').evaluateAll(inputs=>inputs.map(i=>[i.dataset.marker,i.value]));
await row.getByRole('button',{name:'Проверить соединение',exact:true}).click();await page.locator('#indpic-result-modal').waitFor({state:'visible'});assert.match(await page.locator('#result-body').innerText(),/проверен/i);assert.equal((await state()).targets.find(t=>t.id===original.id).verified,true,'real backend verified flag true');
assert.match(await row.innerText(),/соединение проверено/,'connection row refreshed without reload');assert.doesNotMatch(await row.innerText(),/требуется проверка/);assert.doesNotMatch(await header.innerText(),/соединение не проверено/,'overview header refreshed without reload');
assert.equal(await page.getByLabel('Основной сайт',{exact:true}).inputValue(),'unsaved-verification.example.org');assert.equal(await page.locator('#read-state').innerText(),versionText);assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(inputs=>inputs.map(i=>i.value)),selected);assert.deepEqual(await page.locator('#builder-fields select').evaluateAll(inputs=>inputs.map(i=>[i.dataset.marker,i.value])),constructorDraft);assert.match(await page.locator('#change-summary').innerText(),/host/);
assert.equal(await page.getByLabel('Основной сайт a',{exact:true}).inputValue(),'unsaved-verification.example.org');assert.equal(await page.locator('#read-state').innerText(),versionText);assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(inputs=>inputs.map(i=>i.value)),selected);assert.deepEqual(await page.locator('#builder-fields select').evaluateAll(inputs=>inputs.map(i=>[i.dataset.marker,i.value])),constructorDraft);assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/Есть изменения/);
const after=await read(original.id);assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);assert.deepEqual(errors,[]);
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/verified-refresh.png',fullPage:true});fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/verified-refresh-evidence.json',JSON.stringify({target:original.id,serverVerified:true,configVersion:after.version,selected,constructorDraftPreserved:true,pageErrors:errors},null,2));console.log('PASS real targets/test modal + connection/overview verified refresh + editor/selected2/CAS/constructor preserved/config unchanged');
}catch(error){primaryError=error;}finally{