Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: fix issues with determine-playwright-tests action #4603

Merged
merged 18 commits into from
Oct 30, 2024
Merged
21 changes: 10 additions & 11 deletions .github/workflows/prbot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ jobs:
maximumShards: 24
outputs:
testsToRun: ${{ steps.determine-tests.outputs.testsToRun }}
shardIndex: ${{ steps.set-matrix.outputs.shardIndex }}
shardTotal: ${{ steps.set-matrix.outputs.shardTotal }}
shardIndex: ${{ steps.determine-tests.outputs.shardIndex }}
shardTotal: ${{ steps.determine-tests.outputs.shardTotal }}
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
with:
Expand All @@ -104,17 +104,16 @@ jobs:
- run: npm run build
- name: Identify E2E Test Files to run
id: determine-tests
run: node ./scripts/ci/find-tests.mjs testsToRun
run: node ./scripts/ci/determine-tests.mjs testsToRun shardIndex shardTotal
env:
projectRoot: ${{ github.workspace }}
shell: bash
- name: Determine Shard Values
id: set-matrix
run: node ./scripts/ci/determine-shard.mjs shardIndex shardTotal
env:
testsToRun: ${{ steps.determine-tests.outputs.testsToRun }}
maximumShards: ${{ env.maximumShards }}
shell: bash
- name: Log Shard Values for Debugging
run: |
echo "Shard Index: ${{ steps.determine-tests.outputs.shardIndex }}"
echo "Shard Total: ${{ steps.determine-tests.outputs.shardTotal }}"
shell: bash
playwright-atomic:
name: 'Run Playwright tests for Atomic'
needs: prepare-playwright-atomic
Expand All @@ -123,8 +122,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: ${{fromJson(needs.prepare-playwright-atomic.outputs.shardIndex || '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24]')}}
shardTotal: ${{fromJson(needs.prepare-playwright-atomic.outputs.shardTotal || '[24]')}}
shardIndex: ${{ fromJson(needs.prepare-playwright-atomic.outputs.shardIndex) }}
shardTotal: ${{ fromJson(needs.prepare-playwright-atomic.outputs.shardTotal) }}
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
- uses: ./.github/actions/setup
Expand Down
25 changes: 0 additions & 25 deletions scripts/ci/determine-shard.mjs

This file was deleted.

221 changes: 221 additions & 0 deletions scripts/ci/determine-tests.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
#!/usr/bin/env node
import {setOutput} from '@actions/core';
import {readdirSync, statSync} from 'fs';
import {EOL} from 'os';
import {basename, dirname, join, relative} from 'path';
import {getBaseHeadSHAs, getChangedFiles} from './hasFileChanged.mjs';
import {listImports, ensureFileExists} from './list-imports.mjs';

class NoRelevantChangesError extends Error {
constructor() {
super('No changes that would affect Atomic were detected. Skipping tests.');
this.name = 'NoRelevantChangesError';
}
}

class DependentPackageChangeError extends Error {
constructor(file) {
super(
`Changes detected in a package on which Atomic depend: ${file}. Running all tests.`
);
this.name = 'DependentPackageChangeError';
this.file = file;
}
}

class PackageJsonChangeError extends Error {
constructor(file) {
super(
`Changes detected in a package.json or package-lock.json file: ${file}. Running all tests.`
);
this.name = 'PackageJsonChangeError';
this.file = file;
}
}

/**
* Recursively finds all end-to-end test files with the `.e2e.ts` extension in a given directory.
*
* @param {string} dir - The directory to search for test files.
* @returns {string[]} An array of paths to the found test files.
*/
function findAllTestFiles(dir) {
function searchFiles(currentDir, testFiles) {
const files = readdirSync(currentDir);

for (const file of files) {
const fullPath = join(currentDir, file);
const stat = statSync(fullPath);

if (stat.isDirectory()) {
searchFiles(fullPath, testFiles);
} else if (fullPath.endsWith('.e2e.ts')) {
testFiles.push(fullPath);
}
}

return testFiles;
}

return searchFiles(dir, []);
}

/**
* Creates a mapping of test file names to their respective import dependencies.
*
* @param {string[]} testPaths - An array of paths to the test files.
* @param {string} projectRoot - The root directory of the project.
* @returns {Map<string, Set<string>>} A map where the key is the test file name and the value is a set of import dependencies.
*/
function createTestFileMappings(testPaths, projectRoot) {
const testFileMappings = testPaths.map((testPath) => {
const imports = new Set();
const testName = basename(testPath);
const sourceFilePath = join(
dirname(testPath).replace('/e2e', ''),
testName.replace('.e2e.ts', '.tsx')
);

ensureFileExists(sourceFilePath);

[
relative(projectRoot, sourceFilePath),
...listImports(projectRoot, sourceFilePath),
...listImports(projectRoot, testPath),
].forEach((importedFile) => imports.add(importedFile));

return [testName, imports];
});

return new Map(testFileMappings);
}

/**
* Determines which test files need to be run based on the changed files and their dependencies.
*
* @param {string[]} changedFiles - An array of file paths that have been changed.
* @param {Map<string, Set<string>>} testDependencies - A map where the keys are test file paths and the values are sets of source file paths that the test files depend on.
* @returns {string} A space-separated string of test file paths that need to be run.
*/
function determineTestFilesToRun(changedFiles, testDependencies) {
const testsToRun = new Set();
for (const changedFile of changedFiles) {
for (const [testFile, sourceFiles] of testDependencies) {
ensureIsNotCoveoPackage(changedFile);
ensureIsNotPackageJsonOrPackageLockJson(changedFile);
const isChangedTestFile = testFile === basename(changedFile);
const isAffectedSourceFile = sourceFiles.has(changedFile);
if (isChangedTestFile || isAffectedSourceFile) {
testsToRun.add(testFile);
testDependencies.delete(testFile);
}
}
}
return [...testsToRun].join(' ');
}

/**
* Ensures that the given file is not part of a Coveo package.
* Throws an error if the file depends on a Coveo package.
*
* @param {string} file - The path to the file to check.
* @throws {DependentPackageChangeError} If the file depends on a Coveo package.
*/
function ensureIsNotCoveoPackage(file) {
if (dependsOnCoveoPackage(file)) {
throw new DependentPackageChangeError(file);
}
}

/**
* Ensures that the provided file is not 'package.json' or 'package-lock.json'.
* Throws a PackageJsonChangeError if the file is either of these.
*
* @param {string} file - The name or path of the file to check.
* @throws {PackageJsonChangeError} If the file is 'package.json' or 'package-lock.json'.
*/
function ensureIsNotPackageJsonOrPackageLockJson(file) {
if (file.includes('package.json') || file.includes('package-lock.json')) {
throw new PackageJsonChangeError(file);
}
}

/**
* Checks if a given file depends on any of the specified external Coveo packages.
*
* @param {string} file - The path of the file to check.
* @returns {boolean} - Returns true if the file path includes any of the external package paths, otherwise false.
*/
function dependsOnCoveoPackage(file) {
const externalPackages = ['packages/headless/', 'packages/bueno/'];
for (const pkg of externalPackages) {
if (file.includes(pkg)) {
return true;
}
}
}

/**
* Allocates test shards based on the total number of tests and the maximum number of shards.
*
* @param {number} testCount - The total number of tests.
* @param {number} maximumShards - The maximum number of shards to allocate.
* @returns {[number[], number[]]} An array containing two elements:
* - The first element is an array of shard indices.
* - The second element is an array containing the total number of shards.
*/
function allocateShards(testCount, maximumShards) {
const shardTotal =
testCount === 0 ? maximumShards : Math.min(testCount, maximumShards);
const shardIndex = Array.from({length: shardTotal}, (_, i) => i + 1);
return [shardIndex, [shardTotal]];
}

const {base, head} = getBaseHeadSHAs();
const changedFiles = getChangedFiles(base, head).split(EOL);
const outputNameTestsToRun = process.argv[2];
const outputNameShardIndex = process.argv[3];
const outputNameShardTotal = process.argv[4];
const projectRoot = process.env.projectRoot;
const atomicSourceComponents = join('packages', 'atomic', 'src', 'components');

try {
const testFiles = findAllTestFiles(atomicSourceComponents);
const testDependencies = createTestFileMappings(testFiles, projectRoot);
const testsToRun = determineTestFilesToRun(changedFiles, testDependencies);
if (testsToRun === '') {
throw new NoRelevantChangesError();
}
const maximumShards = parseInt(process.env.maximumShards, 10);

const [shardIndex, shardTotal] = allocateShards(
testsToRun.split(' ').length,
maximumShards
);

setOutput(outputNameTestsToRun, testsToRun);
setOutput(outputNameShardIndex, shardIndex);
setOutput(outputNameShardTotal, shardTotal);
} catch (error) {
if (error instanceof NoRelevantChangesError) {
console.warn(error?.message || error);
setOutput(outputNameTestsToRun, '');
setOutput(outputNameShardIndex, [0]);
setOutput(outputNameShardTotal, [0]);
}

if (
error instanceof DependentPackageChangeError ||
error instanceof PackageJsonChangeError
) {
console.warn(error?.message || error);
setOutput(outputNameTestsToRun, '');
const shardIndex = Array.from(
{length: process.env.maximumShards},
(_, i) => i + 1
);
setOutput(outputNameShardIndex, shardIndex);
const shardTotal = [process.env.maximumShards];
setOutput(outputNameShardTotal, shardTotal);
}
}
Loading
Loading