Files
ProxMenux/tests/test_oci_update_cancellation.cjs
T

158 lines
11 KiB
JavaScript

// Execute the real WebSocket handlers and the OCI caller's actual close callback.
const assert = require('node:assert/strict'), vm = require('node:vm');
const {ts,read,compile,translator,catalogs} = require('./backup_i18n_seam.cjs');
const moduleScope = {exports:{}};
vm.runInNewContext(compile(read('lib/script-completion.ts')), moduleScope);
const completion = moduleScope.exports;
const source = ts.createSourceFile('modal.tsx', read('components/script-terminal-modal.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const handlers = []; let messageCallback,reconnectCallback;
function walk(node) {
if (ts.isBinaryExpression(node) && node.left.getText(source) === 'ws.onmessage') handlers.push(node.right);
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'getCompletionMessage') messageCallback = node.initializer.arguments[0];
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'attemptReconnect') reconnectCallback = node.initializer.arguments[0];
ts.forEachChild(node, walk);
}
walk(source);
assert.equal(handlers.length,2,'initial and reconnect handlers');
assert.ok(messageCallback);
function executable(node, file, scope) {
const local = {...scope,exports:{}};
vm.runInNewContext(compile('exports.fn = '+node.getText(file)+';'),local);
return local.exports.fn;
}
function fixture(handler, locale, optedIn=true) {
const writes=[], callbacks=[], state={closed:0};
const t=translator(locale), terminal={write:s=>writes.push(s),writeln:s=>writes.push(s)};
const shared={...completion, backgroundUpdate:undefined, isOpenRef:{current:true}, cancelledExitCode:optedIn?6:undefined,t,
completedSuccessfullyMessage:undefined,completedWithErrorMessage:undefined};
const getCompletionMessage=executable(messageCallback,source,shared);
const scope={...shared,getCompletionMessage,term:terminal,termRef:{current:terminal},
completionReceivedRef:{current:false},onCompleteRef:{current:code=>callbacks.push(code)},
setIsComplete:v=>state.complete=v,ws:{readyState:1,close:()=>state.closed++},WebSocket:{OPEN:1},
setIsWaitingNextInteraction:()=>{},waitingTimeoutRef:{current:null},clearTimeout:()=>{},
setCurrentInteraction:()=>{}};
return {receive:executable(handler,source,scope),writes,callbacks,state,t,scope};
}
let scenarios=0;
for (const handler of handlers) for (const locale of ['en','sk']) for (const frame of ['raw','json']) {
for (const [code,optedIn,kind] of [[6,true,'cancelled'],[6,false,'error'],[1,true,'error'],[0,true,'success'],[-1,true,'error']]) {
const f=fixture(handler,locale,optedIn);
const data=frame==='raw'?`\r\n[Script exited with code ${code}]\r\n`:JSON.stringify({type:'script_complete',exit_code:code});
f.receive({data});
assert.deepEqual(f.callbacks,[code]); assert.equal(f.state.closed,1);
const rendered=f.writes.join('');
if(kind==='cancelled') {
assert.ok(rendered.includes(f.t('scriptTerminal.cancelled')));
assert.ok(rendered.includes('\x1b[36m'));
assert.ok(rendered.startsWith('\x1bc'),'cancel resets the obsolete dialog in the queued output');
assert.ok(!rendered.includes('Script exited with code'));
assert.ok(!rendered.includes('\x1b[31m'));
assert.ok(!rendered.includes(f.t('scriptTerminal.completedSuccessfully')));
} else {
assert.ok(!rendered.includes('\x1bc'),'success/errors and non-opted-in scripts keep their logs');
assert.ok(rendered.includes(kind==='success'?f.t('scriptTerminal.completedSuccessfully'):f.t('scriptTerminal.completedWithError',{code})));
}
// Worker sends both forms; neither a second frame nor a heartbeat may complete twice.
f.receive({data:JSON.stringify({type:'script_complete',exit_code:code})});
f.receive({data:'{"type":"pong"}'});
assert.deepEqual(f.callbacks,[code]);assert.equal(f.state.closed,1);scenarios++;
}
for(const payload of [{type:'script_complete'},{type:'script_complete',exit_code:'invalid'}]) {
const f=fixture(handler,locale);f.receive({data:JSON.stringify(payload)});
assert.ok(f.writes.join('').includes('\x1b[31m'));
assert.ok(!f.writes.join('').includes(f.t('scriptTerminal.cancelled')));scenarios++;
}
}
for(const locale of Object.keys(catalogs)) assert.ok(translator(locale)('scriptTerminal.cancelled')!=='scriptTerminal.cancelled');
// The native CLI and the explicitly opted-in caller share the documented code6 contract.
const fs=require('node:fs');
assert.match(fs.readFileSync('oci/src/proxmenux_oci/management.py','utf8'),/^EXIT_CANCELLED = 6 /m);
assert.equal(completion.OCI_CANCELLED_EXIT_CODE,6);
const parent=ts.createSourceFile('vms.tsx',read('components/virtual-machines.tsx'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX);
let ociElement,reset,pendingRecoveryClick,registrationClick,registrationLabel;
function find(node) {
if(ts.isJsxSelfClosingElement(node) && node.tagName.getText(parent)==='ScriptTerminalModal' &&
node.attributes.properties.some(p=>p.name?.getText(parent)==='scriptPath' && p.initializer?.text?.endsWith('/oci/manage_instance.sh'))) ociElement=node;
if(ts.isCallExpression(node) && node.expression.getText(parent)==='useEffect' && node.arguments[0]?.getText(parent).includes('ociCompletionCodeRef.current = undefined')) reset=node.arguments[0];
if(ts.isJsxElement(node) && node.openingElement.tagName.getText(parent)==='Button') {
const click=node.openingElement.attributes.properties.find(p=>p.name?.getText(parent)==='onClick')?.initializer?.expression;
if(click?.getText(parent).includes('setOciAction(') && click.getText(parent).includes('action: "update"')) pendingRecoveryClick=click;
if(click?.getText(parent).includes('setOciAction(') && click.getText(parent).includes('action: "recover"')) {
registrationClick=click;
registrationLabel=node.children.find(child=>ts.isJsxExpression(child)&&child.expression)?.expression;
}
}
ts.forEachChild(node,find);
}
find(parent);assert.ok(ociElement);assert.ok(reset);assert.ok(pendingRecoveryClick);assert.ok(registrationClick);assert.ok(registrationLabel);
const attribute=name=>ociElement.attributes.properties.find(p=>p.name?.getText(parent)===name)?.initializer?.expression;
assert.equal(attribute('cancelledExitCode').getText(parent),'OCI_CANCELLED_EXIT_CODE');
(async()=>{
for(const locale of ['en','sk']) {
const scope={t:translator(locale),selectedVM:{vmid:110},applyBackup:false,applyBackupStorage:'',selectedBackupStorage:''};
scope.setOciAction=value=>scope.ociAction=value;
executable(registrationClick,parent,scope)();
assert.equal(scope.ociAction.action,'recover');assert.equal(scope.ociAction.vmid,110);
assert.equal(executable(registrationLabel,parent,scope),locale==='sk'?'Znova zaregistrovať':'Register again');
assert.equal(executable(attribute('title'),parent,scope),locale==='sk'?
'Zaregistrovať OCI aplikácie obnovené zo zálohy':'Register restored OCI applications');
scope.selectedVM={vmid:111};
assert.equal(executable(attribute('params'),parent,scope).VMID,'110','registration preserves the clicked target context');
assert.equal(executable(attribute('params'),parent,scope).ACTION,'recover','registration retains its existing global CLI action');
assert.equal(scope.t('vmLxc.ociUpdates.recover'),locale==='sk'?'Obnoviť':'Recover');
}
for(const locale of ['en','sk']) for(const pending of [false,true]) {
const scope={t:translator(locale), selectedVM:{vmid:110}, ociInstance:{pending},
applyBackup:false,applyBackupStorage:'',selectedBackupStorage:''};
scope.setOciAction=value=>scope.ociAction=value;
executable(pendingRecoveryClick,parent,scope)();
assert.equal(scope.ociAction.vmid,110);
assert.equal(scope.ociAction.action,'update','pending recovery retains the existing manage contract');
// Background refresh or another selected guest must not rename this open operation.
scope.ociInstance={pending:!pending};scope.selectedVM={vmid:999};
const title=executable(attribute('title'),parent,scope);
assert.equal(title,scope.t(pending?'vmLxc.ociUpdates.terminalTitleRecoverPrevious':'vmLxc.ociUpdates.terminalTitleUpdate'));
assert.equal(title,pending?(locale==='sk'?'Obnoviť predchádzajúcu OCI inštaláciu':'Recover the previous OCI installation'):
(locale==='sk'?'Aktualizovať OCI aplikáciu':'Update OCI application'));
const params=executable(attribute('params'),parent,scope);
assert.equal(params.ACTION,'update');assert.equal(params.VMID,'110');
assert.equal(params.KEEP_BACKUP,'');
}
for(const action of ['modify','recover']) {
const scope={t:translator('sk'),ociAction:{vmid:110,action,recoverPrevious:true}};
assert.equal(executable(attribute('title'),parent,scope),scope.t(action==='recover'?
'vmLxc.ociUpdates.terminalTitleRecover':'vmLxc.ociUpdates.terminalTitleModify'),
'registration and editing retain their own titles');
}
for(const frame of ['raw','json']) {
const f=fixture(handlers[0],'sk'),timers=[];
const old={readyState:3,onclose:()=>f.callbacks.push(-1),onerror:()=>{},onmessage:()=>{},close(){this.onclose?.({})}};
const ref={current:old};
class Socket{static OPEN=1;constructor(){this.readyState=1}close(){this.onclose?.({})}}
const scope={...f.scope,isOpen:true,isComplete:false,reconnectAttemptsRef:{current:0},reconnectTimeoutRef:{current:null},
wsRef:ref,WebSocket:Socket,sessionIdRef:{current:'fixture'},paramsRef:{current:{}},keepAliveIntervalRef:{current:null},
scriptPath:'/fixture/oci',setConnectionStatus:()=>{},getScriptWebSocketUrl:()=> 'ws://fixture',getTicketedWsUrl:async url=>url,
setTimeout:fn=>{timers.push(fn);return 1},clearTimeout:()=>{}};
executable(reconnectCallback,source,scope)();await timers[0]();
assert.equal(old.onclose,null,'replaced connection cannot emit an unknown result');
assert.equal(old.onmessage,null,'late result from replaced connection cannot win');
ref.current.onmessage({data:frame==='raw'?'\r\n[Script exited with code 6]\r\n':JSON.stringify({type:'script_complete',exit_code:6})});
assert.deepEqual(f.callbacks,[6]);
}
for(const action of ['update','modify']) for(const code of [6,0,1,undefined]) {
const calls=[],row={vmid:110,action},saved=JSON.stringify(row),ref={current:undefined};
const scope={...completion,ociAction:row,ociCompletionCodeRef:ref,
setOciAction:value=>calls.push(['close',value]),fetchApi:(url,options)=>{calls.push(['fetch',url,options]);return Promise.resolve({});},
invalidateLxcApps:vmid=>calls.push(['invalidate',vmid]),vmModalCacheRef:{current:{mountPoints:new Map()}},
fetchOciInstance:vmid=>calls.push(['instance',vmid]),fetchMountPoints:vmid=>calls.push(['mounts',vmid]),mutate:()=>calls.push(['mutate'])};
executable(attribute('onComplete'),parent,scope)(code);
executable(attribute('onClose'),parent,scope)();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(JSON.stringify(row),saved);
if(code===6) assert.deepEqual(calls,[['close',null]],'cancel has zero registry/API/cache calls');
else assert.equal(calls.filter(c=>c[0]==='fetch'&&c[2].method==='POST').length,1,'preserve refresh after non-cancel');
executable(reset,parent,scope)();assert.equal(ref.current,undefined,'next modal cannot inherit cancellation');
}
console.log(`PASS: ${scenarios} real terminal frame scenarios; actual OCI close callbacks, code6 scoping, reset and zero cancel probes`);
})().catch(error=>{console.error(error);process.exitCode=1});