118 lines
11 KiB
JavaScript
118 lines
11 KiB
JavaScript
/* Real production readiness lifecycle/render helpers with controlled I/O edges. */
|
||
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),assert=require('node:assert/strict');
|
||
const source=fs.readFileSync(path.join(__dirname,'../hockey_data/static/agent-diagnostics.js'),'utf8');
|
||
const tick=async()=>{for(let i=0;i<35;i++)await Promise.resolve();};
|
||
const plain=value=>JSON.parse(JSON.stringify(value));
|
||
function definition(name){
|
||
const match=new RegExp('(^|\\n)([ \\t]*)(?:async )?function '+name+'\\(').exec(source);assert.ok(match,name);
|
||
const start=match.index+match[1].length,end=source.indexOf('\n'+match[2]+'}',start);assert.ok(end>start,name);
|
||
return source.slice(start,end+match[2].length+2);
|
||
}
|
||
function payload(device='devA',game='gameA'){
|
||
const labels={selection:'Выбранный контекст',assignment:'Назначение матча',agent:'Agent',vmix:'vMix',inventory:'Структура проекта',publication:'Mapping',targets:'Цели Mapping',delivery:'Обработка и ACK',pending:'Ожидающие команды'};
|
||
const details={agent:{heartbeat_age_seconds:1},inventory:{current_connection:true,scanned_at:'2026-10-21T12:00:00Z',input_count:10,field_count:50},publication:{version:3},targets:{target_count:20,matched_count:20,missing_count:0},delivery:{confirmed:20,full_profile:true,completed_at:'2026-10-21T12:00:00Z',errors_count:0},pending:{count:0,mapping_busy:false}};
|
||
return {ok:true,device_id:device,game_id:game,checked_at:'2026-10-21T12:00:00Z',status:'ready',checks:Object.entries(labels).map(([key,label])=>({key,label,status:'ready',message:'Подтверждено',details:details[key]||{}}))};
|
||
}
|
||
function harness(){
|
||
const values=new Map([['hockey.accountId','accountA'],['hockey.selectedGameId','oldGame'],['hockey.vmix.selected_device','oldDevice']]);
|
||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,String(value))};
|
||
let game='gameA',device='devA',now=0,timerId=0;const timers=new Map(),requests=[];
|
||
const document={hidden:false};let renders=0;
|
||
const setTimer=(fn,ms)=>{const id=++timerId;timers.set(id,{at:now+ms,fn});return id;},clearTimer=id=>timers.delete(id);
|
||
const window={HockeyAgentRuntime:{currentDeviceId:()=>device},UIBuilderRuntime:{getData:()=>({hockey:{selected_game:game?{external_id:game}:null}})},setTimeout:setTimer,clearTimeout:clearTimer};
|
||
const context={window,document,runtimeStorage:storage,AbortController,URLSearchParams,Date,console,setTimeout:setTimer,clearTimeout:clearTimer,
|
||
render:()=>renders++,fetch:(url,options)=>new Promise((resolve,reject)=>{
|
||
const row={url,options,ignoreAbort:false,resolve:body=>resolve({ok:true,status:200,json:async()=>body}),fail:(status,detail)=>resolve({ok:false,status,json:async()=>({detail})})};requests.push(row);
|
||
options.signal?.addEventListener('abort',()=>{if(!row.ignoreAbort)reject(new DOMException('Aborted','AbortError'));},{once:true});
|
||
if(url==='/api/hockey/system/diagnostics')row.resolve({ok:true,database:{ok:true},devices:[]});
|
||
})};
|
||
vm.createContext(context);
|
||
const preamble=source.slice(source.indexOf(' const state ='),source.indexOf(' async function api('));
|
||
const names=['api','activeDeviceId','readinessSelection','invalidateReadiness','syncReadinessSelection','normalizedReadiness','readinessDetails','renderReadiness','loadReadiness','load','stopRequests','schedule','selectionChanged','formatClock','formatReadinessTime'];
|
||
vm.runInContext(preamble+'\n'+names.map(definition).join('\n'),context);
|
||
const state=vm.runInContext('state',context);
|
||
return {context,state,window,document,storage,timers,requests,renders:()=>renders,
|
||
readiness:()=>requests.filter(row=>row.url.startsWith('/api/hockey/system/readiness?')),
|
||
select:(nextGame,nextDevice)=>{game=nextGame;device=nextDevice;},
|
||
advance:async ms=>{now+=ms;for(const[id,timer]of [...timers])if(timer.at<=now){timers.delete(id);timer.fn();}await tick();}};
|
||
}
|
||
const cases={};
|
||
cases.closed_bootstrap=async()=>{
|
||
const h=harness();await h.context.loadReadiness();assert.equal(h.requests.length,0);
|
||
await h.context.load(true,{bootstrap:true});assert.equal(h.requests.length,1);assert.equal(h.readiness().length,0,'Bootstrap added a readiness request');
|
||
h.context.selectionChanged();h.context.schedule();await tick();assert.equal(h.requests.length,1);assert.equal(h.timers.size,0);
|
||
h.state.open=true;await tick();const work=h.context.loadReadiness();h.readiness()[0].resolve(payload());await work;
|
||
assert.equal(h.state.readinessPayload.status,'ready');
|
||
h.state.open=false;h.context.stopRequests();h.context.schedule();assert.equal(h.state.readinessPayload,null);
|
||
h.context.selectionChanged();await tick();assert.equal(h.readiness().length,1,'Closed panel refreshed readiness');
|
||
};
|
||
cases.coalesce_timeout=async()=>{
|
||
const h=harness();h.state.open=true;
|
||
const first=h.context.loadReadiness(),second=h.context.loadReadiness();assert.equal(h.readiness().length,1);
|
||
h.readiness()[0].resolve(payload());await Promise.all([first,second]);assert.equal(h.state.readinessPayload.status,'ready');
|
||
const slow=h.context.loadReadiness();await h.advance(10001);await slow;
|
||
assert.equal(h.state.readinessPayload,null,'Timeout left an old green payload');
|
||
assert.match(h.state.readinessError,/Время проверки истекло/);assert.equal(h.state.readinessLoading,false);assert.equal(h.timers.size,0);
|
||
assert.ok(!h.context.renderReadiness().includes('hockey-readiness-status ready'));
|
||
};
|
||
cases.aba_stale=async()=>{
|
||
const h=harness();h.state.open=true;
|
||
const oldA=h.context.loadReadiness();h.readiness()[0].ignoreAbort=true;
|
||
h.select('gameB','devB');h.context.selectionChanged();await tick();h.readiness()[1].ignoreAbort=true;
|
||
h.select('gameA','devA');h.context.selectionChanged();await tick();
|
||
assert.equal(h.readiness().length,3);assert.equal(h.state.readinessPayload,null);
|
||
h.readiness()[0].resolve(payload());await oldA;await tick();
|
||
assert.equal(h.state.readinessPayload,null,'Old A response survived A→B→A generation fence');assert.equal(h.state.readinessLoading,true);
|
||
h.readiness()[1].resolve(payload('devB','gameB'));await tick();assert.equal(h.state.readinessPayload,null);
|
||
h.readiness()[2].resolve(payload());await tick();assert.equal(h.state.readinessPayload.status,'ready');
|
||
h.select('gameA','');h.context.selectionChanged();assert.equal(h.context.activeDeviceId(),'','Explicit empty Agent fell back to stale storage');
|
||
assert.equal(h.state.readinessPayload,null);assert.equal(h.readiness().length,3);assert.match(h.context.renderReadiness(),/Agent: <b>не выбран/);
|
||
h.select('','devA');h.context.selectionChanged();assert.equal(h.context.readinessSelection().gameId,'','Explicit empty match fell back to storage');
|
||
assert.equal(h.readiness().length,3);
|
||
};
|
||
cases.account_hide_close=async()=>{
|
||
const h=harness();h.state.open=true;const old=h.context.loadReadiness();h.readiness()[0].ignoreAbort=true;
|
||
h.storage.setItem('hockey.accountId','accountB');h.context.selectionChanged();await tick();
|
||
assert.equal(h.state.readinessPayload,null);assert.equal(h.readiness().length,2);
|
||
h.readiness()[0].resolve(payload());await old;assert.equal(h.state.readinessPayload,null,'Previous-account payload rendered');
|
||
h.document.hidden=true;h.context.stopRequests();h.context.schedule();await tick();
|
||
assert.equal(h.state.readinessPayload,null);assert.equal(h.state.readinessLoading,false);assert.equal(h.timers.size,0);
|
||
const count=h.readiness().length;await h.context.loadReadiness();assert.equal(h.readiness().length,count,'Hidden panel requested readiness');
|
||
h.document.hidden=false;h.context.selectionChanged();await tick();assert.equal(h.readiness().length,count+1);
|
||
h.state.open=false;h.context.stopRequests();h.context.schedule();await tick();
|
||
assert.equal(h.timers.size,0);assert.equal(h.state.readinessPayload,null);
|
||
};
|
||
cases.errors_payload=async()=>{
|
||
const h=harness();h.state.open=true;
|
||
let work=h.context.loadReadiness();h.readiness()[0].fail(404,'foreign account details');await work;
|
||
assert.equal(h.state.readinessPayload,null);assert.match(h.state.readinessError,/недоступен для этого аккаунта/);assert.ok(!h.state.readinessError.includes('foreign account details'));
|
||
work=h.context.loadReadiness();h.readiness()[1].fail(503,'server unavailable');await work;assert.match(h.state.readinessError,/server unavailable/);
|
||
work=h.context.loadReadiness();h.readiness()[2].resolve(payload('anotherDevice'));await work;
|
||
assert.equal(h.state.readinessPayload,null);assert.match(h.state.readinessError,/не подтверждает выбранный матч/);
|
||
work=h.context.loadReadiness();const incomplete=payload();incomplete.checks.pop();h.readiness()[3].resolve(incomplete);await work;
|
||
assert.equal(h.state.readinessPayload,null);assert.match(h.state.readinessError,/Не все проверки/);
|
||
work=h.context.loadReadiness();const invalid=payload();invalid.ok='true';h.readiness()[4].resolve(invalid);await work;assert.equal(h.state.readinessPayload,null);
|
||
for(const[key,field,value]of [['delivery','confirmed',true],['delivery','full_profile','true'],['inventory','current_connection','true'],['pending','mapping_busy','false']]){
|
||
const input=payload();input.checks.find(check=>check.key===key).details[field]=value;
|
||
assert.equal(h.context.normalizedReadiness(input,h.context.readinessSelection()).status,'unknown','Truthy strings became green');
|
||
}
|
||
};
|
||
cases.render_readonly=async()=>{
|
||
const h=harness();h.state.open=true;const work=h.context.loadReadiness();const input=payload();
|
||
input.checks[0].message='<img src=x onerror=alert(1)>';input.checks[0].label='<script>unsafe</script>';
|
||
input.checks.find(check=>check.key==='delivery').status='error';input.status='ready';h.readiness()[0].resolve(input);await work;
|
||
assert.equal(h.state.readinessPayload.status,'error','Client promoted failed checks to green');
|
||
const html=h.context.renderReadiness();assert.ok(html.includes('<img'));assert.ok(!html.includes('<img'));assert.ok(!html.includes('<script>'));
|
||
assert.match(html,/Актуальность данных внешних источников.*не подтверждается/);assert.match(html,/визуально.*Countdown/);
|
||
assert.match(html,/21\.10\.2026.*15:00:00.*МСК/,'Date/time must identify old observations in journal timezone');
|
||
const delivery=input.checks.find(check=>check.key==='delivery');delivery.details.full_profile=false;
|
||
assert.match(h.context.readinessDetails(delivery),/>Обработка и ACK:/);assert.ok(!h.context.readinessDetails(delivery).includes('Полная обработка'));
|
||
assert.ok(!html.includes('hockey-readiness-status ready'));
|
||
h.state.readinessLoading=true;const refreshing=h.context.renderReadiness();
|
||
assert.equal((refreshing.match(/data-readiness-check=/g)||[]).length,9,'Refreshing collapsed the readable previous snapshot');
|
||
assert.match(refreshing,/Показан предыдущий результат/);assert.match(refreshing,/is-refreshing/);assert.ok(!refreshing.includes('hockey-readiness-status ready'));
|
||
assert.ok(h.requests.every(row=>row.url.startsWith('/api/hockey/system/readiness?')||row.url==='/api/hockey/system/diagnostics'));
|
||
assert.ok(h.requests.every(row=>!row.options.method||row.options.method==='GET'),'Readiness sent a mutation');
|
||
};
|
||
(async()=>{const name=process.argv[2];assert.ok(cases[name],name);await cases[name]();console.log('PASS readiness frontend: '+name);})().catch(error=>{console.error(error);process.exitCode=1;});
|