feat(admin): ручные варианты UTM и независимый редактор config (#30)
This commit is contained in:
1 parent
47405b27d8
commit
941a4f01aa
28 files changed
+108
-174
No files matched your search
@@ -44,6 +44,8 @@ try{
|
||||
[$status]=request('/api/sets/check','POST',['targets'=>['delete-fixture'],'number'=>1],$csrf);check($status===404&&count(glob($store->dir.'/jobs/*.json'))===$countBefore,'HTTP stale enqueue cannot create job after connection deletion');
|
||||
[$status,$body]=request('/api/apply','POST',['targets'=>[],'changes'=>[]]);check($status===403,'HTTP mutation rejects missing CSRF');
|
||||
[$status,$body]=request('/api/config?target=test');check($status===200&&isset($body['version'],$body['config']),'HTTP config reads actual target version');$version=$body['version'];
|
||||
file_put_contents($store->dir.'/catalog.json','invalid obsolete catalog');
|
||||
[$status,$withoutCatalog]=request('/api/config?target=test');check($status===200&&!array_key_exists('catalog',$withoutCatalog)&&$withoutCatalog['warnings']===[],'HTTP config ignores even corrupt obsolete catalog without warnings');
|
||||
check(is_string($body['read_at']??null)&&preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/D',$body['read_at'])===1&&abs(time()-strtotime($body['read_at']))<10,'HTTP config includes actual successful read timestamp as response metadata');
|
||||
check(!array_key_exists('read_at',$store->target('test'))&&!property_exists(json_decode(file_get_contents($root.'/site/config.json')),'read_at'),'read timestamp does not enter target or runtime config');
|
||||
[$status,$body]=request('/api/apply','POST',['targets'=>[['id'=>'test','version'=>$version]],'changes'=>[['path'=>['host'],'value'=>'http.example']]],$csrf);check($status===202&&isset($body['job']['id']),'HTTP apply queues persistent job');$id=$body['job']['id'];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
declare(strict_types=1);
|
||||
if(($argv[1]??'')==='--crash-child') {require dirname(__DIR__).'/bootstrap.php';$p=json_decode(file_get_contents($argv[2]),true,64,JSON_THROW_ON_ERROR);PrelandAdmin\Remote::execute($p);exit;}
|
||||
require __DIR__.'/backend-security.php';
|
||||
use PrelandAdmin\{Config,Catalog,Sets,Conflict};
|
||||
use PrelandAdmin\{Config,Sets,Conflict};
|
||||
require dirname(__DIR__,2).'/components/utm-codec.php';
|
||||
$resolverConfig=['utm'=>['indpic'=>['default'=>'2','weights'=>['1'=>0,'2'=>0]]]];
|
||||
check(prelandResolveUtm(['indpic'=>'abcd2'],$resolverConfig)['digits']['indpic']==='2','configured zero-weight indpic remains selectable explicitly by actual resolver');
|
||||
@@ -13,10 +13,10 @@ $changed=Config::patch($legacy,[['path'=>['utm','reg','weights'],'value'=>(objec
|
||||
$modern=Config::decode('{"host":"zoomzilla.ru","utm":{"reg":{"default":"1","weights":{"1":100,"2":0}}}}');$changed=Config::patch($modern,[['path'=>['utm','registration','default'],'value'=>'after']]);check($changed->utm->reg->default==='2'&&!isset($changed->utm->registration),'legacy default adapts to modern target');
|
||||
$changed=Config::patch($modern,[['path'=>['utm','registration','weights','before'],'value'=>5]]);check($changed->utm->reg->weights->{'1'}===5,'legacy weight path adapts to numeric target');
|
||||
rejects(fn()=>Config::patch(Config::decode('{"utm":{"reg":{},"registration":{}}}'),[['path'=>['utm','reg','default'],'value'=>'2']]),'pre-existing two aliases rejected before adapter');
|
||||
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":100,"9":0}}}}'),static fn($n)=>$n===1),'zero-weight indpic variant requires complete physical set');
|
||||
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"1","weights":{}}}}'),static fn()=>true)===[],'enum default does not invent weights-membership restriction');
|
||||
$catalog=['host'=>'https://ZOOMZILLA.RU:443','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[['number'=>1,'name'=>'one']],'cons'=>[['number'=>1,'name'=>'one']]]];$store->write('catalog.json',$catalog);$cfg=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"1","weights":{"1":100}}}}');$fresh=Catalog::read($store,$cfg);check($fresh!==null&&Config::validate($cfg,static fn()=>true,$fresh)===[],'fresh matching catalog validates actual available number without fallback warning');
|
||||
$invalid=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"9","weights":{"9":100}}}}');rejects(fn()=>Config::validate($invalid,static fn()=>true,$fresh),'fresh catalog rejects unknown number');$catalog['generated_at']=gmdate(DATE_ATOM,time()-181);$store->write('catalog.json',$catalog);check(Catalog::read($store,$cfg)===null&&count(Config::validate($invalid,static fn()=>true,null))===1,'stale catalog falls back to explicit unverified warning');
|
||||
check(Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":100,"9":0}}}}'))===[],'zero-weight indpic can reference manually entered variant');
|
||||
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"1","weights":{}}}}'))===[],'enum default does not invent weights-membership restriction');
|
||||
$store->write('catalog.json',['host'=>'zoomzilla.ru','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[],'tp'=>[],'cons'=>[]]]);
|
||||
$manual=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"9","weights":{"9":100}}}}');check(Config::validate($manual)===[],'manual groups require no catalog lookup or availability warnings');
|
||||
$validPrepared=$sets->prepareZip($zipPath,1);$source=$sets->manifest($target,1);$desired=Sets::preparedManifest($validPrepared,1);
|
||||
$original=file_get_contents($root.'/site/1/01_desk_1-source.jpg');$img=new Imagick();$img->newImage(1696,920,'#aabbcc');$img->setImageFormat('jpg');file_put_contents($root.'/site/1/01_desk_1-source.jpg',$img->getImagesBlob());$img->clear();$foreign=$sets->manifest($target,1);
|
||||
rejects(fn()=>$sets->upload($target,1,$validPrepared,'CAS',$source,true),'set CAS rejects third-party manifest changed after read');check($sets->manifest($target,1)===$foreign,'set CAS preserves third-party images');
|
||||
|
||||
@@ -3,11 +3,11 @@ declare(strict_types=1);
|
||||
require __DIR__.'/backend.php';
|
||||
use PrelandAdmin\{Config,Jobs,Sets,IndpicSetStorage,Auth,Problem};
|
||||
function rejects(callable $call,string $message): void {try{$call();}catch(\Throwable $e){check(true,$message);return;}throw new RuntimeException('Accepted invalid input: '.$message);}
|
||||
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"reg":{"default":"1","weights":{"1":100}},"registration":{"default":"before","weights":{"before":100}}}}'),static fn()=>true),'old/new UTM alias conflict rejected');
|
||||
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"theme":{"default":"3","weights":{}}}}'),static fn()=>true),'invalid enum rejected');
|
||||
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"degr":{"default":"1","weights":{"1":-1}}}}'),static fn()=>true),'negative UTM weight rejected');
|
||||
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":0}}}}'),static fn()=>false),'incomplete default rejected');
|
||||
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":0,"light":0}}}}'),static fn()=>true)===[],'legacy semantic enum remains valid without rewriting');
|
||||
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"reg":{"default":"1","weights":{"1":100}},"registration":{"default":"before","weights":{"before":100}}}}')),'old/new UTM alias conflict rejected');
|
||||
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"3","weights":{}}}}'))===[],'manual numeric theme variant accepted');
|
||||
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"degr":{"default":"1","weights":{"1":-1}}}}')),'negative UTM weight rejected');
|
||||
check(Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":0}}}}'))===[],'config editor does not require a physical set');
|
||||
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":0,"light":0}}}}'))===[],'legacy semantic enum remains valid without rewriting');
|
||||
$current=$transport->readConfig();$job=$jobs->create('apply',['targets'=>[['id'=>'test','version'=>$current['version']]],'changes'=>[['path'=>['custom','empty'],'value'=>(object)[]]]]);$jobs->run($job['id']);
|
||||
check($transport->readConfig()['config']->custom->empty instanceof stdClass,'empty JSON object survives persisted job');
|
||||
$state=$transport->readConfig();$id=$jobs->create('apply',['targets'=>[['id'=>'test','version'=>$state['version']]],'changes'=>[['path'=>['host'],'value'=>'resumed.example']]])['id'];
|
||||
@@ -28,7 +28,7 @@ $inflated=$root.'/inflated.zip';file_put_contents($inflated,rawZip([['1/01_desk_
|
||||
$result=$sets->upload($target,1,$prepared,'Тестовый набор');check(str_contains($result['message'],'90'),'local set publication revalidates delivered files');
|
||||
$sets->check($target,1);check(is_file($root.'/site/1/01_desk_1-source.jpg'),'set directory published');
|
||||
check((fileperms($root.'/site/1')&0777)===0755,'published set directory is readable by independent web-server UID');
|
||||
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"2","weights":{"1":100}}}}'),static fn()=>true),'default without weight variant rejected instead of runtime fallback');
|
||||
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"2","weights":{"1":100}}}}')),'default without weight variant rejected instead of runtime fallback');
|
||||
$kept=hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg');$badFiles=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename)$badFiles[$filename]=['data'=>base64_encode(file_get_contents($prepared.'/1/'.$filename)),'sha256'=>str_repeat('0',64)];
|
||||
rejects(fn()=>$transport->call(['op'=>'set-write','number'=>1,'files'=>$badFiles,'expected_manifest'=>$sets->manifest($target,1)]),'delivery hash mismatch rejects activation');check(hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg')===$kept,'failed set publication preserves previous working image');
|
||||
// Настоящий формат должен совпадать с расширением, даже если размеры верны.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Запуск только в одноразовом Docker-контейнере от root, без host state/socket.
|
||||
set -eu
|
||||
test "$(id -u)" = 0
|
||||
test ! -e /srv/preland-admin
|
||||
test ! -e /usr/bin/docker
|
||||
base=/srv/preland-admin
|
||||
install -d -m 700 "$base" "$base/secrets" "$base/deploy" /tmp/catalog-victim
|
||||
install -d -m 700 -o 997 -g 987 "$base/state"
|
||||
printf 'unchanged\n' > /tmp/catalog-victim/original
|
||||
printf 'not executed\n' > "$base/deploy/export-catalog.php"
|
||||
cat > /usr/bin/docker <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' '{"host":"zoomzilla.ru","generated_at":"2026-10-25T00:00:00Z","groups":{"degr":[],"tp":[],"cons":[]}}'
|
||||
SH
|
||||
chmod 755 /usr/bin/docker
|
||||
# Унаследованный cwd моделирует доступ к корню bind mount внутри приложения,
|
||||
# минуя закрытый host-parent /srv/preland-admin.
|
||||
(cd "$base/state" && setpriv --reuid=997 --regid=987 --clear-groups ln -s /tmp/catalog-victim catalog.json)
|
||||
sed 's/\r$//' /work/admin/deploy/refresh-catalog.sh > /tmp/catalog-refresh.sh
|
||||
bash /tmp/catalog-refresh.sh
|
||||
test ! -L "$base/state/catalog.json"
|
||||
test -f "$base/state/catalog.json"
|
||||
test "$(stat -c %u /tmp/catalog-victim/original)" = 0
|
||||
test "$(cat /tmp/catalog-victim/original)" = unchanged
|
||||
test "$(find /tmp/catalog-victim -type f | wc -l)" = 1
|
||||
test "$(stat -c '%u:%g:%a' "$base/state/catalog.json")" = 997:987:600
|
||||
if setpriv --reuid=997 --regid=987 --clear-groups ls "$base/secrets" >/dev/null 2>&1; then
|
||||
echo 'FAIL: app UID can inspect root preparation directory' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo 'PASS root catalog publisher replaces destination symlink without following it; victim and owner intact; preparation inaccessible to app UID'
|
||||
@@ -44,9 +44,8 @@ try{
|
||||
foreach(['{"utm":{"chob":{"default":"a","weights":{"a":100}},"bchoice":{"default":"a","weights":{"a":100}}}}','{"utm":{"reg":null}}','{"utm":{"theme":{"default":"dark","weights":{"dark":33.33,"1":66.67}}}}','{"utm":{"focus":{"default":true,"weights":{"1":100}}}}','{"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":100}}}}');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":100}}}}');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');
|
||||
fixture('source','{"utm":{"indpic":{"default":"1","weights":{"1":100}}}}');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']==='success'&&readCopy('c')['config']->utm->indpic->default==='1'&&count($store->backups('c'))===$backupCount+1&&!is_dir($paths['c'].'/1'),'copy writes manual indpic without requiring physical images');
|
||||
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":100}},"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'}===100&&$c['config']->utm->cons->default===1,'one saved snapshot copies to two successful recipients including tp/cons');
|
||||
@@ -55,7 +54,10 @@ try{
|
||||
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":100}}}}');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":"catalog.example","utm":{"degr":{"default":"2","weights":{"2":100}}}}');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']==='success'&&readCopy('c')['config']->utm->degr->default==='2','copy ignores catalog and preserves manually selected group');
|
||||
fixture('source','{"utm":{"theme":{"default":"3","weights":{"1":0,"2":0,"3":100}}}}');fixture('c','{"utm":{"theme":{"default":"light","weights":{"dark":0,"light":100},"extra":"keep"},"focus":{"default":"2","weights":{"2":100}}}}');
|
||||
[$status,$body]=copyRequest(copyBody(['c']),$csrf);copyCheck($status===202,'copy queues arbitrary theme3');$jobs->run($body['job']['id']);$copied=readCopy('c')['config'];
|
||||
copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='success'&&$copied->utm->theme->default==='3'&&get_object_vars($copied->utm->theme->weights)===['dark'=>0,'light'=>0,3=>100]&&$copied->utm->theme->extra==='keep'&&$copied->utm->focus->default==='2','copy keeps numeric theme3 beside semantic aliases and retains recipient-only group');
|
||||
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":100}},"bchoice":{"default":"b","weights":{"b":100}}}}');[$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');
|
||||
|
||||
@@ -53,7 +53,12 @@ $targets = $linkStore->read('targets.json');$targets['first']['domain']='renamed
|
||||
$renamed = $builder->get('first');
|
||||
linkCheck(str_starts_with($renamed['url'],'https://renamed.example/?') && !str_contains($renamed['url'],'first.example'), 'domain rename rebases saved URL to current origin');
|
||||
linkCheck($renamed['selection']===$modified && $renamed['saved_at']===$updated['saved_at'] && parse_url($renamed['url'],PHP_URL_QUERY)===parse_url($updated['url'],PHP_URL_QUERY), 'domain rename preserves selection, timestamp and existing query noise');
|
||||
foreach ([[], array_diff_key($selection,['focus'=>true]), $selection+['unexpected'=>'1'], array_replace($selection,['theme'=>'9']), array_replace($selection,['indpic'=>'0']), array_replace($selection,['cons'=>1]), array_replace($selection,['tp'=>'rnd&host=evil']), array_replace($selection,['reg'=>['1']])] as $invalid) {
|
||||
foreach (array_keys($selection) as $key) {
|
||||
$manualSelection=array_fill_keys(array_keys($selection),'rnd');$manualSelection[$key]='9';
|
||||
$manual=$builder->save('second',$manualSelection);parse_str(parse_url($manual['url'],PHP_URL_QUERY),$manualParameters);
|
||||
linkCheck($builder->get('second')===$manual&&$manual['selection'][$key]==='9'&&$manualParameters[$key][4]==='9','manual digit 9 encodes and persists for '.$key);
|
||||
}
|
||||
foreach ([[], array_diff_key($selection,['focus'=>true]), $selection+['unexpected'=>'1'], array_replace($selection,['theme'=>'10']), array_replace($selection,['indpic'=>'0']), array_replace($selection,['cons'=>1]), array_replace($selection,['tp'=>'rnd&host=evil']), array_replace($selection,['reg'=>['1']])] as $invalid) {
|
||||
linkReject(fn()=>$builder->save('first',$invalid),'strict selection rejects missing, unknown, wrong-type or injected value');
|
||||
}
|
||||
linkReject(fn()=>$builder->get('../targets'),'target traversal rejected');
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require dirname(__DIR__).'/bootstrap.php';
|
||||
use PrelandAdmin\{Config,Store,Transport,Jobs,Problem};
|
||||
function manualCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
|
||||
foreach(['indpic','theme','degr','reg','banch','tp','cons','focus'] as $key){
|
||||
$config=Config::decode('{"utm":{"'.$key.'":{"default":"3","weights":{"1":0,"3":100}}}}');
|
||||
manualCheck(Config::validate($config)===[],'manual variant 3 accepted independently for '.$key);
|
||||
}
|
||||
$semantic=Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":100,"light":0},"note":"keep"}}}');
|
||||
$next=Config::patch($semantic,[['path'=>['utm','theme','weights'],'value'=>(object)['1'=>25,'2'=>25,'3'=>50]],['path'=>['utm','theme','default'],'value'=>'3']]);
|
||||
Config::validate($next);
|
||||
manualCheck($next->utm->theme->default==='3'&&get_object_vars($next->utm->theme->weights)===['dark'=>25,'light'=>25,3=>50]&&$next->utm->theme->note==='keep','semantic target retains aliases while adding numeric variant 3');
|
||||
foreach(['0','10','-1','green'] as $variant){try{Config::validate(Config::decode('{"utm":{"theme":{"default":"'.$variant.'","weights":{}}}}'));throw new RuntimeException('Accepted invalid number');}catch(Problem){manualCheck(true,'invalid variant rejected '.$variant);}}
|
||||
try{Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":50,"1":50}}}}'));throw new RuntimeException('Accepted duplicate');}catch(Problem){manualCheck(true,'semantic numeric duplicate remains rejected');}
|
||||
$root=sys_get_temp_dir().'/manual-variants-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);$store=new Store($root.'/state');putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store->saveTarget(['id'=>'site','domain'=>'site.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$store->verifyTarget('site');file_put_contents($root.'/site/config.json','{"utm":{"theme":{"default":"dark","weights":{"dark":100,"light":0}}}}');$transport=new Transport($store,$store->target('site'));$jobs=new Jobs($store);
|
||||
$read=$transport->readConfig();$job=$jobs->create('apply',['targets'=>[['id'=>'site','version'=>$read['version']]],'changes'=>[['path'=>['utm','theme','weights'],'value'=>(object)['1'=>0,'2'=>0,'3'=>100]],['path'=>['utm','theme','default'],'value'=>'3'],['path'=>['utm','indpic'],'value'=>(object)['default'=>'9','weights'=>(object)['9'=>100]]]]]);$jobs->run($job['id']);$read=$transport->readConfig();manualCheck($jobs->get($job['id'])['results'][0]['status']==='success'&&$read['config']->utm->theme->default==='3'&&$read['config']->utm->indpic->default==='9'&&!is_dir($root.'/site/9'),'apply writes theme3 and indpic9 without probing public files');
|
||||
$backup=$store->backup('site','{"utm":{"theme":{"default":"3","weights":{"3":100}},"degr":{"default":"9","weights":{"9":100}}}}');$store->write('catalog.json',['host'=>'zoomzilla.ru','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[],'tp'=>[],'cons'=>[]]]);$job=$jobs->create('restore',['target'=>'site','version'=>$read['version'],'id'=>$backup]);$jobs->run($job['id']);manualCheck($jobs->get($job['id'])['results'][0]['status']==='success'&&$transport->readConfig()['config']->utm->degr->default==='9','restore accepts manual variants without catalog');
|
||||
@@ -6,7 +6,7 @@ function obsoleteCheck(bool $ok,string $message): void {if(!$ok)throw new Runtim
|
||||
function obsoleteReject(callable $call,string $message): void {try{$call();}catch(Problem $e){obsoleteCheck($e->status===422&&str_contains($e->getMessage(),'utm.chb'),$message);return;}throw new RuntimeException('Accepted obsolete key: '.$message);}
|
||||
$legacy='{"host":"zoomzilla.ru","utm":{"chb":null,"unknown":{"raw":true},"cons":{"default":"2","weights":{"1":0,"2":100}}},"custom":{"chb":true}}';
|
||||
$raw=Config::decode($legacy);obsoleteCheck(property_exists($raw->utm,'chb')&&Config::decode(Config::encode($raw))->custom->chb===true,'legacy raw remains readable and encodable without migration');
|
||||
obsoleteReject(fn()=>Config::validate($raw,static fn()=>true),'validation rejects exact deprecated key even null');
|
||||
obsoleteReject(fn()=>Config::validate($raw),'validation rejects exact deprecated key even null');
|
||||
foreach([
|
||||
[['path'=>['host'],'value'=>'other.example']],
|
||||
[['path'=>['utm','chb'],'value'=>(object)['default'=>'1','weights'=>(object)['1'=>1]]]],
|
||||
@@ -14,7 +14,7 @@ foreach([
|
||||
[['path'=>['utm'],'value'=>(object)['chb'=>null]]],
|
||||
] as $changes)obsoleteReject(fn()=>Config::patch($changes[0]['path']===['utm','chb','default']?Config::decode('{"utm":{"chb":{}}}'):$raw,$changes),'patch refuses resulting chb at any depth or parent replacement');
|
||||
$clean=Config::patch($raw,[['path'=>['utm','chb'],'delete'=>true]]);obsoleteCheck(!property_exists($clean->utm,'chb')&&Config::encode($clean->utm->cons)===Config::encode($raw->utm->cons)&&Config::encode($clean->utm->unknown)===Config::encode($raw->utm->unknown)&&$clean->custom->chb===true,'explicit delete removes only utm.chb and preserves unknown keys and cons');
|
||||
obsoleteCheck(Config::validate($clean,static fn()=>true)!==null,'cleaned config passes write validation');
|
||||
obsoleteCheck(Config::validate($clean)!==null,'cleaned config passes write validation');
|
||||
$root=sys_get_temp_dir().'/obsolete-utm-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);$store=new Store($root.'/state');putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store->saveTarget(['id'=>'a','name'=>'A','domain'=>'a.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$store->verifyTarget('a');$target=$store->target('a');$transport=new Transport($store,$target);file_put_contents($root.'/site/config.json',$legacy);$read=$transport->readConfig();obsoleteCheck($read['content']===$legacy&&property_exists($read['config']->utm,'chb'),'actual transport reads obsolete raw without rewriting');
|
||||
obsoleteReject(fn()=>$transport->writeConfig($read['version'],$legacy),'direct transport rejects obsolete write');obsoleteCheck(file_get_contents($root.'/site/config.json')===$legacy&&$store->backups('a')===[],'rejected direct transport creates no backup and changes no runtime bytes');
|
||||
$remotePrivate=$root.'/remote-private';$remote=Remote::execute(['op'=>'write','root'=>$root.'/site','local_private'=>$remotePrivate,'version'=>$read['version'],'content'=>$legacy]);obsoleteCheck(!$remote['ok']&&str_contains($remote['error'],'utm.chb'),'standalone remote rejects obsolete write');obsoleteCheck(!file_exists($remotePrivate)&&file_get_contents($root.'/site/config.json')===$legacy,'remote rejection happens before private directory, backup or temp creation');
|
||||
|
||||
@@ -13,7 +13,7 @@ percentCheck($acceptedPrecision===[],'strict precision does not use binary toler
|
||||
foreach([(object)['1'=>1],(object)['1'=>33.333,'2'=>66.667],(object)['1'=>100.01],(object)['1'=>-1,'2'=>101],(object)['1'=>'100'],(object)['1'=>true],(object)['1'=>null],(object)['1'=>INF],(object)['1'=>NAN],(object)['1'=>50,'2'=>49.99],[],null] as $weights)percentReject(fn()=>PercentWeights::validate(percentConfig($weights)),'invalid stored weights rejected');
|
||||
percentReject(fn()=>PercentWeights::validate((object)['utm'=>[]]),'invalid utm container rejected');
|
||||
percentReject(fn()=>PercentWeights::validate((object)['utm'=>(object)['theme'=>null]]),'invalid known group rejected');
|
||||
percentReject(fn()=>Config::validate(percentConfig((object)['1'=>1]),static fn()=>true),'Config validation rejects relative weights');
|
||||
percentReject(fn()=>Config::validate(percentConfig((object)['1'=>1])),'Config validation rejects relative weights');
|
||||
$source=percentConfig((object)['1'=>3,'2'=>1],'registration');$before=Config::encode($source);$migrated=PercentWeights::normalizeRelative($source);PercentWeights::validate($migrated);
|
||||
percentCheck(get_object_vars($migrated->utm->registration->weights)===['1'=>75,'2'=>25],'explicit migration produces integer percentages');
|
||||
$rounded=PercentWeights::normalizeRelative(percentConfig((object)['1'=>2,'2'=>1]));percentCheck(get_object_vars($rounded->utm->theme->weights)===['1'=>66.67,'2'=>33.33],'migration rounds each share without redistribution');
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* preland#30: native manual config editing; Browser plugin not available. */
|
||||
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
|
||||
(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',ids=['a','b','d'],original=new Map(ids.map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());page.setDefaultTimeout(7000);const 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}),read=id=>JSON.parse(fs.readFileSync(root+'/'+id+'/config.json','utf8'));const settled=async id=>page.waitForFunction(id=>/^(Прочитано с хостинга|Записано на хостинг)$/.test(document.querySelector('[data-overview-target='+id+'] .matrix-status').textContent),id,{timeout:60000});const add=async(id,m,n)=>{await cell(id,m).getByRole('button',{name:'Добавить вариант '+m+' '+id,exact:true}).click();const number=cell(id,m).getByLabel('Новый вариант '+m+' '+id,{exact:true}).last();assert.equal(await number.inputValue(),'');assert.equal(await cell(id,m).getByLabel(m+' вес новый '+id,{exact:true}).last().inputValue(),'');assert.equal(await cell(id,m).getByLabel(m+' default новый '+id,{exact:true}).last().isChecked(),false);assert.equal(await save(id).isDisabled(),true);await number.fill('0');assert.equal(await save(id).isDisabled(),true);await number.fill(n);await cell(id,m).getByLabel(m+' вес '+n+' '+id,{exact:true}).last().fill('0');};try{const semantic=read('a');semantic.utm.theme={default:'dark',weights:{dark:100,light:0}};semantic.utm.focus={default:'1',weights:{'2':100}};fs.writeFileSync(root+'/a/config.json',JSON.stringify(semantic));const d=read('d');delete d.utm.cons;fs.writeFileSync(root+'/d/config.json',JSON.stringify(d));await page.goto('/');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 settled(id);
|
||||
for(const m of ['indpic','theme','degr','reg','banch','tp','cons','focus']){await add('a',m,'9');assert.equal(await cell('a',m).getByLabel(m+' вес 9 a',{exact:true}).inputValue(),'0');assert.equal(await cell('a',m).getByLabel(m+' default 9 a',{exact:true}).isChecked(),false);}await save('a').click();await settled('a');for(const m of ['indpic','theme','degr','reg','banch','tp','cons','focus'])assert.equal(read('a').utm[m].weights['9'],0,'new zero variant must persist '+m);
|
||||
assert.equal(Object.hasOwn(read('a').utm.focus.weights,'1'),false,'synthetic missing default row must not be materialized by a manual zero addition');await add('a','theme','9');assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/уникальны/);await cell('a','theme').getByLabel('Новый вариант theme a',{exact:true}).last().fill('3');await cell('a','theme').getByLabel('theme default 3 a',{exact:true}).check();await save('a').click();await settled('a');assert.equal(read('a').utm.theme.default,'3');assert.equal(read('a').utm.theme.weights['3'],0);await page.getByRole('button',{name:'Обновить настройки a',exact:true}).click();await settled('a');assert.equal(await cell('a','theme').getByLabel('theme default 3 a',{exact:true}).isChecked(),true);
|
||||
await add('d','cons','3');assert.equal(await save('d').isDisabled(),true,'new group requires explicit default');await cell('d','cons').getByLabel('cons default 3 d',{exact:true}).check();await save('d').click();await settled('d');assert.deepEqual(read('d').utm.cons,{default:'3',weights:{'3':0}});
|
||||
await page.locator('#copy-source').selectOption('a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,null,{timeout:60000});assert.equal(read('b').utm.theme.default,'3');assert.equal(read('b').utm.theme.weights['3'],0);assert.equal(read('b').utm.focus.weights['9'],0);await add('a','theme','8');await cancel('a').click();assert.equal(await cell('a','theme').getByLabel('theme вес 8 a',{exact:true}).count(),0);await page.locator('#builder-target').selectOption('a');await page.getByLabel('Ссылка: theme',{exact:true}).selectOption('3');await page.locator('#builder-save').click();await page.locator('#builder-result').waitFor({state:'visible'});assert.equal((await page.request.get('/api/link-builder?target=a').then(r=>r.json())).selection.theme,'3');assert.doesNotMatch(await page.locator('#builder-warnings').innerText(),/каталог/);await page.locator('#overview').screenshot({path:out+'/manual-variants-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/manual-variants-mobile.png'});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS all8 manual variants, zero Save/reread, theme3/default/builder, duplicate recovery, missing group/default, cancel, mobile, no JS errors');}finally{await browser.close();for(const [id,raw]of original)fs.writeFileSync(root+'/'+id+'/config.json',raw);}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -15,8 +15,8 @@ const {chromium}=require('playwright-core');const assert=require('node:assert/st
|
||||
const weights=cell('a','theme').locator('input[type=number]');assert.equal(await page.locator('.matrix-percent').count(),0);assert.equal(await weights.first().getAttribute('min'),'0');assert.equal(await weights.first().getAttribute('max'),'100');assert.equal(await weights.first().getAttribute('step'),'0.01');assert.match(await cell('a','theme').locator('.matrix-labels').innerText(),/Вес, %/);
|
||||
const defaultBefore=readFile('a').utm.theme.default;await weights.nth(0).fill('33.33');assert.equal(await weights.nth(1).inputValue(),'75','typing never redistributes other weights');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/сумма/);await weights.nth(1).fill('66.67');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a','theme').locator('.matrix-total').innerText(),'Сумма: 100%');await save('a').click();await saved('a');assert.deepEqual(readFile('a').utm.theme.weights,{'1':33.33,'2':66.67});assert.equal(readFile('a').utm.theme.default,defaultBefore);await page.getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');assert.equal(await weights.first().inputValue(),'33.33');
|
||||
await weights.first().fill('33.333');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/двух знаков/);await weights.first().fill('0.00000000001');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/двух знаков/);await weights.first().fill('-1');assert.equal(await save('a').isDisabled(),true);await weights.first().fill('101');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/от 0 до 100/);await weights.first().fill('');assert.equal(await save('a').isDisabled(),true);await weights.first().fill('0');await weights.nth(1).fill('0');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a','theme').locator('.matrix-total').innerText(),'Сумма: 0%');await save('a').click();await saved('a');assert.deepEqual(readFile('a').utm.theme.weights,{'1':0,'2':0});assert.equal(readFile('a').utm.theme.default,defaultBefore);await weights.first().fill('25');await weights.nth(1).fill('75');await save('a').click();await saved('a');
|
||||
assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] button').count(),0);
|
||||
assert.equal(await cell('d','cons').innerText(),'Группа отсутствует');assert.equal(readFile('d').utm.cons,undefined);
|
||||
assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] .matrix-remove').count(),0);
|
||||
assert.equal(await cell('d','cons').locator('.matrix-variant').count(),0);assert.equal(await cell('d','cons').locator('.matrix-add-variant').count(),1);assert.equal(readFile('d').utm.cons,undefined);
|
||||
const beforeDefault=readFile('a');await cell('a','theme').locator('input[type=radio]').first().check();await save('a').click();await saved('a');assert.equal(await header('a').locator('.matrix-status').innerText(),'Записано на хостинг');assert.deepEqual(readFile('a').utm.theme.weights,beforeDefault.utm.theme.weights);assert.equal(readFile('a').utm.theme.default,'1');assert.equal(typeof readFile('a').utm.theme.default,typeof beforeDefault.utm.theme.default);await cell('a','theme').locator('input[type=radio]').nth(1).check();await save('a').click();await saved('a');assert.deepEqual(readFile('a'),beforeDefault);
|
||||
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('70');await cell('b','theme').locator('input[type=number]').nth(1).fill('30');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,70);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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* preland#29: actual rendered read lifecycle and immutable UTM identifiers. Browser plugin not available. */
|
||||
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
|
||||
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));const header=id=>page.locator('[data-overview-target="'+id+'"]'),reload=id=>page.getByRole('button',{name:'Обновить настройки '+id,exact:true});const idle=()=>page.waitForFunction(()=>[...document.querySelectorAll('.matrix-status')].every(n=>!n.textContent.includes('Чтение')));try{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();await idle();
|
||||
assert.equal(await page.locator('[data-overview-utm] select').count(),0,'UTM numbers must be static');assert.equal(await page.locator('[data-overview-utm] button').count(),0,'UTM variants/groups cannot be added or removed');assert.match(await header('a').locator('.matrix-status').innerText(),/Прочитано с хостинга/);const date=header('a').locator('.matrix-read-at');assert.match(await date.innerText(),/Последнее чтение: .*\d{2}:\d{2}:\d{2} МСК/);const before=await date.innerText(),foreign=await header('b').locator('.matrix-read-at').innerText();let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);const delay=async route=>{const response=await route.fetch();started();await gate;await route.fulfill({response});};await page.route('**/api/config?target=a',delay);await reload('a').click();await seen;assert.equal(await reload('a').isDisabled(),true);assert.match(await header('a').locator('.matrix-status').innerText(),/Чтение с хостинга/);assert.equal(await date.innerText(),before);await page.waitForTimeout(1100);release();await page.waitForFunction(()=>!document.querySelector('[data-overview-target=a] .matrix-status').textContent.includes('Чтение'));await page.unroute('**/api/config?target=a',delay);assert.equal(await reload('a').isEnabled(),true);assert.equal(await header('b').locator('.matrix-read-at').innerText(),foreign);const success=await date.innerText();
|
||||
assert.equal(await page.locator('[data-overview-utm] select').count(),0,'UTM numbers must be static');assert.equal(await page.locator('[data-overview-utm] .matrix-remove').count(),0,'UTM variants cannot be removed');assert.equal(await page.locator('[data-overview-utm] .matrix-add-variant').count(),32,'manual add on every UTM/domain');assert.match(await header('a').locator('.matrix-status').innerText(),/Прочитано с хостинга/);const date=header('a').locator('.matrix-read-at');assert.match(await date.innerText(),/Последнее чтение: .*\d{2}:\d{2}:\d{2} МСК/);const before=await date.innerText(),foreign=await header('b').locator('.matrix-read-at').innerText();let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);const delay=async route=>{const response=await route.fetch();started();await gate;await route.fulfill({response});};await page.route('**/api/config?target=a',delay);await reload('a').click();await seen;assert.equal(await reload('a').isDisabled(),true);assert.match(await header('a').locator('.matrix-status').innerText(),/Чтение с хостинга/);assert.equal(await date.innerText(),before);await page.waitForTimeout(1100);release();await page.waitForFunction(()=>!document.querySelector('[data-overview-target=a] .matrix-status').textContent.includes('Чтение'));await page.unroute('**/api/config?target=a',delay);assert.equal(await reload('a').isEnabled(),true);assert.equal(await header('b').locator('.matrix-read-at').innerText(),foreign);const success=await date.innerText();
|
||||
const fail=route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({ok:false,error:'Ошибка чтения fixture #29'})});await page.route('**/api/config?target=a',fail);await reload('a').click();await header('a').getByText(/Ошибка чтения fixture/).waitFor();assert.equal(await date.innerText(),success);assert.equal(await reload('a').isEnabled(),true);await page.unroute('**/api/config?target=a',fail);await page.waitForTimeout(1100);await reload('a').click();await idle();assert.match(await header('a').locator('.matrix-status').innerText(),/Прочитано с хостинга/);assert.notEqual(await date.innerText(),success);
|
||||
const host=page.getByLabel('Основной сайт a',{exact:true});await host.fill('draft-29.invalid');page.once('dialog',d=>d.dismiss());await reload('a').click();assert.equal(await host.inputValue(),'draft-29.invalid');page.once('dialog',d=>d.accept());await reload('a').click();await idle();assert.notEqual(await host.inputValue(),'draft-29.invalid');assert.deepEqual(errors,[]);await page.locator('#overview').screenshot({path:'/output/read-desktop.png'});await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:'/output/read-mobile.png'});console.log('PASS static UTM / actual GET loading+disabled/date / error retains date / retry / foreign date / dirty dismiss+confirm / desktop/mobile/no JS errors');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,5 @@
|
||||
/* preland#30: upload a new physical set, manually add its UTM, replace through row control. */
|
||||
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
|
||||
(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',raw=fs.readFileSync(root+'/a/config.json','utf8'),stateFiles=['sets.json','checks.json'],snap=new Map(stateFiles.map(name=>[name,fs.existsSync(root+'/state/'+name)?fs.readFileSync(root+'/state/'+name):null]));assert.ok(!fs.existsSync(root+'/a/9'),'isolated fixture must have no set9');const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[],dialogs=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>{dialogs.push(d.message());d.accept();});page.setDefaultTimeout(15000);const config=()=>JSON.parse(fs.readFileSync(root+'/a/config.json','utf8')),done=async()=>{await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor({timeout:90000});await page.getByRole('button',{name:'Готово',exact:true}).click();};try{await page.goto('/');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();await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#set-add input[name=number]').fill('9');await page.locator('#set-add input[name=name]').fill('Новый сет 9');await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);await page.locator('#set-add').screenshot({path:out+'/set-new-form.png'});await page.locator('#set-add button').click();await done();assert.equal(fs.readFileSync(root+'/a/config.json','utf8'),raw,'new ZIP does not change config');assert.equal(fs.readdirSync(root+'/a/9').length,90);const row=page.locator('#sets-body tr').filter({has:page.getByLabel('Название Set 9',{exact:true})});await row.waitFor();assert.equal(await row.getByLabel('Название Set 9',{exact:true}).inputValue(),'Новый сет 9');assert.equal(dialogs.length,0,'new set upload does not ask replacement');
|
||||
const cell=page.locator('[data-matrix-target=a][data-marker=indpic]');assert.equal(await cell.getByLabel('indpic вес 9 a',{exact:true}).count(),0);await cell.getByRole('button',{name:'Добавить вариант indpic a',exact:true}).click();await cell.screenshot({path:out+'/variant-empty.png'});await cell.getByLabel('Новый вариант indpic a',{exact:true}).fill('9');await cell.getByLabel('indpic вес 9 a',{exact:true}).fill('0');const refreshedSets=page.waitForResponse(r=>r.url().endsWith('/api/sets?target=a'));await page.getByRole('button',{name:'Сохранить настройки a',exact:true}).click();await page.waitForFunction(()=>document.querySelector('[data-overview-target=a] .matrix-status').textContent==='Записано на хостинг',null,{timeout:60000});assert.equal(config().utm.indpic.weights['9'],0);const after=fs.readFileSync(root+'/a/config.json','utf8');await (await refreshedSets).finished();await row.getByLabel('Название Set 9',{exact:true}).waitFor();
|
||||
await row.getByLabel('ZIP Set 9',{exact:true}).setInputFiles(process.env.VALID_ZIP);await row.getByRole('button',{name:'Загрузить ZIP',exact:true}).click();await done();assert.equal(fs.readFileSync(root+'/a/config.json','utf8'),after,'replace ZIP does not change config');assert.equal(dialogs.length,1);assert.match(dialogs[0],/Заменить существующий набор 9/);assert.equal(fs.readdirSync(root+'/a/9').length,90);await page.locator('#indpic').screenshot({path:out+'/set-new-replaced.png'});assert.deepEqual(errors,[]);console.log('PASS absent set9 upload90files/no configwrites/no replaceconfirm; manual indpic9 zero Save; existing set9 ZIP replace90files/confirm/no configwrites');}finally{await browser.close();fs.writeFileSync(root+'/a/config.json',raw);fs.rmSync(root+'/a/9',{recursive:true,force:true});for(const[name,content]of snap){if(content===null)fs.rmSync(root+'/state/'+name,{force:true});else fs.writeFileSync(root+'/state/'+name,content);}}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -1,3 +1,3 @@
|
||||
/* 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);assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] button').count(),0);assert.match(await page.locator('.matrix-read-at').first().innerText(),/МСК/);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)});
|
||||
(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);assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] .matrix-remove').count(),0);assert.match(await page.locator('.matrix-read-at').first().innerText(),/МСК/);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)});
|
||||
@@ -3,6 +3,6 @@ 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.match(/^(Прочитано с хостинга|Записано на хостинг)$/),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');assert.equal(await cell(id).locator('select,button').count(),0);const rows=cell(id).locator('.matrix-variant');const changed=rows.first(),oldWeight=await changed.locator('input[type=number]').inputValue();await changed.locator('input[type=number]').fill(String(Number(oldWeight)+1));await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await rows.first().locator('input[type=number]').inputValue(),oldWeight);const choice=rows.nth(await rows.evaluateAll(ns=>ns.findIndex(n=>!n.querySelector('input[type=radio]').checked))),digit=await choice.locator('.matrix-variant-number').innerText();await choice.locator('input[type=radio]').check();await save(id);const expected=structuredClone(originals.get(id));expected.utm.indpic.default=typeof expected.utm.indpic.default==='number'?Number(digit):digit;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('.matrix-variant-number').textContent),digit);}
|
||||
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');assert.equal(await cell(id).locator('select,.matrix-remove').count(),0);const rows=cell(id).locator('.matrix-variant');const changed=rows.first(),oldWeight=await changed.locator('input[type=number]').inputValue();await changed.locator('input[type=number]').fill(String(Number(oldWeight)+1));await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await rows.first().locator('input[type=number]').inputValue(),oldWeight);const choice=rows.nth(await rows.evaluateAll(ns=>ns.findIndex(n=>!n.querySelector('input[type=radio]').checked))),digit=await choice.locator('.matrix-variant-number').innerText();await choice.locator('input[type=radio]').check();await save(id);const expected=structuredClone(originals.get(id));expected.utm.indpic.default=typeof expected.utm.indpic.default==='number'?Number(digit):digit;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('.matrix-variant-number').textContent),digit);}
|
||||
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 / static variants + existing default + weight cancel / per-column Save+reread / otherfields preserved');
|
||||
}finally{for(const [id,config]of originals){const def=String(config.utm.indpic.default);for(const row of await cell(id).locator('.matrix-variant').all())if(await row.locator('.matrix-variant-number').innerText()===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)});
|
||||
Reference in new issue
Block a user