conductor/scripts/test-management.mjs

156 lines
11 KiB
JavaScript

// Isolated browser acceptance checks against the actual built app and SQLite backend.
import {chromium, expect} from '@playwright/test';
import {spawn, spawnSync} from 'node:child_process';
import {mkdtemp, mkdir, rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import path from 'node:path';
import net from 'node:net';
import {fileURLToPath} from 'node:url';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
const data=await mkdtemp(path.join(tmpdir(),'conductor-management-'));
const artifacts=path.join(root,'test-results/management');
await mkdir(artifacts,{recursive:true});
const port=await new Promise(resolve=>{
const socket=net.createServer();socket.listen(0,'127.0.0.1',()=>{const value=socket.address().port;socket.close(()=>resolve(value));});
});
const base='http://127.0.0.1:'+port;
const env={...process.env,PORT:String(port),CONDUCTOR_DATA_DIR:data,CONDUCTOR_STATIC_DIR:path.join(root,'frontend/build'),CONDUCTOR_SECRET_KEY:Buffer.alloc(32,19).toString('base64')};
let output='';
const server=spawn(process.execPath,['backend/dist/index.js'],{cwd:root,env,stdio:['ignore','pipe','pipe']});
server.stdout.on('data',chunk=>{output+=chunk;});server.stderr.on('data',chunk=>{output+=chunk;});
let browser,page;
const registry=(command,manifest)=>{
const args=['backend/dist/scripts/includedApps.js',command];
if(command==='unregister')args.push(manifest);
const result=spawnSync(process.execPath,args,{cwd:root,env,input:command==='register'?JSON.stringify(manifest):undefined,encoding:'utf8'});
if(result.status!==0)throw new Error(result.stderr||result.stdout);
return JSON.parse(result.stdout);
};
try {
for(let attempt=0;attempt<100;attempt++){
if(server.exitCode!==null)throw new Error(output);
if(await fetch(base+'/api/health').then(r=>r.ok).catch(()=>false))break;
if(attempt===99)throw new Error('Backend did not start.\n'+output);
await new Promise(resolve=>setTimeout(resolve,50));
}
browser=await chromium.launch({headless:true});
const context=await browser.newContext({baseURL:base,viewport:{width:1360,height:960}});
const setup=await context.request.get('/api/auth/setup');
const token=(await setup.json()).setupToken;
const created=await context.request.post('/api/auth/setup',{headers:{'X-Setup-Token':token},data:{username:'management-admin',password:'management-test-password',displayName:'Test Administrator'}});
expect(created.status()).toBe(201);
const csrf=(await created.json()).csrfToken;
const request=(method,url,body)=>context.request.fetch(url,{method,headers:{'X-CSRF-Token':csrf},data:body});
page=await context.newPage();
const errors=[];page.on('pageerror',error=>errors.push(error.message));
const nav=name=>page.getByRole('navigation').getByRole('button',{name,exact:true}).click();
await page.goto('/');
await expect(page.getByRole('heading',{name:'Welcome to Conductor'})).toBeVisible();
await expect(page.getByText(/Backend scaffold|coming in a later step/)).toHaveCount(0);
await expect(page.getByText('No included applications are installed yet.',{exact:false})).toBeVisible();
await expect(page.getByText('Social Scheduler',{exact:true})).toHaveCount(0);
await page.screenshot({path:path.join(artifacts,'home-empty.png'),fullPage:true});
await page.getByRole('button',{name:/^Projects Create a project/}).click();
await expect(page.getByText('No saved projects yet.')).toBeVisible();
await page.getByRole('button',{name:'New project',exact:true}).click();
await page.getByLabel('Project name',{exact:true}).fill('Customer Dashboard');
await page.getByLabel('Description',{exact:true}).fill('A saved builder project');
await page.getByRole('button',{name:'Create project',exact:true}).click();
await expect(page.getByTitle('Click to rename project')).toHaveText('Customer Dashboard');
const saved=(await (await request('GET','/api/projects')).json())[0];
const document=await (await request('GET','/api/projects/'+saved.id)).json();
expect(JSON.parse(document.project_json).project.description).toBe('A saved builder project');
await nav('Projects');
let card=page.getByRole('article',{name:'Project Customer Dashboard',exact:true});
await card.getByRole('button',{name:'Rename',exact:true}).click();
await card.getByLabel('Project name',{exact:true}).fill('Customer Operations');
await card.getByRole('button',{name:'Save details',exact:true}).click();
card=page.getByRole('article',{name:'Project Customer Operations',exact:true});
await expect(card).toBeVisible();
await page.getByLabel('Search projects').fill('missing');
await expect(page.getByText('No projects match your search.')).toBeVisible();
await page.getByLabel('Search projects').fill('');
await card.getByRole('button',{name:'Preview',exact:true}).click();
await expect(page.getByRole('heading',{name:'Projects',exact:true})).toHaveCount(0);
await nav('Projects');
const other=await request('POST','/api/projects',{name:'Second Project',description:'Another builder project'});
expect(other.status()).toBe(201);
await card.getByRole('button',{name:'Open editor',exact:true}).click();
await page.getByTitle('Click to rename project').click();
await page.locator('[aria-label="Project commands"] input').fill('Unsaved name');
await page.locator('[aria-label="Project commands"] input').press('Enter');
await nav('Projects');
await expect(page.getByText('You have unsaved changes to',{exact:false})).toBeVisible();
await expect(card.getByRole('button',{name:'Rename',exact:true})).toBeDisabled();
page.once('dialog',dialog=>dialog.dismiss());
await page.getByRole('article',{name:'Project Second Project',exact:true}).getByRole('button',{name:'Open editor',exact:true}).click();
await expect(page.getByRole('heading',{name:'Projects',exact:true})).toBeVisible();
page.once('dialog',dialog=>dialog.accept());
await page.getByRole('article',{name:'Project Second Project',exact:true}).getByRole('button',{name:'Open editor',exact:true}).click();
await expect(page.getByTitle('Click to rename project')).toHaveText('Second Project');
await nav('Projects');
await page.screenshot({path:path.join(artifacts,'projects.png'),fullPage:true});
await card.getByRole('button',{name:'Publish',exact:true}).click();
await expect(page.getByLabel('Saved project')).toHaveValue(String(saved.id));
await page.getByLabel('Application name',{exact:true}).fill('Operations App');
await page.getByLabel('Application address',{exact:true}).fill('operations-app');
await page.getByRole('button',{name:'Publish',exact:true}).click();
await expect(page.getByRole('heading',{name:'Operations App'})).toBeVisible();
await page.getByRole('button',{name:'Republish',exact:true}).click();
await expect(page.getByText('Signed-in users · Version 2')).toBeVisible();
await nav('Projects');
page.once('dialog',dialog=>dialog.accept());
await card.getByRole('button',{name:'Delete',exact:true}).click();
await expect(page.getByRole('alert')).toContainText('Unpublish');
await nav('Publishing');
await page.getByRole('button',{name:'Unpublish',exact:true}).click();
await expect(page.getByText('Unpublished',{exact:true})).toBeVisible();
await nav('Projects');
page.once('dialog',dialog=>dialog.accept());
await card.getByRole('button',{name:'Delete',exact:true}).click();
await expect(card).toHaveCount(0);
await nav('Publishing');
await expect(page.getByText('No included applications are installed yet.',{exact:false})).toBeVisible();
const manifest={schemaVersion:1,id:'example-application',name:'Example Application',description:'An application registered by its installer.',version:'1.0.0',launchPath:'/example-application',configurationPath:'/example-application?view=settings',audience:'all-users'};
registry('register',manifest);registry('register',{...manifest,version:'1.1.0'});
registry('register',{...manifest,id:'admin-application',name:'Admin Application',audience:'admins'});
await page.getByRole('button',{name:'Refresh applications',exact:true}).click();
await expect(page.getByRole('heading',{name:'Example Application',exact:true})).toHaveCount(1);
await expect(page.getByText('Version 1.1.0',{exact:true})).toBeVisible();
await expect(page.getByRole('link',{name:'Open Example Application',exact:true})).toHaveAttribute('href','/example-application');
await expect(page.getByRole('link',{name:'Configure',exact:true})).toHaveCount(2);
await page.screenshot({path:path.join(artifacts,'publishing-included.png'),fullPage:true});
await page.route('**/api/included-apps',route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'Applications temporarily unavailable.'})}));
await page.getByRole('button',{name:'Refresh applications',exact:true}).click();
await expect(page.getByRole('alert')).toContainText('Included applications could not be loaded. Please try again.');
await page.unroute('**/api/included-apps');
await page.getByRole('button',{name:'Refresh applications',exact:true}).click();
await expect(page.getByRole('heading',{name:'Example Application',exact:true})).toBeVisible();
const user=await request('POST','/api/admin/users',{username:'management-user',password:'management-user-password',role:'user'});
expect(user.status()).toBe(201);
const userContext=await browser.newContext({baseURL:base,viewport:{width:1100,height:800}});
expect((await userContext.request.post('/api/auth/login',{data:{username:'management-user',password:'management-user-password'}})).status()).toBe(200);
const userPage=await userContext.newPage();await userPage.goto('/');
await expect(userPage.getByRole('heading',{name:'Example Application',exact:true})).toBeVisible();
await expect(userPage.getByRole('heading',{name:'Admin Application',exact:true})).toHaveCount(0);
await expect(userPage.getByRole('link',{name:'Configure',exact:true})).toHaveCount(0);
await userPage.screenshot({path:path.join(artifacts,'user-applications.png'),fullPage:true});
registry('unregister','example-application');
await userPage.getByRole('button',{name:'Refresh applications',exact:true}).click();
await expect(userPage.getByText('No included applications are installed yet.',{exact:false})).toBeVisible();
await page.goto('/');
await expect(page.getByRole('heading',{name:'Admin Application',exact:true})).toBeVisible();
await expect(page.getByRole('heading',{name:'Example Application',exact:true})).toHaveCount(0);
expect(errors).toEqual([]);
console.log('PASS: home, projects CRUD/search/unsaved protection, publish/republish/unpublish, installer registration/update/removal, role filtering, API failure recovery.');
} catch(error) {
if(page)await page.screenshot({path:path.join(artifacts,'failure.png'),fullPage:true}).catch(()=>{});
throw error;
} finally {
await browser?.close();
if(server.exitCode===null){await new Promise(resolve=>{server.once('exit',resolve);server.kill('SIGTERM');});}
await rm(data,{recursive:true,force:true});
}