feat(admin): compact UTM percentages and shared notes
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 22:59:37 +03:00
1 parent 4fc4fd8162
commit f2097d4800
10 files changed
+240 -16

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
/* preland#23: real compact overview and CAS notes. Runtime configs/jobs stay unchanged. */
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 context=await browser.newContext({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});const page=await context.newPage();page.on('dialog',d=>d.accept());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()});});
const output=process.env.UI_OUTPUT||'/tmp';let initialNote=null,csrf='',primaryError=null;
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 row=()=>page.locator('tr[data-utm-note=reg]'),textarea=()=>row().getByLabel('Заметка reg',{exact:true});
const enterEdit=async()=>{await row().getByRole('button',{name:/(?:Добавить|Редактировать) заметку reg/}).click();await textarea().waitFor();};
const withDelayedNotesGet=async operation=>{
let release,ready;const gate=new Promise(resolve=>release=resolve),captured=new Promise(resolve=>ready=resolve);let held=false;
const handler=async route=>{if(route.request().method()==='GET'&&!held){held=true;const response=await route.fetch();ready();await gate;await route.fulfill({response});}else await route.continue();};
await page.route('**/api/utm-notes',handler);
try{await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);await page.evaluate(()=>document.querySelector('#overview-refresh').click());await Promise.race([captured,new Promise((_,reject)=>setTimeout(()=>reject(Error('GET notes was not captured')),5000))]);await operation();release();await page.waitForFunction(()=>![...document.querySelectorAll('tr[data-utm-note] [role=status]')].some(n=>n.textContent.includes('Загрузка заметок')));}
finally{release();await page.unroute('**/api/utm-notes',handler);}
};
const postStartThenOldGet=async()=>{
let releasePost,releaseGet,postReady,getReady;const postGate=new Promise(resolve=>releasePost=resolve),getGate=new Promise(resolve=>releaseGet=resolve),postSeen=new Promise(resolve=>postReady=resolve),getSeen=new Promise(resolve=>getReady=resolve);let heldPost=false,heldGet=false;
const handler=async route=>{if(route.request().method()==='POST'&&!heldPost){heldPost=true;postReady();await postGate;await route.continue();}else if(route.request().method()==='GET'&&!heldGet){heldGet=true;const response=await route.fetch();getReady();await getGate;await route.fulfill({response});}else await route.continue();};
await page.route('**/api/utm-notes',handler);
try{await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);const posted=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();await Promise.race([postSeen,new Promise((_,reject)=>setTimeout(()=>reject(Error('POST notes was not captured')),5000))]);await page.evaluate(()=>document.querySelector('#overview-refresh').click());await Promise.race([getSeen,new Promise((_,reject)=>setTimeout(()=>reject(Error('GET during POST was not captured')),5000))]);releasePost();const response=await(await posted).json();assert.ok(response.ok,response.error);await textarea().waitFor({state:'hidden'});releaseGet();await page.waitForFunction(()=>![...document.querySelectorAll('tr[data-utm-note] [role=status]')].some(n=>n.textContent.includes('Загрузка заметок')));}
finally{releasePost();releaseGet();await page.unroute('**/api/utm-notes',handler);}
};
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});
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-percent').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');}}}
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+1);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';
await withDelayedNotesGet(async()=>{await enterEdit();await textarea().fill(newer);await save();});
await row().getByText(newer,{exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,newer,'late GET cannot roll back successful Save');
const postFirst=newer+'\nPOST начат раньше GET';await enterEdit();await textarea().fill(postFirst);await postStartThenOldGet();await row().getByText(postFirst,{exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,postFirst,'GET started during Save cannot roll back committed result');
await enterEdit();const conflictDraft=postFirst+'\nМой конфликтный черновик';await textarea().fill(conflictDraft);const current=(await request('utm-notes')).notes.reg;const external='Внешняя заметка '+Date.now();await request('utm-notes',{marker:'reg',text:external,version:current.version});
const conflictResponse=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();assert.equal((await conflictResponse).status(),409);assert.equal(await textarea().inputValue(),conflictDraft);await row().getByRole('button',{name:'Загрузить сохранённую заметку reg',exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,external);await withDelayedNotesGet(async()=>{const latest=(await request('utm-notes')).notes.reg;const finalExternal=external+' — новая версия';await request('utm-notes',{marker:'reg',text:finalExternal,version:latest.version});await row().getByRole('button',{name:'Загрузить сохранённую заметку reg',exact:true}).click();await page.waitForFunction(text=>document.querySelector('textarea[aria-label="Заметка reg"]').value===text,finalExternal);});assert.equal(await textarea().inputValue(),external+' — новая версия','late GET cannot roll back explicit server reload');
const networkDraft=external+'\nВосстановление связи';await textarea().fill(networkDraft);await context.setOffline(true);await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();await row().locator('[role=status]').getByText(/Связь|соединени|ошибк/i).waitFor();assert.equal(await textarea().inputValue(),networkDraft);await context.setOffline(false);await save();assert.equal((await request('utm-notes')).notes.reg.text,networkDraft);
await page.screenshot({path:output+'/notes-desktop.png',fullPage:true});await page.setViewportSize({width:390,height:844});await enterEdit();await textarea().fill('Мобильный черновик\nНесколько строк');await page.screenshot({path:output+'/notes-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.equal(await textarea().evaluate(n=>getComputedStyle(n).resize),'vertical');await row().getByRole('button',{name:'Отменить заметку reg',exact:true}).click();
for(const before of snapshots){const after=await page.request.get('/api/config?target='+before.id).then(r=>r.json());if(before.ok){assert.ok(after.ok,after.error);assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);}}assert.deepEqual((await request('jobs')).jobs.map(j=>j.id).sort(),jobsBefore);assert.deepEqual(errors,[]);fs.writeFileSync(output+'/notes-evidence.json',JSON.stringify({configVersions:snapshots.filter(r=>r.ok).map(r=>({id:r.id,version:r.version})),jobsUnchanged:true,pageErrors:errors,console:consoleMessages},null,2));console.log('PASS actual compact percentages / notes input-save-reload / cursor survives refresh / CAS conflict explicit reload / offline recovery / desktop mobile / runtime jobs unchanged');
}catch(error){primaryError=error;}finally{const cleanup=[];try{await context.setOffline(false);if(initialNote){if(!(await textarea().isVisible()))await enterEdit();await textarea().fill(initialNote.text);const latest=(await request('utm-notes')).notes.reg;await request('utm-notes',{marker:'reg',text:initialNote.text,version:latest.version});}}catch(error){cleanup.push(error);}try{await browser.close();}catch(error){cleanup.push(error);}if(primaryError&&cleanup.length)throw new AggregateError([primaryError,...cleanup],'notes scenario failed; separate cleanup errors',{cause:primaryError});if(primaryError)throw primaryError;if(cleanup.length)throw new AggregateError(cleanup,'notes cleanup failed');}
})().catch(error=>{console.error(error);process.exit(1);});
+1 -1
View File
@@ -35,7 +35,7 @@ const fs = require('node:fs');
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('textarea').count(),0,'JSON textarea absent');
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});
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Notes,Problem,Store};
function notesCheck(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo "PASS {$message}\n";}
function notesReject(callable $callback,int $status,string $message): void {try{$callback();}catch(Problem $e){notesCheck($e->status===$status,$message);return;}throw new RuntimeException('Accepted invalid operation: '.$message);}
notesCheck(class_exists(Notes::class),'Notes service is available');
$root=sys_get_temp_dir().'/utm-notes-'.bin2hex(random_bytes(8));$store=new Store($root);$notes=new Notes($store);$markers=['indpic','theme','degr','reg','banch','tp','cons','focus'];
$runtime=$root.'/runtime.json';file_put_contents($runtime,'{"host":"untouched.example","utm":{}}');$runtimeHash=hash_file('sha256',$runtime);
$all=$notes->all();notesCheck(array_keys($all)===$markers&&count(array_filter($all,static fn($n)=>$n['text']===''&&$n['saved_at']===null&&is_string($n['version'])))===8,'GET contract provides all eight empty notes with stable versions');
notesCheck(!file_exists($root.'/utm-notes.json')&&$notes->all()===$all,'initial read does not create state or regenerate versions');
$text="Общее описание для всех доменов\n<script>alert('literal')</script>\tпример\r\n😀";
$saved=$notes->save(['marker'=>'theme','text'=>$text,'version'=>$all['theme']['version']]);notesCheck($saved['text']===$text&&$saved['version']!==$all['theme']['version']&&is_string($saved['saved_at']),'save stores exact multiline Unicode plaintext with new revision and timestamp');
notesCheck((new Notes(new Store($root)))->all()['theme']===$saved,'fresh service reload returns exact saved note and version');
notesReject(fn()=>$notes->save(['marker'=>'theme','text'=>'stale','version'=>$all['theme']['version']]),409,'stale per-note version conflicts instead of overwriting');
$indpic=$notes->save(['marker'=>'indpic','text'=>'Independent note','version'=>$all['indpic']['version']]);notesCheck($notes->all()['theme']===$saved&&$indpic['text']==='Independent note','another marker can save its original revision without losing sibling note');
$cleared=$notes->save(['marker'=>'theme','text'=>'','version'=>$saved['version']]);notesCheck($cleared['text']===''&&$cleared['version']!==$all['theme']['version']&&$cleared['version']!==$saved['version'],'explicit clear keeps a new revision and prevents ABA');
notesReject(fn()=>$notes->save(['marker'=>'theme','text'=>'old initial','version'=>$all['theme']['version']]),409,'initial version remains stale after save then clear');
$large=str_repeat('😀',10000);$max=$notes->save(['marker'=>'cons','text'=>$large,'version'=>$all['cons']['version']]);notesCheck($max['text']===$large,'limit counts 10000 Unicode characters rather than UTF8 bytes');
foreach([['marker'=>'unknown','text'=>'x','version'=>'x'],['marker'=>'registration','text'=>'x','version'=>'x'],['marker'=>'theme','text'=>1,'version'=>'x'],['marker'=>'theme','text'=>'x','version'=>null],['marker'=>'theme','text'=>'x'],['marker'=>'theme','text'=>'x','version'=>'x','domain'=>'evil.example'],['marker'=>['theme'],'text'=>'x','version'=>'x'],['marker'=>'theme','text'=>str_repeat('a',10001),'version'=>'x'],['marker'=>'theme','text'=>"invalid\xFF",'version'=>'x'],['marker'=>'theme','text'=>"NUL\0",'version'=>'x'],['marker'=>'theme','text'=>"DEL\x7f",'version'=>'x'],['marker'=>'theme','text'=>"C1\u{0085}",'version'=>'x']] as $invalid)notesReject(fn()=>$notes->save($invalid),422,'invalid marker, fields, types, length, UTF8 or controls rejected');
$pids=[];$snapshot=$notes->all();foreach(['degr','tp'] as $marker){$pid=pcntl_fork();if($pid===0){try{(new Notes(new Store($root)))->save(['marker'=>$marker,'text'=>'parallel-'.$marker,'version'=>$snapshot[$marker]['version']]);exit(0);}catch(Throwable){exit(2);}}$pids[]=$pid;}foreach($pids as $pid){pcntl_waitpid($pid,$status);notesCheck(pcntl_wexitstatus($status)===0,'parallel unrelated note save succeeds');}notesCheck($notes->all()['degr']['text']==='parallel-degr'&&$notes->all()['tp']['text']==='parallel-tp','global file lock preserves concurrent writes to different notes');
$snapshot=$notes->all();$pids=[];foreach(['first','second'] as $text){$pid=pcntl_fork();if($pid===0){try{(new Notes(new Store($root)))->save(['marker'=>'reg','text'=>$text,'version'=>$snapshot['reg']['version']]);exit(0);}catch(Problem $e){exit($e->status===409?9:2);}}$pids[]=$pid;}$codes=[];foreach($pids as $pid){pcntl_waitpid($pid,$status);$codes[]=pcntl_wexitstatus($status);}sort($codes);notesCheck($codes===[0,9],'concurrent same-note saves produce one success and one conflict');
notesCheck((fileperms($root.'/utm-notes.json')&0777)===0600,'notes state is private 0600');$good=file_get_contents($root.'/utm-notes.json');
foreach(['{broken','null','[]','{"unexpected":{}}',str_replace('Independent note','bad\\u0000text',$good)] as $bad){file_put_contents($root.'/utm-notes.json',$bad);notesReject(fn()=>$notes->all(),503,'corrupt private note state fails closed');}file_put_contents($root.'/utm-notes.json',$good);
file_put_contents($root.'/utm-notes.json','null');notesReject(fn()=>$notes->save(['marker'=>'indpic','text'=>'must not reset','version'=>$indpic['version']]),503,'write never resets corrupt note state');notesCheck(file_get_contents($root.'/utm-notes.json')==='null','failed corrupt-state write leaves evidence intact');
unlink($root.'/utm-notes.json');symlink($runtime,$root.'/utm-notes.json');notesReject(fn()=>$notes->all(),503,'note symlink read fails closed');notesReject(fn()=>$notes->save(['marker'=>'indpic','text'=>'must not follow','version'=>$indpic['version']]),503,'note symlink write fails closed');unlink($root.'/utm-notes.json');file_put_contents($root.'/utm-notes.json',$good);chmod($root.'/utm-notes.json',0600);
notesCheck(hash_file('sha256',$runtime)===$runtimeHash&&glob($root.'/jobs/*.json')===[]&&!file_exists($root.'/targets.json'),'notes never modify runtime, targets or jobs');
// Настоящий HTTP-контракт, отдельный PHP server, обычный auth/CSRF.
$env=getenv();$env['ADMIN_STATE_DIR']=$root;$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='notes-fixture';$env['ADMIN_PASSWORD_HASH']=password_hash('notes-fixture-password',PASSWORD_DEFAULT);unset($env['PHP_CLI_SERVER_WORKERS']);$port=random_int(19000,29000);$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);fclose($pipes[0]);$cookie='';
function notesRequest(string $method='GET',?array $data=null,?string $csrf=null,string $path='/api/utm-notes'): 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'=>$method,'header'=>implode("\r\n",$headers),'content'=>$data===null?'':json_encode((object)$data,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);$h=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$h[0]??'',$m);foreach($h as $line)if(str_starts_with(strtolower($line),'set-cookie: '))$cookie=explode(';',substr($line,12))[0];return [(int)($m[1]??0),json_decode($raw,true,64,JSON_THROW_ON_ERROR)];}
try{$until=microtime(true)+5;do{$s=@fsockopen('127.0.0.1',$port);if($s){fclose($s);break;}usleep(20000);}while(microtime(true)<$until);
[$status]=notesRequest();notesCheck($status===401,'HTTP notes require authentication');[$status,$login]=notesRequest('POST',['username'=>'notes-fixture','password'=>'notes-fixture-password'],null,'/api/login');notesCheck($status===200,'HTTP fixture login succeeds');$csrf=$login['csrf'];
[$status,$body]=notesRequest();notesCheck($status===200&&array_keys($body['notes'])===$markers,'HTTP GET returns eight global note objects without target');$version=$body['notes']['focus']['version'];
[$status]=notesRequest('POST',['marker'=>'focus','text'=>'csrf','version'=>$version]);notesCheck($status===403,'HTTP notes mutation requires CSRF');
[$status,$body]=notesRequest('POST',['marker'=>'focus','text'=>'HTTP global note','version'=>$version],$csrf);notesCheck($status===200&&$body['note']['text']==='HTTP global note','HTTP POST saves exact note response');$httpSaved=$body['note'];
[$status,$body]=notesRequest();notesCheck($body['notes']['focus']===$httpSaved,'HTTP reload restores note across requests');
[$status]=notesRequest('POST',['marker'=>'focus','text'=>'stale HTTP','version'=>$version],$csrf);notesCheck($status===409,'HTTP stale editor gets 409 conflict');
[$status]=notesRequest('POST',['marker'=>'focus','text'=>'x','version'=>$httpSaved['version'],'target'=>'any'],$csrf);notesCheck($status===422,'HTTP note refuses domain scope and unknown top-level fields');
[$status]=notesRequest('POST',['marker'=>'focus','text'=>false,'version'=>$httpSaved['version']],$csrf);notesCheck($status===422,'HTTP strict note value types return 422');
file_put_contents($root.'/utm-notes.json','{bad');[$status]=notesRequest();notesCheck($status===503,'HTTP corrupt file returns safe 503');file_put_contents($root.'/utm-notes.json',$good);
notesCheck(hash_file('sha256',$runtime)===$runtimeHash&&glob($root.'/jobs/*.json')===[]&&!file_exists($root.'/targets.json'),'HTTP notes leave runtime, jobs and target registry unchanged');
[$status]=notesRequest('POST',[],$csrf,'/api/logout');notesCheck($status===200,'HTTP note fixture logs out');
}finally{proc_terminate($server);proc_close($server);fclose($log);}
echo "UTM notes contract complete\n";