47 lines
7.7 KiB
JavaScript
47 lines
7.7 KiB
JavaScript
// Real-browser smoke test with Node's standard library and Chromium's CDP pipe.
|
||
// Usage: node tests/browser.mjs build/workspace_debug/fpol_server
|
||
import assert from 'node:assert/strict';
|
||
import {spawn, spawnSync} from 'node:child_process';
|
||
import {mkdtempSync, mkdirSync, writeFileSync, rmSync} from 'node:fs';
|
||
import {tmpdir} from 'node:os';
|
||
import path from 'node:path';
|
||
import net from 'node:net';
|
||
const root=path.resolve(import.meta.dirname,'..');
|
||
const executable=path.resolve(process.argv[2]||'build/workspace_debug/fpol_server');
|
||
const temporary=mkdtempSync(path.join(tmpdir(),'fpol-browser-'));
|
||
const database=path.join(temporary,'fpol.sqlite');
|
||
const password='browser test password';
|
||
let server,browser;
|
||
const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||
async function until(check,description){const deadline=Date.now()+15000;while(Date.now()<deadline){const result=await check();if(result)return result;await pause(50);}throw new Error(`Timed out: ${description}`);}
|
||
function admin(args,input){const result=spawnSync(path.join(path.dirname(executable),'fpol_admin'),['--database',database,...args],{encoding:'utf8',input});assert.equal(result.status,0,result.stderr);return result.stdout;}
|
||
try{
|
||
admin(['import',path.join(root,'data/demo/catalog.json'),path.join(root,'data/demo/ratings.json')]);admin(['create-user','alice'],password+'\n');
|
||
const listener=net.createServer();await new Promise((resolve,reject)=>{listener.once('error',reject);listener.listen(0,'127.0.0.1',resolve);});const port=listener.address().port;await new Promise(resolve=>listener.close(resolve));
|
||
const origin=`http://127.0.0.1:${port}`;
|
||
let serverLog='';server=spawn(executable,['--host','127.0.0.1','--port',String(port),'--database',database,'--origin',origin,'--allow-demo','--web-root',path.join(root,'frontend/dist')]);server.stderr.on('data',data=>{serverLog+=data;});server.stdout.on('data',data=>{serverLog+=data;});
|
||
await until(()=>{if(server.exitCode!=null)throw new Error(serverLog);return serverLog.includes('Listening on');},'C++ server startup');
|
||
const flags=['--headless','--disable-gpu','--disable-background-networking','--disable-component-update','--no-first-run','--remote-debugging-pipe',`--user-data-dir=${temporary}/chrome`];
|
||
if(process.getuid?.()===0)flags.push('--no-sandbox');
|
||
browser=spawn(process.env.FPOL_CHROMIUM||'chromium',flags,{stdio:['ignore','ignore','pipe','pipe','pipe']});let browserLog='';browser.stderr.on('data',data=>{browserLog+=data;});
|
||
let counter=0,buffer='';const pending=new Map();
|
||
browser.stdio[4].on('data',chunk=>{buffer+=chunk.toString();let index;while((index=buffer.indexOf('\0'))>=0){const value=JSON.parse(buffer.slice(0,index));buffer=buffer.slice(index+1);if(value.id&&pending.has(value.id)){const promise=pending.get(value.id);pending.delete(value.id);clearTimeout(promise.timer);value.error?promise.reject(new Error(JSON.stringify(value.error))):promise.resolve(value.result);}}});
|
||
browser.on('exit',()=>{for(const promise of pending.values()){clearTimeout(promise.timer);promise.reject(new Error(`Chromium exited: ${browserLog}`));}pending.clear();});
|
||
function cdp(method,params={},sessionId){return new Promise((resolve,reject)=>{const id=++counter;const timer=setTimeout(()=>{pending.delete(id);reject(new Error(`CDP timeout: ${method}. ${browserLog}`));},15000);pending.set(id,{resolve,reject,timer});browser.stdio[3].write(JSON.stringify({id,method,params,sessionId})+'\0');});}
|
||
async function page(){const {browserContextId}=await cdp('Target.createBrowserContext');const {targetId}=await cdp('Target.createTarget',{url:'about:blank',browserContextId});const {sessionId}=await cdp('Target.attachToTarget',{targetId,flatten:true});await cdp('Page.enable',{},sessionId);await cdp('Runtime.enable',{},sessionId);
|
||
async function evaluate(expression){const result=await cdp('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true},sessionId);if(result.exceptionDetails)throw new Error(result.exceptionDetails.text+': '+result.exceptionDetails.exception?.description);return result.result.value;}
|
||
return {sessionId,evaluate,go:async(hash='')=>{await cdp('Page.navigate',{url:origin+'/'+hash},sessionId);},wait:selector=>until(()=>evaluate(`!!document.querySelector(${JSON.stringify(selector)})`),selector),click:selector=>evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`),fill:(selector,value)=>evaluate(`document.querySelector(${JSON.stringify(selector)}).value=${JSON.stringify(value)}`),text:()=>evaluate('document.body.innerText')};
|
||
}
|
||
async function signIn(tab){await tab.wait('#signin');await tab.fill('#signin [name=username]','alice');await tab.fill('#signin [name=password]',password);await tab.click('#signin button');await tab.wait('a[href="#create"]');}
|
||
const alice=await page();await alice.go();await signIn(alice);assert.match(await alice.text(),/My leagues/);
|
||
await alice.click('a[href="#create"]');await alice.wait('#create');await alice.fill('[name=name]','Election friends');await alice.fill('[name=team_name]','The Night Owls');await alice.fill('[name=catalog_id]','demo-2026');await alice.click('#create button');await alice.wait('#invite');
|
||
const league=await alice.evaluate('location.hash.split("/")[1]');await alice.click('#invite');await alice.wait('#invite-link input');const inviteUrl=await alice.evaluate('document.querySelector("#invite-link input").value');assert.match(inviteUrl,/#join\/[a-f0-9]+$/);
|
||
const bob=await page();await bob.go(inviteUrl.split('/#')[1]? '#'+inviteUrl.split('/#')[1]:'');await bob.wait('#register');await bob.fill('#register [name=username]','bob');await bob.fill('#register [name=password]',password);await bob.fill('#register [name=team_name]','Late Calls');await bob.click('#register button');await bob.wait(`a[href="#league/${league}"]`);
|
||
await alice.go(`#draft/${league}`);await alice.wait('#start');await until(()=>alice.evaluate('!document.querySelector("#start").disabled'),'draft readiness');await alice.click('#start');await alice.wait('#pause');await bob.go(`#draft/${league}`);await bob.wait('#clock');
|
||
const active=await alice.evaluate('document.querySelector("#turn-label").innerText.includes("You’re on the clock")')?alice:bob;
|
||
await until(()=>active.evaluate('!!document.querySelector(".pick-candidate:not([disabled])")'),'legal candidate buttons');await active.click('.queue-candidate:not([disabled])');await until(()=>active.evaluate('!!document.querySelector(".unqueue")'),'saved private queue');await active.click('.pick-candidate:not([disabled])');await until(()=>active.evaluate('document.querySelector("#recent").innerText.includes("house:")||document.querySelector("#recent").innerText.includes("governor:")||document.querySelector("#recent").innerText.includes("senate:")'),'recorded pick');
|
||
const artifacts=path.join(root,'build/browser-artifacts');mkdirSync(artifacts,{recursive:true});await cdp('Emulation.setDeviceMetricsOverride',{width:1365,height:900,deviceScaleFactor:1,mobile:false},alice.sessionId);writeFileSync(path.join(artifacts,'draft-desktop.png'),Buffer.from((await cdp('Page.captureScreenshot',{format:'png'},alice.sessionId)).data,'base64'));
|
||
await alice.go(`#standings/${league}`);await alice.wait('.standing');assert.match(await alice.text(),/Provisional/);await cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},alice.sessionId);assert.equal(await alice.evaluate('document.documentElement.scrollWidth <= 390'),true);writeFileSync(path.join(artifacts,'standings-mobile.png'),Buffer.from((await cdp('Page.captureScreenshot',{format:'png'},alice.sessionId)).data,'base64'));
|
||
console.log('Browser smoke passed: accounts, invitations, leagues, queues, picks, standings, responsive layout.');
|
||
}finally{browser?.kill('SIGTERM');server?.kill('SIGTERM');await pause(300);rmSync(temporary,{recursive:true,force:true});}
|