Files
hockey_new/tests/build198_match_access_loading.cjs

206 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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;});