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

ci(atomic): run only affected E2E tests in atomic #4484

Merged
merged 31 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cb387b6
test find-test script
y-lakhdar Oct 2, 2024
1cd1f12
lock
y-lakhdar Oct 2, 2024
bb949c1
add missing file
y-lakhdar Oct 2, 2024
6d1ad28
fix logic
y-lakhdar Oct 2, 2024
d90dd6f
Merge branch 'master' of github.com:coveo/ui-kit into KIT-3560-scope-…
y-lakhdar Oct 2, 2024
086de15
better logic
y-lakhdar Oct 2, 2024
b2139f5
clean code
y-lakhdar Oct 2, 2024
217c91a
add condition to only run on pull request
y-lakhdar Oct 3, 2024
6317509
cleaning
y-lakhdar Oct 3, 2024
6e62c0f
fix recursion
y-lakhdar Oct 3, 2024
4d50c6c
add missing test dependencies
y-lakhdar Oct 3, 2024
5a7925c
update github actions
y-lakhdar Oct 3, 2024
72e8e24
remove console log
y-lakhdar Oct 3, 2024
164f230
handle circular dependencies
y-lakhdar Oct 4, 2024
4c3a190
improve recursion
y-lakhdar Oct 4, 2024
fa7ef6a
compute shards
y-lakhdar Oct 7, 2024
65cccf9
Merge branch 'master' into KIT-3560-scope-tests
y-lakhdar Oct 7, 2024
f346363
Update scripts/ci/find-tests.mjs
y-lakhdar Oct 7, 2024
3e751e8
Merge branch 'master' into KIT-3560-scope-tests
y-lakhdar Oct 7, 2024
00298da
add logs
y-lakhdar Oct 7, 2024
e9c81e1
fix typo
y-lakhdar Oct 7, 2024
4a3e6fb
typo
y-lakhdar Oct 7, 2024
ec2fdbb
add missing actions
y-lakhdar Oct 7, 2024
af7c2ba
reduce workflow time
y-lakhdar Oct 8, 2024
0078040
build after setup
y-lakhdar Oct 8, 2024
1d87c2f
typo
y-lakhdar Oct 8, 2024
14ffefb
add missing checkout
y-lakhdar Oct 8, 2024
66ee70d
update workflow
y-lakhdar Oct 8, 2024
a5dcf64
1 shard per test
y-lakhdar Oct 8, 2024
ff094df
cleaning
y-lakhdar Oct 8, 2024
3b6489d
Merge branch 'master' into KIT-3560-scope-tests
louis-bompart Oct 10, 2024
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
5 changes: 4 additions & 1 deletion .github/actions/playwright-atomic/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ inputs:
shardTotal:
description: 'The total number of shards'
required: true
testsToRun:
description: 'The tests to run'
required: true
uploadArtifacts:
description: 'Whether to upload artifacts'
required: false
Expand All @@ -19,7 +22,7 @@ runs:
working-directory: packages/atomic
shell: bash
- name: Run Playwright tests
run: npx playwright test --shard=${{ inputs.shardIndex }}/${{ inputs.shardTotal }}
run: npx playwright test ${{ inputs.testsToRun }} --shard=${{ inputs.shardIndex }}/${{ inputs.shardTotal }}
working-directory: packages/atomic
shell: bash
- uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4
Expand Down
63 changes: 34 additions & 29 deletions .github/workflows/prbot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,41 +78,45 @@ jobs:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
- uses: ./.github/actions/setup
- uses: ./.github/actions/e2e-atomic-csp
prepare-playwright-atomic:
name: 'Determine Playwright E2E tests to run'
if: ${{ always() && github.event_name == 'pull_request'}}
runs-on: ubuntu-latest
env:
maximumShards: 24
outputs:
testsToRun: ${{ steps.determine-tests.outputs.testsToRun }}
shardIndex: ${{ steps.set-matrix.outputs.shardIndex }}
shardTotal: ${{ steps.set-matrix.outputs.shardTotal }}
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
with:
fetch-depth: 0
- uses: ./.github/actions/setup
- run: npm run build
- name: Identify E2E Test Files to run
id: determine-tests
run: node ./scripts/ci/find-tests.mjs testsToRun
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
playwright-atomic:
name: 'Run Playwright tests for Atomic'
needs: build
needs: prepare-playwright-atomic
if: ${{ always() }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
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: [24]
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]')}}
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
- uses: ./.github/actions/setup
Expand All @@ -121,6 +125,7 @@ jobs:
with:
shardIndex: ${{ matrix.shardIndex }}
shardTotal: ${{ matrix.shardTotal }}
testsToRun: ${{ needs.prepare-playwright-atomic.outputs.testsToRun }}
merge-playwright-reports:
name: 'Merge Playwright reports'
environment: PR Artifacts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export class AtomicRecsList implements InitializableComponent<RecsBindings> {
}

private get recommendationListStateWithAugment() {
// TODO: some changes
y-lakhdar marked this conversation as resolved.
Show resolved Hide resolved
return {
...this.recommendationListState,
firstRequestExecuted:
Expand Down
25 changes: 25 additions & 0 deletions scripts/ci/determine-shard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env node
import {setOutput} from '@actions/core';

function getOutputName() {
return process.argv.slice(2, 4);
}

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 testsToRun = process.env.testsToRun.split(' ');
const maximumShards = parseInt(process.env.maximumShards, 10);

const [shardIndexOutputName, shardTotalOutputName] = getOutputName();
const [shardIndex, shardTotal] = allocateShards(
testsToRun.length,
maximumShards
);

setOutput(shardIndexOutputName, shardIndex);
setOutput(shardTotalOutputName, shardTotal);
125 changes: 125 additions & 0 deletions scripts/ci/find-tests.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/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,
getOutputName,
} from './hasFileChanged.mjs';
import {listImports, ensureFileExists} from './list-imports.mjs';

/**
* Recursively searches for all end-to-end (E2E) test files in a given directory.
* E2E test files are identified by the `.e2e.ts` file extension.
*
* @param dir - The root directory to start the search from.
* @returns An array of strings, each representing the full path to an E2E test file.
*/
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 the set of files they import.
*
* @param testPaths - An array of E2E test file paths.
* @returns A map where each key is a test file name and the value is the set of files it imports.
*/
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 E2E test files to run based on the files that have changed.
*
* @param changedFiles - An array of files that have changed.
* @param testDependencies - A map of test file names to the set of files they import.
* @returns A space-separated string of test files to run.
*/
function determineTestFilesToRun(changedFiles, testDependencies) {
const testsToRun = new Set();
for (const changedFile of changedFiles) {
for (const [testFile, sourceFiles] of testDependencies) {
ensureIsNotCoveoPackage(changedFile);
const isChangedTestFile = testFile === basename(changedFile);
const isAffectedSourceFile = sourceFiles.has(changedFile);
if (isChangedTestFile || isAffectedSourceFile) {
testsToRun.add(testFile);
testDependencies.delete(testFile);
}
}
}
return [...testsToRun].join(' ');
}

function ensureIsNotCoveoPackage(file) {
if (dependsOnCoveoPackage(file)) {
throw new Error('Change detected in an different Coveo package.');
}
}

function dependsOnCoveoPackage(file) {
const externalPackages = ['packages/headless', 'packages/bueno'];
for (const pkg of externalPackages) {
if (file.includes(pkg)) {
return true;
}
}
}

const {base, head} = getBaseHeadSHAs();
const changedFiles = getChangedFiles(base, head).split(EOL);
const outputName = getOutputName();
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);
setOutput(outputName, testsToRun ? testsToRun : '--grep @no-test');

if (!testsToRun) {
console.log('No relevant source file changes detected for E2E tests.');
}
} catch (error) {
console.warn(error?.message || error);
}
6 changes: 3 additions & 3 deletions scripts/ci/hasFileChanged.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {context} from '@actions/github';
import {minimatch} from 'minimatch';
import {execSync} from 'node:child_process';

function getBaseHeadSHAs() {
export function getBaseHeadSHAs() {
switch (context.eventName) {
case 'pull_request':
return {
Expand All @@ -19,7 +19,7 @@ function getBaseHeadSHAs() {
}
}

function getChangedFiles(from, to) {
export function getChangedFiles(from, to) {
return execSync(`git diff --name-only ${from}..${to}`, {
stdio: 'pipe',
encoding: 'utf-8',
Expand All @@ -41,7 +41,7 @@ function checkPatterns(files, patterns) {
return false;
}

function getOutputName() {
export function getOutputName() {
return process.argv[2];
}

Expand Down
97 changes: 97 additions & 0 deletions scripts/ci/list-imports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env node
import {existsSync, readFileSync} from 'fs';
import {join, relative, resolve} from 'path';
import ts from 'typescript';

export function ensureFileExists(filePath) {
if (!existsSync(filePath)) {
throw new Error(`File ${filePath} does not exist.`);
}
}

function getSourceFile(containingFile, fileContent) {
return ts.createSourceFile(
containingFile,
fileContent,
ts.ScriptTarget.ES2021,
true // SetParentNodes - useful for AST transformations
);
}

function getImports(sourceFile, filePath, compilerOptions) {
const imports = new Set();
const alreadyResolved = new Set();

const moduleResolutionHost = {
fileExists: (filePath) => existsSync(filePath),
readFile: (filePath) => {
try {
return readFileSync(filePath, 'utf8');
} catch {
return undefined;
}
},
};

const resolveAndAddImport = (containingFile, importPath) => {
const {resolvedModule} = ts.resolveModuleName(
importPath,
containingFile,
compilerOptions,
moduleResolutionHost
);

if (!resolvedModule) {
return null;
}

imports.add(resolvedModule.resolvedFileName);
return resolvedModule.resolvedFileName;
};

const visit = (node, currentFile) => {
if (ts.isImportDeclaration(node)) {
const importPath = node.moduleSpecifier.getText().slice(1, -1); // Remove quotes
const resolvedFileName =
resolveAndAddImport(currentFile, importPath) ||
resolveAndAddImport(currentFile, join(importPath, 'index')); // Check if the import is from an index file

if (resolvedFileName && !alreadyResolved.has(resolvedFileName)) {
alreadyResolved.add(resolvedFileName);
const fileContent = readFileSync(resolvedFileName, 'utf-8');
const sourceFile = getSourceFile(resolvedFileName, fileContent);
ts.forEachChild(sourceFile, (childNode) =>
visit(childNode, resolvedFileName)
);
}
}
};

ts.forEachChild(sourceFile, (node) => visit(node, filePath));

return Array.from(imports);
}

/**
* Function to extract all import statements from a TypeScript file.
* @param filePath Path to the TypeScript file.
* @returns A list of files that are imported by the input file.
*/
export function listImports(projectRoot, filePath) {
ensureFileExists(filePath);
const fileContent = readFileSync(filePath, 'utf-8');
const sourceFile = getSourceFile(filePath, fileContent);

const compilerOptions = {
target: ts.ScriptTarget.ES2021,
};

const imports = getImports(sourceFile, filePath, compilerOptions);

const resolvedImports = imports.map((importPath) => {
const absolutePath = resolve(importPath);
return relative(projectRoot, absolutePath);
});

return resolvedImports;
}
Loading