206 lines
16 KiB
JavaScript
206 lines
16 KiB
JavaScript
/* Real match-access/menu modules and native dialog/inert behavior; only API/runtime rendering I/O is synthetic. */
|
||
'use strict';
|
||
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
|
||
const root = process.argv[3] || path.join(__dirname, '..');
|
||
const workspace = path.resolve(__dirname, '../../..');
|
||
let playwright;
|
||
for (const candidate of [process.env.HOCKEY_TEST_PLAYWRIGHT, 'playwright',
|
||
process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES && path.join(process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES, 'playwright'),
|
||
'/opt/codex/runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright'].filter(Boolean)) {
|
||
try { playwright = require(candidate); break; } catch (_) {}
|
||
}
|
||
assert.ok(playwright, 'Playwright is required for the native dialog regression; set HOCKEY_TEST_PLAYWRIGHT if needed');
|
||
const asset = name => fs.readFileSync(path.join(root, name), 'utf8');
|
||
const originalMenu = asset('hockey_data/static/tournament-menu.js');
|
||
const autoInit = ` if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init, { once: true });
|
||
} else {
|
||
init();
|
||
}`;
|
||
assert.equal(originalMenu.split(autoInit).length, 2, 'Expected one menu bootstrap');
|
||
const menu = originalMenu.replace(autoInit, ` window.__accessLoadingMenu = {state, ensureShell, openGame, setMatchLoading, updateMatchLoading};
|
||
// Unrelated standings/schedule repaint I/O is not part of this integration.
|
||
loadTournamentStandings = async () => {};
|
||
loadGames = async () => {};`);
|
||
const cases = {};
|
||
let browser;
|
||
async function fixture({holdMapping = false, claimFails = false, existingController = false, emptyRosters = false, noToolbar = false} = {}) {
|
||
const localBrowser = path.join(workspace, 'audit/browser_test/chromium');
|
||
browser = await playwright.chromium.launch({
|
||
headless: true,
|
||
...(process.env.HOCKEY_TEST_CHROMIUM ? {executablePath: process.env.HOCKEY_TEST_CHROMIUM} : fs.existsSync(localBrowser) ? {executablePath: localBrowser} : {}),
|
||
env: {...process.env, ...(fs.existsSync(path.join(workspace, 'audit/browser_test/libs')) ? {LD_LIBRARY_PATH:path.join(workspace, 'audit/browser_test/libs')} : {})},
|
||
args:['--no-sandbox', '--no-zygote', '--disable-dev-shm-usage', '--disable-gpu', '--use-gl=disabled'],
|
||
});
|
||
const context = await browser.newContext({viewport:{width:1100,height:850}});
|
||
const unexpected = [], errors = [];
|
||
await context.route('**/*', route => {
|
||
if (route.request().url() === 'http://match-access-198.invalid/') return route.fulfill({contentType:'text/html',body:'<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><main><header class="runtime-topbar"><div class="runtime-actions"><button id="backgroundControl">Эфир</button></div></header><div id="workspace"></div></main></body></html>'.replace('class="runtime-topbar"',noToolbar?'class="synthetic-editor-bar"':'class="runtime-topbar"')});
|
||
unexpected.push(route.request().url()); return route.abort();
|
||
});
|
||
const page = await context.newPage(); page.setDefaultTimeout(5000);
|
||
page.on('pageerror', error => errors.push(String(error)));
|
||
await page.goto('http://match-access-198.invalid/');
|
||
for (const name of ['ui_builder/static/styles.css','hockey_data/static/tournament-menu.css','hockey_data/static/match-access.css']) await page.addStyleTag({content:asset(name)});
|
||
await page.evaluate(options => {
|
||
window.UI_BUILDER_BOOT = {mode:'runtime'};
|
||
localStorage.setItem('hockey.accountId','user-198');
|
||
localStorage.setItem('hockey.language','ru');
|
||
sessionStorage.setItem('hockey.vmix.selected_device','dev1');
|
||
window.__accessIO = []; window.__accessRoles = new Map(); window.__heldMapping = [];
|
||
window.__runtime198 = {hockey:{}};
|
||
window.UIBuilderRuntime = {
|
||
flushGameTimers:async()=>{},
|
||
getData:()=>window.__runtime198,
|
||
patchData:patch=>{window.__runtime198 = {...window.__runtime198,hockey:{...window.__runtime198.hockey,...patch.hockey}};},
|
||
};
|
||
const full = id => ({external_id:id,tournament_external_id:'t',date:'2026-10-22',is_finished:true,status:'finished',
|
||
home:{name:id+' HOME',players:options.emptyRosters?[]:[{id:id+'-h',name:'Home player'}]},
|
||
away:{name:id+' AWAY',players:options.emptyRosters?[]:[{id:id+'-a',name:'Away player'}]},referees:[]});
|
||
window.__fullAccess198 = full;
|
||
window.fetch = async (input, init={}) => {
|
||
const url = new URL(String(input),location.href), headers = new Headers(init.headers||{});
|
||
const body = init.body ? JSON.parse(init.body) : null;
|
||
window.__accessIO.push({path:url.pathname,method:init.method||'GET',body,game:headers.get('X-Hockey-Game')});
|
||
const response = (value,status=200) => new Response(JSON.stringify(value),{status,headers:{'Content-Type':'application/json'}});
|
||
const match = /^\/api\/hockey\/games\/([^/]+)\/access(?:\/(\w+))?$/.exec(url.pathname);
|
||
const access = id => {
|
||
const role = window.__accessRoles.get(id) || (options.existingController?'controller':'viewer');
|
||
return {game_id:id,participant_id:headers.get('X-Hockey-Participant'),generation:role==='controller'?2:1,revision:1,
|
||
can_control:role==='controller',role,can_claim:role!=='controller',can_resume:false,can_force_claim:false,
|
||
lease_remaining_ms:120000,owner:role==='controller'?{name:'Тестовый оператор'}:null,participants:[]};
|
||
};
|
||
if (match) {
|
||
const id=decodeURIComponent(match[1]),action=match[2]||'';
|
||
if (action==='claim' && options.claimFails) return response({detail:{message:'Не удалось принять управление'}},409);
|
||
if (action==='claim' || action==='resume') window.__accessRoles.set(id,'controller');
|
||
return response(access(id));
|
||
}
|
||
const control=/^\/api\/hockey\/games\/([^/]+)\/control$/.exec(url.pathname);
|
||
if (control) return response({game_id:control[1],revision:1,control_access:access(control[1]),timers:{},score:{home:0,away:0}});
|
||
if (/\/details(?:\/sync)?$/.test(url.pathname)) {
|
||
const id=decodeURIComponent(url.pathname.split('/')[4]);
|
||
return response({game:full(id),details:{loaded:true,synced_at:'2026-10-22T10:00:00Z'}});
|
||
}
|
||
if (url.pathname==='/api/hockey/sessions') return response({token:'session-'+body.game_external_id,
|
||
agent_assignment:body.vmix_device_id?{device_id:body.vmix_device_id,delivered:true}:null});
|
||
if (/\/apply-mapping$/.test(url.pathname)) {
|
||
if (options.holdMapping) await new Promise(resolve=>window.__heldMapping.push(resolve));
|
||
return response({ok:true,complete:true,applied:3,total:3,errors:[],missing:[],mapping_ack_waited:true});
|
||
}
|
||
if (url.pathname==='/api/hockey/agents/devices') return response({devices:[{device_id:'dev1',online:true,paired_to_me:true,active_for_account:true}]});
|
||
if (/\/select-session$/.test(url.pathname)) return response({assignment:{device_id:'dev1',delivered:true}});
|
||
if (url.pathname.startsWith('/api/hockey/tournaments/')) return response({external_id:'t',items:[]});
|
||
throw Error('Unexpected synthetic API: '+url.pathname);
|
||
};
|
||
}, {holdMapping,claimFails,existingController,emptyRosters});
|
||
await page.addScriptTag({content:asset('hockey_data/static/match-access.js')});
|
||
await page.addScriptTag({content:asset('hockey_data/static/mapping-requests.js')});
|
||
await page.addScriptTag({content:menu});
|
||
await page.evaluate(() => {
|
||
const h=window.__accessLoadingMenu;h.ensureShell();h.state.selectedId='t';h.state.selectedGameId='';
|
||
h.state.games={items:['A','B'].map(id=>{const g=window.__fullAccess198(id);delete g.home.players;delete g.away.players;return g;})};
|
||
h.state.selectedGame=null;h.state.selectedMatchDetails=null;
|
||
});
|
||
const start = async id => page.evaluate(id => {window.__openingDone=false;window.__opening = window.__accessLoadingMenu.openGame(id).then(()=>{window.__openingDone=true;});},id);
|
||
const finished = async () => {await page.waitForFunction(()=>window.__openingDone);assert.deepEqual(errors,[]);assert.deepEqual(unexpected,[]);};
|
||
const io = async () => page.evaluate(()=>window.__accessIO);
|
||
return {page,start,finished,io,errors,unexpected};
|
||
}
|
||
async function checkChoice(h) {
|
||
const {page}=h;
|
||
await page.locator('#hockeyMatchAccessDialog').waitFor({state:'visible'});
|
||
assert.equal(await page.locator('#hockeyMatchAccessDialog').evaluate(node=>node.hasAttribute('inert')),false,'The loading lock must not make the native access dialog inert');
|
||
assert.equal(await page.locator('[data-access-claim]').isEnabled(),true,'Role buttons must be enabled while choosing access');
|
||
assert.equal(await page.locator('[data-match-loading]').evaluate(node=>node.classList.contains('show')),false,'Match loader must wait for the role decision');
|
||
assert.equal((await h.io()).filter(row=>row.path==='/api/hockey/sessions').length,0,'No session/Agent assignment before the access decision');
|
||
await page.keyboard.press('Tab');
|
||
assert.equal(await page.locator('#hockeyMatchAccessDialog').evaluate(node=>node.contains(document.activeElement)),true,'Tab must remain usable inside the real native dialog');
|
||
}
|
||
cases.viewer_choice_is_interactive = async () => {
|
||
const h=await fixture({holdMapping:true});await h.start('B');await checkChoice(h);
|
||
await h.page.locator('[data-access-view]').click();
|
||
await h.page.waitForFunction(()=>window.__heldMapping.length===1);
|
||
assert.equal(await h.page.evaluate(()=>window.__openingDone),false,'Viewer waits for title-data ACK just like an operator');
|
||
await h.page.evaluate(()=>window.__heldMapping.shift()());await h.finished();
|
||
assert.equal((await h.io()).filter(row=>row.path.endsWith('/apply-mapping')).length,1,'Viewer receives title DATA on the selected own Agent');
|
||
const result=await h.page.evaluate(()=>({id:window.__accessLoadingMenu.state.selectedGameId,status:window.__accessLoadingMenu.state.matchOpeningJob.status,shown:document.querySelector('[data-match-loading]').classList.contains('show'),canControl:window.HockeyMatchAccess.canControl()}));
|
||
assert.deepEqual(result,{id:'B',status:'ready',shown:false,canControl:false});
|
||
assert.equal((await h.io()).find(row=>row.path==='/api/hockey/sessions').body.vmix_device_id,'dev1');
|
||
const denied=await h.page.evaluate(async()=>{const paths=['/api/hockey/vmix/sequence','/api/hockey/games/B/control/period','/api/hockey/games/B/control/flags'];return Promise.all(paths.map(async path=>(await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})).status));});
|
||
assert.deepEqual(denied,[403,403,403],'Viewer DATA permission must not enable on-air/state commands');
|
||
};
|
||
cases.operator_choice_waits_for_mapping = async () => {
|
||
const h=await fixture({holdMapping:true});await h.start('B');await checkChoice(h);
|
||
await h.page.locator('[data-access-claim]').click();
|
||
await h.page.waitForFunction(()=>window.__heldMapping.length===1);
|
||
assert.equal(await h.page.locator('#hockeyMatchAccessDialog').evaluate(node=>node.open),false,'Successful claim finishes the access choice');
|
||
assert.equal(await h.page.locator('[data-match-loading]').evaluate(node=>node.classList.contains('show')),true,'The roster/ACK loading lock starts after the native dialog closes');
|
||
assert.equal(await h.page.evaluate(()=>window.__openingDone),false);
|
||
assert.equal((await h.io()).find(row=>row.path==='/api/hockey/sessions').body.vmix_device_id,'dev1');
|
||
await h.page.evaluate(()=>window.__heldMapping.shift()());await h.finished();
|
||
assert.equal(await h.page.evaluate(()=>window.HockeyMatchAccess.canControl('B')),true);
|
||
assert.equal(await h.page.locator('[data-match-loading]').evaluate(node=>node.classList.contains('show')),false);
|
||
};
|
||
cases.claim_failure_can_continue_viewing = async () => {
|
||
const h=await fixture({claimFails:true});await h.start('B');await checkChoice(h);
|
||
await h.page.locator('[data-access-claim]').click();
|
||
await h.page.waitForFunction(()=>document.querySelector('[data-access-message]').textContent.includes('Не удалось принять управление'));
|
||
await checkChoice(h);await h.page.locator('[data-access-view]').click();await h.finished();
|
||
assert.equal((await h.io()).filter(row=>row.path.endsWith('/apply-mapping')).length,1);
|
||
assert.equal(await h.page.evaluate(()=>window.HockeyMatchAccess.canControl('B')),false);
|
||
};
|
||
cases.escape_continues_as_viewer = async () => {
|
||
const h=await fixture();await h.start('B');await checkChoice(h);
|
||
await h.page.keyboard.press('Escape');await h.finished();
|
||
assert.equal(await h.page.locator('#hockeyMatchAccessDialog').evaluate(node=>node.open),false);
|
||
assert.equal((await h.io()).filter(row=>row.path.endsWith('/apply-mapping')).length,1);
|
||
assert.equal(await h.page.evaluate(()=>window.HockeyMatchAccess.canControl('B')),false);
|
||
};
|
||
cases.stale_choice_cannot_open_previous_match = async () => {
|
||
const h=await fixture();await h.start('B');await checkChoice(h);
|
||
await h.page.evaluate(()=>{window.__directChoice=window.HockeyMatchAccess.selectGame('A',{waitForWorkspace:true,waitForChoice:true});});
|
||
await h.page.waitForFunction(()=>window.HockeyMatchAccess.snapshot()?.game_id==='A'&&document.querySelector('#hockeyMatchAccessDialog').open&&window.__openingDone);
|
||
assert.equal((await h.io()).filter(row=>row.path==='/api/hockey/sessions').length,0,'Superseded B must not create a session');
|
||
await h.start('A');await checkChoice(h);
|
||
await h.page.locator('[data-access-view]').click();await h.finished();await h.page.evaluate(()=>window.__directChoice);
|
||
assert.equal(await h.page.evaluate(()=>window.__accessLoadingMenu.state.selectedGameId),'A');
|
||
assert.deepEqual((await h.io()).filter(row=>row.path==='/api/hockey/sessions').map(row=>row.body.game_external_id),['A']);
|
||
};
|
||
cases.existing_controller_needs_no_new_choice = async () => {
|
||
const h=await fixture({existingController:true});await h.start('B');await h.finished();
|
||
assert.equal(await h.page.locator('#hockeyMatchAccessDialog').evaluate(node=>node.open),false);
|
||
assert.equal((await h.io()).filter(row=>row.path.endsWith('/access/claim')).length,0);
|
||
assert.equal((await h.io()).filter(row=>row.path.endsWith('/apply-mapping')).length,1);
|
||
assert.equal(await h.page.evaluate(()=>window.__accessLoadingMenu.state.matchOpeningJob.status),'ready');
|
||
};
|
||
cases.viewer_incomplete_rosters_can_continue = async () => {
|
||
const h=await fixture({emptyRosters:true});await h.start('B');await checkChoice(h);
|
||
await h.page.locator('[data-access-view]').click();await h.finished();
|
||
assert.equal(await h.page.locator('[data-match-loading]').evaluate(node=>node.dataset.status),'warning');
|
||
assert.equal((await h.io()).filter(row=>/apply-mapping|select-session/.test(row.path)).length,0);
|
||
await h.page.locator('[data-match-loading-continue]').click();
|
||
assert.equal(await h.page.locator('[data-match-loading]').evaluate(node=>node.classList.contains('show')),false);
|
||
};
|
||
cases.missing_access_toolbar_does_not_hang = async () => {
|
||
const h=await fixture({noToolbar:true});await h.start('B');await h.finished();
|
||
assert.equal(await h.page.locator('#hockeyMatchAccessDialog').count(),0);
|
||
assert.equal((await h.io()).filter(row=>row.path.endsWith('/access/claim')).length,0);
|
||
assert.equal((await h.io()).filter(row=>row.path.endsWith('/apply-mapping')).length,1);
|
||
assert.equal(await h.page.evaluate(()=>window.__accessLoadingMenu.state.matchOpeningJob.status),'ready');
|
||
};
|
||
cases.coalesced_default_selection_still_waits_for_choice = async () => {
|
||
const h=await fixture({holdMapping:true});
|
||
await h.page.evaluate(()=>{window.__defaultSelectionDone=false;window.__defaultSelection=window.HockeyMatchAccess.selectGame('B').then(()=>{window.__defaultSelectionDone=true;});});
|
||
await h.start('B');await checkChoice(h);
|
||
assert.equal(await h.page.evaluate(()=>window.__defaultSelectionDone),true,'Default callers retain non-waiting selection behavior');
|
||
await h.page.locator('[data-access-view]').click();
|
||
await h.page.waitForFunction(()=>window.__heldMapping.length===1);
|
||
await h.page.evaluate(()=>window.__heldMapping.shift()());await h.finished();
|
||
assert.equal((await h.io()).filter(row=>row.path.endsWith('/apply-mapping')).length,1);
|
||
};
|
||
(async()=>{
|
||
const name=process.argv[2];assert.equal(typeof cases[name],'function','Unknown match-access regression');
|
||
try {await cases[name]();console.log('PASS access loading: '+name);} finally {await browser?.close();}
|
||
})().catch(error=>{console.error(error);process.exitCode=1;});
|