Skip to content

Commit

Permalink
refactor(e2e): only start servers when needed
Browse files Browse the repository at this point in the history
Note that this change is also a workaround for:
microsoft/playwright#18209
  • Loading branch information
divdavem committed Mar 29, 2024
1 parent b79baa0 commit d4bc220
Show file tree
Hide file tree
Showing 8 changed files with 258 additions and 90 deletions.
44 changes: 25 additions & 19 deletions e2e/fixture.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import reportCoverage from '@agnos-ui/code-coverage/reportCoverage';
import {getSamplesList} from '../demo/scripts/listSamples.plugin';
import {test as base} from '@playwright/test';
import type {SampleInfo} from '../demo/src/lib/layout/sample';
import {getSamplesList} from '../demo/scripts/listSamples.plugin';
import type {Frameworks, Project, SimpleSampleInfo} from './types';

export {expect} from '@playwright/test';

export type FixtureOptions = {
// with option: true
framework?: string;
project?: Project;
framework?: Frameworks;
sampleKey?: string;

// with option: false (default)
coverage: void;
sampleInfo?: Pick<SampleInfo, 'componentName' | 'sampleName' | 'style'> & {sampleURL: string};
sampleInfo?: SimpleSampleInfo;
};

let cachedSamplesList: undefined | ReturnType<typeof getSamplesList>;
Expand All @@ -23,25 +24,30 @@ export const samplesList = () => {
return cachedSamplesList;
};

const serverManagerURL = process.env.SERVER_MANAGER_URL!;

export const test = base.extend<FixtureOptions>({
project: [undefined, {option: true}],
framework: [undefined, {option: true}],
sampleKey: [undefined, {option: true}],
sampleInfo: async ({sampleKey, baseURL}, use) => {
let sampleInfo: FixtureOptions['sampleInfo'];
if (sampleKey && baseURL) {
const sampleItem = samplesList()[sampleKey];
if (sampleItem) {
const sampleURL = new URL(
sampleItem.style === 'bootstrap'
? `#/${sampleItem.componentName}/${sampleItem.sampleName}`
: `../${sampleItem.style}/#/${sampleItem.componentName}/${sampleItem.sampleName}`,
baseURL,
).href;
sampleInfo = {...sampleItem, sampleURL};
}
}
await use(sampleInfo);
sampleInfo: async ({sampleKey}, use) => {
await use(sampleKey ? samplesList()[sampleKey] : undefined);
},
baseURL: [
async ({project, framework, sampleKey, sampleInfo}, use) => {
const req = await fetch(serverManagerURL, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project, framework, sampleKey, sampleInfo}),
});
const answer = await req.json();
if (!req.ok) {
throw new Error(answer.error);
}
await use(answer.url);
},
{timeout: 60000, scope: 'test'},
],
coverage: [
async ({page, browserName}, use) => {
await use();
Expand Down
8 changes: 7 additions & 1 deletion e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import setupCoverage from '@agnos-ui/code-coverage/setup';
import serverManager from './serverManager';

async function globalSetup() {
return await setupCoverage(import.meta.dirname);
const coverageTeardown = await setupCoverage(import.meta.dirname);
const serverManagerTeardown = await serverManager();
return async () => {
await serverManagerTeardown();
await coverageTeardown();
};
}

export default globalSetup;
4 changes: 2 additions & 2 deletions e2e/samplesMarkup.singlebrowser-e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ test.describe(`Samples markup consistency check`, async () => {

test.fixme(sampleKey === 'daisyui/rating/default', 'This sample does not currently have a consistent markup!');

test(`should have a consistent markup`, async ({page, sampleInfo}) => {
await page.goto(`${sampleInfo?.sampleURL}${samplesExtraHash[sampleKey] ?? ''}`, {waitUntil: 'networkidle'});
test(`should have a consistent markup`, async ({page, baseURL}) => {
await page.goto(`${baseURL}${samplesExtraHash[sampleKey] ?? ''}`, {waitUntil: 'networkidle'});
await expect.poll(async () => (await page.locator('#root').innerHTML()).trim().length).toBeGreaterThan(0);
await samplesExtraAction[sampleKey]?.(page);
expect(await htmlSnapshot(page.locator('body'))).toMatchSnapshot(`${sampleKey}.html`);
Expand Down
201 changes: 201 additions & 0 deletions e2e/serverManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import {spawn} from 'child_process';
import express from 'express';
import type {Server} from 'http';
import type {AddressInfo} from 'net';
import type {Project, Frameworks, SimpleSampleInfo} from './types';

interface RequestBody {
project?: Project;
framework?: Frameworks;
sampleKey?: string;
sampleInfo?: SimpleSampleInfo;
}

const isPreview = process.env.PREVIEW === 'true';
const includeCoverage = process.env.COVERAGE === 'true';
const coverageSuffix = includeCoverage ? ':coverage' : '';
const previewOrDev = isPreview ? 'preview' : 'dev';
const allServers = {
angularDemoDevBootstrap: {
command: ['npm', 'run', '-w', 'angular/demo', `dev:bootstrap${coverageSuffix}`],
url: 'http://localhost:4200',
urlReadyPath: '/angular/samples/bootstrap/',
},
angularDemoDevDaisyui: {
command: ['npm', 'run', '-w', 'angular/demo', `dev:daisyui${coverageSuffix}`],
url: 'http://localhost:4201',
urlReadyPath: '/angular/samples/daisyui/',
},
reactDemoDev: {
command: ['npm', 'run', '-w', 'react/demo', `dev${coverageSuffix}`],
url: 'http://localhost:3000',
urlReadyPath: '/react/samples/bootstrap/',
},
svelteDemoDev: {
command: ['npm', 'run', '-w', 'svelte/demo', `dev${coverageSuffix}`],
url: 'http://localhost:3001',
urlReadyPath: '/svelte/samples/bootstrap/',
},
demoSite: {
command: ['npm', 'run', '-w', 'demo', `${previewOrDev}${coverageSuffix}`],
url: 'http://localhost:4000',
urlReadyPath: '/',
},
};
type ServerKey = keyof typeof allServers;

const getNeededServersAndURL = ({project, framework, sampleInfo}: RequestBody) => {
let url: string | undefined;
const servers: ServerKey[] = [];
switch (project) {
case 'singlebrowser':
case 'main': {
let urlPath = '/';
if (framework) {
if (sampleInfo) {
urlPath = `/${framework}/samples/${sampleInfo.style}/#/${sampleInfo.componentName}/${sampleInfo.sampleName}`;
} else {
urlPath = `/${framework}/samples/bootstrap/`;
}
}
if (isPreview) {
servers.push('demoSite');
} else {
switch (framework) {
case 'angular':
if (sampleInfo?.style === 'daisyui') {
servers.push('angularDemoDevDaisyui');
} else {
servers.push('angularDemoDevBootstrap');
}
break;
case 'react':
servers.push('reactDemoDev');
break;
case 'svelte':
servers.push('svelteDemoDev');
break;
}
}
url = `${allServers[servers[0]].url}${urlPath}`;
break;
}
case 'demo':
servers.push('demoSite');
if (!isPreview) {
servers.push('angularDemoDevBootstrap', 'angularDemoDevDaisyui', 'reactDemoDev', 'svelteDemoDev');
}
url = allServers.demoSite.url;
break;
}
return {servers, url};
};

const isURLReady = async (url: string, signal: AbortSignal) => {
const abortController = new AbortController();
const listener = () => abortController.abort();
signal.addEventListener('abort', listener);
try {
const res = await fetch(url, {signal: abortController.signal});
await res.body?.cancel();
return res.ok;
} catch (error) {
return false;
} finally {
signal.removeEventListener('abort', listener);
}
};

const startServer = async (serverKey: ServerKey, abortSignal: AbortSignal) => {
const serverInfo = allServers[serverKey];
const url = `${serverInfo.url}${serverInfo.urlReadyPath}`;
if (await isURLReady(url, abortSignal)) {
// server is already running
console.log(`Reusing existing server ${serverKey}`);
return;
}
if (abortSignal.aborted) {
return;
}
console.log(`Starting server ${serverKey}`);
const isWindows = process.platform === 'win32';
let processExited = false;
const proc = spawn(serverInfo.command[0], serverInfo.command.slice(1), {
shell: isWindows,
stdio: 'inherit',
detached: !isWindows,
});

const onAbort = () => {
if (isWindows) {
proc.kill();
} else if (proc.pid != null) {
process.kill(-proc.pid, 'SIGINT');
}
};

abortSignal.addEventListener('abort', onAbort);

proc.on('close', (code, signal) => {
processExited = true;
abortSignal.removeEventListener('abort', onAbort);
console.error(`Server ${serverKey} exited with ${code ? `code ${code}` : `signal ${signal}`}`);
});

proc.on('error', (error) => {
console.error(`Failed to start server ${serverKey}: ${error}`);
});
let urlReady = false;
while (!urlReady && !abortSignal.aborted) {
if (processExited) {
throw new Error(`Server ${serverKey} exited before being ready`);
}
urlReady = await isURLReady(url, abortSignal);
await new Promise((resolve) => setTimeout(resolve, 100));
}
console.log(`Server ${serverKey} is ready`);
};

export default async () => {
const serversStatus = new Map<ServerKey, Promise<void>>();
const abortController = new AbortController();

const ensureServerRuns = async (serverKey: ServerKey) => {
let status = serversStatus.get(serverKey);
if (!status) {
status = startServer(serverKey, abortController.signal);
serversStatus.set(serverKey, status);
}
return await status;
};

const app = express();
app.use(express.json());

app.post('/', async (req, res) => {
try {
const {servers, url} = getNeededServersAndURL(req.body);
await Promise.all(servers.map(ensureServerRuns));
res.json({url});
} catch (error) {
res.status(500).json({error: `${error}`});
}
});

const server = await new Promise<Server>((resolve, reject) => {
const server = app.listen(0, '127.0.0.1', () => resolve(server)).on('error', reject);
});
const port = (server.address() as AddressInfo).port;
const serverManagerURL = `http://127.0.0.1:${port}`;
process.env.SERVER_MANAGER_URL = serverManagerURL;
console.log(`Server manager was started on ${serverManagerURL}`);
return async () => {
abortController.abort();
console.log('Closing server manager...');
await new Promise((resolve) => {
server.close(resolve);
server.closeAllConnections();
});
console.log('Server manager closed');
};
};
4 changes: 4 additions & 0 deletions e2e/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type {SampleInfo} from '../demo/src/lib/layout/sample';
export type {Frameworks} from '../demo/src/lib/stores';
export type SimpleSampleInfo = Pick<SampleInfo, 'style' | 'componentName' | 'sampleName'>;
export type Project = 'singlebrowser' | 'main' | 'demo';
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsdoc": "^48.2.1",
"express": "^4.19.2",
"glob": "10.3.10",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
Expand Down Expand Up @@ -128,7 +129,8 @@
"command": "node scripts/e2e.js --ui",
"dependencies": [
"./code-coverage:build"
]
],
"service": true
},
"prepare": {
"dependencies": [
Expand Down
Loading

0 comments on commit d4bc220

Please sign in to comment.