145 lines
10 KiB
JavaScript
145 lines
10 KiB
JavaScript
// Acceptance against the production frontend and real SQLite backend, with disposable data.
|
|
import {chromium, expect} from '@playwright/test';
|
|
import {spawn, spawnSync} from 'node:child_process';
|
|
import {mkdtemp, mkdir, rm, writeFile} from 'node:fs/promises';
|
|
import {tmpdir} from 'node:os';
|
|
import path from 'node:path';
|
|
import net from 'node:net';
|
|
import {fileURLToPath} from 'node:url';
|
|
import {createRequire} from 'node:module';
|
|
|
|
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
|
const {PNG}=createRequire(path.join(root,'backend/package.json'))('pngjs');
|
|
const data=await mkdtemp(path.join(tmpdir(),'conductor-identity-'));
|
|
const artifacts=path.join(root,'test-results/slice10');
|
|
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 checks=[];
|
|
function register(manifest) {
|
|
const result=spawnSync(process.execPath,['backend/dist/scripts/includedApps.js','register'],{cwd:root,env,input:JSON.stringify(manifest),encoding:'utf8'});
|
|
if(result.status!==0)throw new Error(result.stderr||result.stdout);
|
|
}
|
|
async function identity(target,title,icon='/favicon.svg') {
|
|
await expect(target).toHaveTitle(title);
|
|
await expect.poll(()=>target.locator('#conductor-favicon').evaluate(el=>new URL(el.href).pathname)).toBe(icon);
|
|
checks.push({url:new URL(target.url()).pathname,title,icon});
|
|
}
|
|
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:1440,height:1040}});
|
|
page=await context.newPage();
|
|
const errors=[];page.on('pageerror',error=>errors.push(error.message));
|
|
await page.goto('/');
|
|
await identity(page,'Set up · Conductor');
|
|
const setup=await context.request.get('/api/auth/setup');
|
|
const created=await context.request.post('/api/auth/setup',{headers:{'X-Setup-Token':(await setup.json()).setupToken},data:{username:'identity-admin',password:'identity-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});
|
|
const nav=name=>page.getByRole('navigation').getByRole('button',{name,exact:true}).click();
|
|
await page.goto('/');
|
|
await identity(page,'Conductor');
|
|
for(const name of ['Projects','Visual Editor','JSON Editor','Preview','Actions & Bindings','Execution History','Publishing','Users']){
|
|
await nav(name);await identity(page,name+' · Conductor');
|
|
}
|
|
for(const asset of ['/favicon.svg','/favicon.ico','/favicon-32.png','/apple-touch-icon.png'])expect((await context.request.get(asset)).status()).toBe(200);
|
|
const project=await request('POST','/api/projects',{name:'Workshop Notes',description:'Browser identity acceptance'});
|
|
expect(project.status()).toBe(201);
|
|
const projectId=(await project.json()).id;
|
|
await nav('Publishing');
|
|
const form=page.locator('form').filter({has:page.getByLabel('Saved project')});
|
|
await form.getByLabel('Application name',{exact:true}).fill('Workshop Notes');
|
|
await form.getByLabel('Application address',{exact:true}).fill('workshop-notes');
|
|
await form.getByLabel(/^Access/).selectOption('public');
|
|
const png=new PNG({width:32,height:32});
|
|
for(let y=0;y<32;y++)for(let x=0;x<32;x++){const offset=(y*32+x)*4;const white=x>=10&&x<22&&y>=7&&y<25;png.data.set(white?[255,255,255,255]:[220,90,20,255],offset);}
|
|
const fixture={name:'workshop.png',mimeType:'image/png',buffer:PNG.sync.write(png)};
|
|
await form.getByLabel('Application icon (optional)').setInputFiles(fixture);
|
|
await expect(form.getByText('Custom application icon')).toBeVisible();
|
|
const customIcon=await form.locator('img').getAttribute('src');
|
|
expect(customIcon).toMatch(/^\/app-icons\/[a-f0-9]{64}\.png$/);
|
|
await form.getByRole('button',{name:'Publish',exact:true}).click();
|
|
let card=page.getByRole('article').filter({has:page.getByRole('heading',{name:'Workshop Notes',exact:true})});
|
|
await expect(card).toBeVisible();
|
|
await expect(card.locator('img').first()).toHaveAttribute('src',customIcon);
|
|
await page.screenshot({path:path.join(artifacts,'publishing-custom-icon.png'),fullPage:true});
|
|
await card.getByRole('link',{name:'Open app'}).click();
|
|
await identity(page,'Workshop Notes · Conductor',customIcon);
|
|
await page.reload();await identity(page,'Workshop Notes · Conductor',customIcon);
|
|
const anonymous=await browser.newContext({baseURL:base});
|
|
const publicPage=await anonymous.newPage();
|
|
publicPage.on('pageerror',error=>errors.push(error.message));
|
|
await publicPage.goto('/apps/workshop-notes');
|
|
await identity(publicPage,'Workshop Notes · Conductor',customIcon);
|
|
await page.goto('/');await identity(page,'Conductor');await nav('Publishing');
|
|
card=page.getByRole('article').filter({has:page.getByRole('heading',{name:'Workshop Notes',exact:true})});
|
|
await card.locator('summary').click();
|
|
await card.getByRole('button',{name:'Use Conductor icon'}).click();
|
|
await expect(card.getByText('Conductor default icon')).toBeVisible();
|
|
await publicPage.reload();await identity(publicPage,'Workshop Notes · Conductor');
|
|
await card.getByLabel('Application icon (optional)').setInputFiles({name:'invalid.png',mimeType:'image/png',buffer:Buffer.from('not an image')});
|
|
await expect(card.getByRole('alert')).toBeVisible();
|
|
await expect(card.locator('img').first()).toHaveAttribute('src','/favicon.svg');
|
|
await card.getByLabel('Application icon (optional)').setInputFiles(fixture);
|
|
await expect(card.getByText('Custom application icon')).toBeVisible();
|
|
await card.getByRole('button',{name:'Republish',exact:true}).click();
|
|
await expect(card.getByText('Public access · Version 2')).toBeVisible();
|
|
await publicPage.reload();await identity(publicPage,'Workshop Notes · Conductor',customIcon);
|
|
const privateApp=await request('POST','/api/admin/published-apps',{sourceProjectId:projectId,displayName:'Private Notes',slug:'private-notes',visibility:'authenticated',iconPath:customIcon});
|
|
expect(privateApp.status()).toBe(201);
|
|
await publicPage.goto('/apps/private-notes');
|
|
await identity(publicPage,'Sign in · Conductor');
|
|
await publicPage.getByLabel('Username',{exact:true}).fill('identity-admin');
|
|
await publicPage.getByLabel('Password',{exact:true}).fill('identity-test-password');
|
|
await publicPage.getByRole('button',{name:'Sign in',exact:true}).click();
|
|
await identity(publicPage,'Private Notes · Conductor',customIcon);
|
|
// These fixtures test generic registry metadata matching; application hosting is SLICE11.
|
|
const manifest={schemaVersion:1,id:'example-tool',name:'Example Tool',description:'Identity fixture',version:'1.0.0',launchPath:'/example-tool',audience:'all-users',iconPath:customIcon};
|
|
register(manifest);
|
|
register({...manifest,id:'missing-icon',name:'Missing Icon',launchPath:'/missing-icon',iconPath:'/not-installed.png'});
|
|
await page.goto('/example-tool?view=settings');await identity(page,'Example Tool · Conductor',customIcon);
|
|
await page.goto('/example-tool/reports');await identity(page,'Example Tool · Conductor',customIcon);
|
|
await page.goto('/example-toolbox');await identity(page,'Conductor');
|
|
await page.goto('/missing-icon');await identity(page,'Missing Icon · Conductor');
|
|
await page.goto('/');await identity(page,'Conductor');
|
|
await page.getByRole('button',{name:'Sign out',exact:true}).click();
|
|
await identity(page,'Sign in · Conductor');
|
|
await page.goto('/example-tool');await identity(page,'Sign in · Conductor');
|
|
await page.getByLabel('Username',{exact:true}).fill('identity-admin');
|
|
await page.getByLabel('Password',{exact:true}).fill('identity-test-password');
|
|
await page.getByRole('button',{name:'Sign in',exact:true}).click();
|
|
await identity(page,'Example Tool · Conductor',customIcon);
|
|
await page.goto('/');await nav('Publishing');
|
|
await page.setViewportSize({width:1000,height:900});
|
|
await page.screenshot({path:path.join(artifacts,'publishing-compact.png'),fullPage:true});
|
|
const iconReview=await context.newPage();
|
|
await iconReview.setContent('<html><body style="margin:0;font:16px sans-serif"><section style="padding:32px;background:#fff;color:#222"><h2>Light background</h2><img src="'+base+'/favicon.svg" width="16" height="16"> <img src="'+base+'/favicon.svg" width="32" height="32"> <img src="'+base+'/favicon.svg" width="48" height="48"></section><section style="padding:32px;background:#20242a;color:#fff"><h2>Dark background</h2><img src="'+base+'/favicon.svg" width="16" height="16"> <img src="'+base+'/favicon.svg" width="32" height="32"> <img src="'+base+'/favicon.svg" width="48" height="48"></section></body></html>');
|
|
await expect.poll(()=>iconReview.locator('img').evaluateAll(images=>images.every(img=>img.complete&&img.naturalWidth>0))).toBe(true);
|
|
await iconReview.screenshot({path:path.join(artifacts,'default-icon-light-dark.png'),fullPage:true});
|
|
await iconReview.close();
|
|
expect(errors).toEqual([]);
|
|
await writeFile(path.join(artifacts,'browser-identity.json'),JSON.stringify({passed:true,checks},null,2)+'\n');
|
|
console.log('PASS: '+checks.length+' identity checks; default assets, publication upload/reset/republish, public and private access, registry routes, missing assets, sign-in/out.');
|
|
} 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});
|
|
}
|