Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/e2e-tests/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module.exports = {
node: true,
},
extends: ['../../.eslintrc.js'],
ignorePatterns: ['test-applications/**'],
ignorePatterns: ['test-applications/**', 'tmp/**'],
parserOptions: {
sourceType: 'module',
},
Expand Down
1 change: 1 addition & 0 deletions packages/e2e-tests/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.env
tmp
80 changes: 80 additions & 0 deletions packages/e2e-tests/lib/buildApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/* eslint-disable no-console */

import * as fs from 'fs-extra';
import * as path from 'path';

import { DEFAULT_BUILD_TIMEOUT_SECONDS } from './constants';
import type { Env, RecipeInstance } from './types';
import { spawnAsync } from './utils';

export async function buildApp(appDir: string, recipeInstance: RecipeInstance, env: Env): Promise<void> {
const { recipe, label, dependencyOverrides } = recipeInstance;

const packageJsonPath = path.resolve(appDir, 'package.json');

if (dependencyOverrides) {
// Override dependencies
const packageJson: { dependencies?: Record<string, string> } = JSON.parse(
fs.readFileSync(packageJsonPath, { encoding: 'utf-8' }),
);
packageJson.dependencies = packageJson.dependencies
? { ...packageJson.dependencies, ...dependencyOverrides }
: dependencyOverrides;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2), {
encoding: 'utf-8',
});
}

if (recipe.buildCommand) {
console.log(`Running build command for test application "${label}"`);

const buildResult = await spawnAsync(recipe.buildCommand, {
cwd: appDir,
timeout: (recipe.buildTimeoutSeconds ?? DEFAULT_BUILD_TIMEOUT_SECONDS) * 1000,
env: {
...process.env,
...env,
} as unknown as NodeJS.ProcessEnv,
});

if (buildResult.error) {
console.log(`Build failed for test application "${label}"`);

// Prepends some text to the output build command's output so we can distinguish it from logging in this script
console.log(buildResult.stdout.replace(/^/gm, ' [BUILD OUTPUT] '));
console.log(buildResult.stderr.replace(/^/gm, ' [BUILD OUTPUT] '));

console.log('[BUILD ERROR] ', buildResult.error);
throw buildResult.error;
}

if (recipe.buildAssertionCommand) {
console.log(`Running build assertion for test application "${label}"`);

const buildAssertionResult = await spawnAsync(
recipe.buildAssertionCommand,
{
cwd: appDir,
timeout: (recipe.buildTimeoutSeconds ?? DEFAULT_BUILD_TIMEOUT_SECONDS) * 1000,
env: {
...process.env,
...env,
} as unknown as NodeJS.ProcessEnv,
},
buildResult.stdout,
);

if (buildAssertionResult.error) {
console.log(`Build assertion failed for test application "${label}"`);

// Prepends some text to the output build command's output so we can distinguish it from logging in this script
console.log(buildAssertionResult.stdout.replace(/^/gm, ' [BUILD ASSERTION OUTPUT] '));
console.log(buildAssertionResult.stderr.replace(/^/gm, ' [BUILD ASSERTION OUTPUT] '));

console.log('[BUILD ASSERTION ERROR] ', buildAssertionResult.error);

throw buildAssertionResult.error;
}
}
}
}
47 changes: 47 additions & 0 deletions packages/e2e-tests/lib/buildRecipeInstances.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as fs from 'fs';

import type { Recipe, RecipeInput, RecipeInstance } from './types';

export function buildRecipeInstances(recipePaths: string[]): RecipeInstance[] {
const recipes = buildRecipes(recipePaths);
const recipeInstances: RecipeInstance[] = [];

const basePort = 3001;

recipes.forEach((recipe, i) => {
recipe.versions.forEach(version => {
const dependencyOverrides =
Object.keys(version.dependencyOverrides).length > 0 ? version.dependencyOverrides : undefined;
const dependencyOverridesInformationString = dependencyOverrides
? ` (Dependency overrides: ${JSON.stringify(dependencyOverrides)})`
: '';

recipeInstances.push({
label: `${recipe.testApplicationName}${dependencyOverridesInformationString}`,
recipe,
dependencyOverrides,
port: basePort + i,
});
});
});

return recipeInstances;
}

function buildRecipes(recipePaths: string[]): Recipe[] {
return recipePaths.map(recipePath => buildRecipe(recipePath));
}

function buildRecipe(recipePath: string): Recipe {
const recipe: RecipeInput = JSON.parse(fs.readFileSync(recipePath, 'utf-8'));

const versions = process.env.CANARY_E2E_TEST
? recipe.canaryVersions ?? []
: recipe.versions ?? [{ dependencyOverrides: {} }];

return {
...recipe,
path: recipePath,
versions,
};
}
6 changes: 6 additions & 0 deletions packages/e2e-tests/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const TEST_REGISTRY_CONTAINER_NAME = 'verdaccio-e2e-test-registry';
export const DEFAULT_BUILD_TIMEOUT_SECONDS = 60 * 5;
export const DEFAULT_TEST_TIMEOUT_SECONDS = 60 * 2;
export const VERDACCIO_VERSION = '5.22.1';
export const PUBLISH_PACKAGES_DOCKER_IMAGE_NAME = 'publish-packages';
export const TMP_DIR = 'tmp';
84 changes: 84 additions & 0 deletions packages/e2e-tests/lib/runAllTestApps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* eslint-disable no-console */
import { buildRecipeInstances } from './buildRecipeInstances';
import { buildAndTestApp } from './runTestApp';
import type { RecipeInstance, RecipeTestResult } from './types';

export async function runAllTestApps(
recipePaths: string[],
envVarsToInject: Record<string, string | undefined>,
): Promise<void> {
const maxParallel = process.env.CI ? 2 : 5;

const recipeInstances = buildRecipeInstances(recipePaths);

const results = await shardPromises(
recipeInstances,
recipeInstance => buildAndTestApp(recipeInstance, envVarsToInject),
maxParallel,
);

console.log('--------------------------------------');
console.log('Test Result Summary:');

results.forEach(result => {
if (result.buildFailed) {
console.log(`● BUILD FAILED - ${result.label} (${result.recipe.path}`);
} else {
console.log(`● BUILD SUCCEEDED - ${result.label}`);
result.tests.forEach(testResult => {
console.log(` ● ${testResult.result.padEnd(7, ' ')} ${testResult.testName}`);
});
}
});

const failed = results.filter(result => result.buildFailed || result.testFailed);

if (failed.length) {
console.log(`${failed.length} test(s) failed.`);
process.exit(1);
}

console.log('All tests succeeded. 🎉');
}

// Always run X promises at a time
function shardPromises(
recipes: RecipeInstance[],
callback: (recipe: RecipeInstance) => Promise<RecipeTestResult>,
maxParallel: number,
): Promise<RecipeTestResult[]> {
return new Promise(resolve => {
console.log(`Running a total of ${recipes.length} jobs, with up to ${maxParallel} jobs in parallel...`);
const results: RecipeTestResult[] = [];
const remaining = recipes.slice();
const running: Promise<unknown>[] = [];

function runNext(): void {
if (running.length < maxParallel && remaining.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const next = remaining.shift()!;
const promise = callback(next);

console.log(`Running job ${next.label}, ${remaining.length} remaining...`);

running.push(promise);

promise
.then(result => results.push(result))
.finally(() => {
const pos = running.indexOf(promise);
running.splice(pos, 1);

runNext();
});
} else if (remaining.length === 0 && running.length === 0) {
resolve(results);
}
}

// Initial runs
for (let i = 0; i < maxParallel; i++) {
runNext();
}
});
}
54 changes: 54 additions & 0 deletions packages/e2e-tests/lib/runTestApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/* eslint-disable no-console */

import { uuid4 } from '@sentry/utils';
import * as fs from 'fs-extra';
import * as path from 'path';

import { buildApp } from './buildApp';
import { TMP_DIR } from './constants';
import { testApp } from './testApp';
import type { Env, RecipeInstance, RecipeTestResult } from './types';

// This should never throw, we always return a result here
export async function buildAndTestApp(
recipeInstance: RecipeInstance,
envVarsToInject: Record<string, string | undefined>,
): Promise<RecipeTestResult> {
const { recipe, port } = recipeInstance;
const recipeDirname = path.dirname(recipe.path);

const targetDir = path.join(TMP_DIR, `${recipe.testApplicationName}-${uuid4()}`);

await fs.copy(recipeDirname, targetDir);

const env: Env = {
...envVarsToInject,
PORT: port.toString(),
};

try {
await buildApp(targetDir, recipeInstance, env);
} catch (error) {
await fs.remove(targetDir);

return {
...recipeInstance,
buildFailed: true,
testFailed: false,
tests: [],
};
}

// This cannot throw, we always return a result here
const results = await testApp(targetDir, recipeInstance, env);

// Cleanup
await fs.remove(targetDir);

return {
...recipeInstance,
buildFailed: false,
testFailed: results.some(result => result.result !== 'PASS'),
tests: results,
};
}
50 changes: 50 additions & 0 deletions packages/e2e-tests/lib/testApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/* eslint-disable no-console */

import { DEFAULT_TEST_TIMEOUT_SECONDS } from './constants';
import type { Env, RecipeInstance, TestDef, TestResult } from './types';
import { spawnAsync } from './utils';

export async function testApp(appDir: string, recipeInstance: RecipeInstance, env: Env): Promise<TestResult[]> {
const { recipe } = recipeInstance;

const results: TestResult[] = [];
for (const test of recipe.tests) {
results.push(await runTest(appDir, recipeInstance, test, env));
}

return results;
}

async function runTest(appDir: string, recipeInstance: RecipeInstance, test: TestDef, env: Env): Promise<TestResult> {
const { recipe, label } = recipeInstance;
console.log(`Running test command for test application "${label}", test "${test.testName}"`);

const testResult = await spawnAsync(test.testCommand, {
cwd: appDir,
timeout: (recipe.testTimeoutSeconds ?? DEFAULT_TEST_TIMEOUT_SECONDS) * 1000,
env: {
...process.env,
...env,
} as unknown as NodeJS.ProcessEnv,
});

if (testResult.error) {
console.log(`Test failed for test application "${label}", test "${test.testName}"`);

// Prepends some text to the output test command's output so we can distinguish it from logging in this script
console.log(testResult.stdout.replace(/^/gm, ' [TEST OUTPUT] '));
console.log(testResult.stderr.replace(/^/gm, ' [TEST OUTPUT] '));

console.log('[TEST ERROR] ', testResult.error);

return {
testName: test.testName,
result: testResult.error?.message.includes('ETDIMEDOUT') ? 'TIMEOUT' : 'FAIL',
};
}

return {
testName: test.testName,
result: 'PASS',
};
}
49 changes: 49 additions & 0 deletions packages/e2e-tests/lib/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export type TestResult = {
testName: string;
result: 'PASS' | 'FAIL' | 'TIMEOUT';
};

type DependencyOverrides = Record<string, string>;

export interface TestDef {
testName: string;
testCommand: string;
timeoutSeconds?: number;
}

export interface RecipeInput {
testApplicationName: string;
buildCommand?: string;
buildAssertionCommand?: string;
buildTimeoutSeconds?: number;
testTimeoutSeconds?: number;
tests: TestDef[];
versions?: { dependencyOverrides: DependencyOverrides }[];
canaryVersions?: { dependencyOverrides: DependencyOverrides }[];
}

export interface Recipe {
path: string;
testApplicationName: string;
buildCommand?: string;
buildAssertionCommand?: string;
buildTimeoutSeconds?: number;
testTimeoutSeconds?: number;
tests: TestDef[];
versions: { dependencyOverrides: DependencyOverrides }[];
}

export interface RecipeInstance {
label: string;
recipe: Recipe;
dependencyOverrides?: DependencyOverrides;
port: number;
}

export interface RecipeTestResult extends RecipeInstance {
buildFailed: boolean;
testFailed: boolean;
tests: TestResult[];
}

export type Env = Record<string, string | string>;
Loading