Qualify candidate media playback without changing live dashboard files

This commit is contained in:
archipelago
2026-10-06 04:40:59 -04:00
parent c78b6021c3
commit 11f016a944
2 changed files with 49 additions and 6 deletions
+12
View File
@@ -234,3 +234,15 @@ local-network permission because synthetic responses lack normal address-space
metadata. That permission is confined to the isolated loopback test context;
no app origin policy or live browser settings were weakened. These fixture
results cannot replace final deployed-node/companion acceptance.
The desktop timeout was identified from a failure screenshot: a visible CPU-load
notification covered the session Close button. The repeat used the notification's
normal dismiss button, then passed the entire desktop sequence. Mobile and
desktop candidate checks now pass hidden playback, pause/resume, reopening the
same iframe and Stop. Logs: `/tmp/archy-integrated-v4v-browser-origin-4.log`
(mobile pass, earlier desktop obstruction) and
`/tmp/archy-integrated-v4v-desktop-2.log` (desktop pass). No forced clicks or
production notification suppression were used. Production UI rebuild including
the new card footers is running; signed-install and physical companion gates
remain open.
+37 -6
View File
@@ -1,6 +1,8 @@
// Real deployed dashboard + disposable V4V media. Catalog/package metadata is
// a browser-only fixture: this does NOT qualify release signatures or installs.
const fs = require('node:fs');
const path = require('node:path');
const uiDirectory = process.env.ARCHY_TEST_UI_DIR ? fs.realpathSync(process.env.ARCHY_TEST_UI_DIR) : null;
const { chromium, expect } = require('../../neode-ui/node_modules/@playwright/test');
const cdp = process.env.ARCHY_TEST_CDP;
const sessionFile = process.env.ARCHY_TEST_SESSION_FILE;
@@ -10,21 +12,49 @@ const appOrigin = new URL(process.env.V4V_TEST_ORIGIN || 'http://127.0.0.1:32908
if (!cdp || !sessionFile || !passwordFile) throw Error('CDP endpoint, private session file and fixture password file are required');
if (new URL(origin).hostname !== new URL(appOrigin).hostname) throw Error('Fixture must be on the same hostname as the dashboard');
(async()=>{const browser=await chromium.connectOverCDP(cdp),contexts=[];
try{for(const width of [390,1440]){
try{for(const width of (process.env.ARCHY_TEST_WIDTHS || '390,1440').split(',').map(Number)){
if(!Number.isInteger(width)||width<320||width>2560)throw Error('Invalid qualification viewport');
const id='node-demo-v4v';
const routeFailures=[];
const c=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'});contexts.push(c);
await c.addCookies(Object.entries(JSON.parse(fs.readFileSync(sessionFile))).map(([name,value])=>({name,value,url:origin,httpOnly:name!=='csrf_token'})));
await c.addInitScript(()=>{localStorage.setItem('neode-auth','true');localStorage.setItem('neode_onboarding_complete','1');localStorage.setItem('neode_companion_intro_seen','build:54');window.__qualificationMedia=[];const play=HTMLMediaElement.prototype.play;HTMLMediaElement.prototype.play=function(...args){this.muted=true;if(!window.__qualificationMedia.includes(this))window.__qualificationMedia.push(this);return play.apply(this,args)}});
if(uiDirectory)await c.route(origin+'/**',async route=>{
const pathname=new URL(route.request().url()).pathname;
if(route.request().method()!=='GET'||!(pathname==='/'||pathname.startsWith('/dashboard')||pathname.startsWith('/assets/')))return route.fallback();
const relative=pathname.startsWith('/assets/')?pathname.slice(1):'index.html';
const file=path.resolve(uiDirectory,relative);
if(!file.startsWith(uiDirectory+path.sep)||!fs.existsSync(file))return route.abort();
await route.fulfill({path:file});
});
// A route.fulfill document has synthetic address-space metadata in Chromium.
// Allow only this isolated loopback candidate context to reach its loopback
// media fixture; the live dashboard path never grants this permission.
if(uiDirectory && ['127.0.0.1','localhost','[::1]'].includes(new URL(origin).hostname))
await c.grantPermissions(['local-network-access'],{origin});
const login=await c.request.post(appOrigin + '/api/alpha/login',{data:{password:fs.readFileSync(passwordFile,'utf8').trim()},timeout:30000});if(!login.ok())throw Error('Fixture authentication failed');
await c.route('**/api/node-app-catalog',r=>r.fulfill({json:{scope:'single-node-demo',expires_at:'2030-01-01T00:00:00Z',apps:{[id]:{version:'0.6.7',manifest:{app:{id,name:'V4V Demo',metadata:{launch:{media_controls:'archipelago-v1'}},ports:[{host:Number(new URL(appOrigin).port),container:5181,auth:'open'}]}}}}}}));
const fixturePackage={state:'running',health:'healthy','ui-ready':true,manifest:{id,title:'V4V Demo'},installed:{'interface-addresses':{main:{'lan-address':appOrigin}}}};
await c.route('**/rpc/v1', async route=>{const body=route.request().postDataJSON();if(body?.method!=='server.get-state')return route.continue();const response=await route.fetch();const json=await response.json();if(json.result?.data?.['package-data'])json.result.data['package-data'][id]=fixturePackage;await route.fulfill({response,json});});
const p=await c.newPage();p.on('framenavigated',f=>{if(f!==p.mainFrame())console.log('frame navigation',new URL(f.url()||origin).pathname)});p.on('framedetached',f=>console.log('frame detached',new URL(f.url()||origin).pathname));await p.goto(origin+'/dashboard/discover',{waitUntil:'domcontentloaded',timeout:60000});
await c.route('**/rpc/v1', async route=>{
try {
const body=route.request().postDataJSON();
if(body?.method!=='server.get-state')return await route.continue();
const response=await route.fetch();const json=await response.json();
if(json.result?.data?.['package-data'])json.result.data['package-data'][id]=fixturePackage;
await route.fulfill({response,json});
} catch {
// Playwright request errors can contain Cookie headers. Record only a safe
// diagnostic and fail qualification without logging the request or exception.
routeFailures.push('Dashboard RPC fixture request failed');
await route.abort().catch(()=>{});
}
});
const p=await c.newPage();await p.addLocatorHandler(p.getByText('CpuLoad Alert',{exact:true}),async heading=>{await heading.locator('xpath=../..').getByRole('button').click()});p.on('requestfailed',r=>{if(r.isNavigationRequest()){const u=new URL(r.url());console.log('navigation failed',u.origin,u.pathname,r.failure()?.errorText)}});p.on('framenavigated',f=>{if(f!==p.mainFrame())console.log('frame navigation',new URL(f.url()||origin).origin,new URL(f.url()||origin).pathname)});p.on('framedetached',f=>console.log('frame detached',new URL(f.url()||origin).pathname));await p.goto(origin+'/dashboard/discover',{waitUntil:'domcontentloaded',timeout:60000});
await p.waitForFunction(()=>document.querySelector('#app')?.__vue_app__?.config.globalProperties.$pinia?._s.has('appLauncher'),undefined,{timeout:30000});
await p.waitForTimeout(1500);const later=p.getByRole('button',{name:'Later',exact:true});if(await later.isVisible().catch(()=>false))await later.click();
await p.evaluate(({id,appOrigin})=>{const s=document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s;const app=s.get('app');app.data['package-data'][id]={state:'running',health:'healthy','ui-ready':true,manifest:{id,title:'V4V Demo'},installed:{'interface-addresses':{main:{'lan-address':appOrigin}}}};s.get('sync').$subscribe((mutation,state)=>{if(state.data?.['package-data']&&!state.data['package-data'][id])state.data['package-data'][id]={state:'running',health:'healthy','ui-ready':true,manifest:{id,title:'V4V Demo'},installed:{'interface-addresses':{main:{'lan-address':appOrigin}}}}},{flush:'sync'});s.get('appLauncher').openSession(id)},{id,appOrigin});
console.log('stage',width,'launched',await p.evaluate(()=>{const s=document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('appLauncher');return {panel:s.panelAppId,media:s.mediaAppId}}));
await expect.poll(()=>p.frames().some(f=>f.url().startsWith(appOrigin)),{timeout:30000}).toBe(true);const frame=p.frames().find(f=>f.url().startsWith(appOrigin));
await expect.poll(()=>p.frames().some(f=>f.url().startsWith(appOrigin)),{timeout:30000}).toBe(true).catch(async error=>{console.log('frame targets',await p.locator('iframe').evaluateAll(elements=>elements.map(el=>{const u=new URL(el.src,location.href);return {origin:u.origin,path:u.pathname}})));throw error});const frame=p.frames().find(f=>f.url().startsWith(appOrigin));
console.log('stage',width,'frame loaded');await frame.waitForFunction(()=>window.PulsewireLegacy?.getReactBridge?.(),undefined,{timeout:30000,polling:100});
await frame.waitForFunction(()=>{const b=window.PulsewireLegacy?.getReactBridge?.();return b?.getState().releases.some(r=>{try{return new URL(b.helpers.releasePlayableUrl(r),location.href).pathname.startsWith('/media/dev-audio/')}catch{return false}})},undefined,{timeout:30000,polling:100});console.log('stage',width,'bridge ready');await frame.evaluate(()=>{const b=window.PulsewireLegacy.getReactBridge();const track=b.getState().releases.find(r=>{try{return new URL(b.helpers.releasePlayableUrl(r),location.href).pathname.startsWith('/media/dev-audio/')}catch{return false}});if(!track)throw Error('Bundled track missing');b.actions.play(track.id)});
await frame.waitForFunction(()=>window.__qualificationMedia.some(a=>!a.paused&&a.currentTime>0),undefined,{timeout:15000,polling:100});
@@ -36,5 +66,6 @@ try{for(const width of [390,1440]){
await bar.getByRole('button',{name:'Play audio',exact:true}).click();await frame.waitForFunction(()=>window.__qualificationMedia.some(a=>!a.paused),undefined,{timeout:10000,polling:100});
await bar.getByRole('button',{name:'Open playing app'}).click();await expect(bar).toBeHidden();if(!p.frames().includes(frame))throw Error('Frame recreated');
await p.getByRole('button',{name:'Close',exact:true}).filter({visible:true}).click();await expect(bar).toBeVisible();await bar.getByRole('button',{name:'Stop audio',exact:true}).click();await expect(bar).toBeHidden();
console.log(JSON.stringify({passed:true,width,scope:'actual deployed dashboard and real V4V media; browser-only catalog/package metadata fixture',hidden_playback:true,bar_pause_resume:true,reopen_same_frame:true,stop:true}));await c.unrouteAll({behavior:'ignoreErrors'});await c.close();
}}catch(error){console.log('test failure',String(error.message).split(/Call [Ll]og:/)[0]);throw error;}finally{for(const c of contexts){await c.unrouteAll({behavior:'ignoreErrors'}).catch(()=>{});await c.close().catch(()=>{})}}process.exit(0)})().catch(e=>{console.error(String(e.message).split('Call log:')[0]);process.exit(1)});
if(routeFailures.length)throw Error(routeFailures[0]);
console.log(JSON.stringify({passed:true,width,scope:uiDirectory?'candidate production UI at dashboard origin; real backend and V4V media; browser-only catalog/package fixture':'actual deployed dashboard and real V4V media; browser-only catalog/package metadata fixture',hidden_playback:true,bar_pause_resume:true,reopen_same_frame:true,stop:true}));await c.unrouteAll({behavior:'ignoreErrors'});await c.close();
}}catch(error){if(process.env.ARCHY_TEST_FAILURE_SCREENSHOT){const page=contexts[contexts.length-1]?.pages()[0];await page?.screenshot({path:process.env.ARCHY_TEST_FAILURE_SCREENSHOT}).catch(()=>{})}console.log('test failure',String(error.message).split(/Call [Ll]og:/)[0]);throw error;}finally{for(const c of contexts){await c.unrouteAll({behavior:'ignoreErrors'}).catch(()=>{});await c.close().catch(()=>{})}}process.exit(0)})().catch(e=>{console.error(String(e.message).split(/Call [Ll]og:/)[0]);process.exit(1)});