156 lines
11 KiB
JavaScript
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});
|
|
}
|