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