diff --git a/.github/workflows/changelog-enforcer.yml b/.github/workflows/changelog-enforcer.yml index f22dc1475..77c3545e0 100644 --- a/.github/workflows/changelog-enforcer.yml +++ b/.github/workflows/changelog-enforcer.yml @@ -8,7 +8,7 @@ jobs: changelog: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.2.0 + - uses: actions/checkout@v4.2.1 - uses: dangoslen/changelog-enforcer@v3 with: changeLogPath: 'CHANGELOG.md' diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index a74a82316..c42d81457 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -8,7 +8,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Build docker image working-directory: common/jenkins-agents/golang/docker @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Build docker image working-directory: common/jenkins-agents/jdk/docker @@ -41,7 +41,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Build docker image working-directory: common/jenkins-agents/nodejs16/docker @@ -57,7 +57,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Build docker image working-directory: common/jenkins-agents/nodejs18/docker @@ -73,7 +73,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Build docker image working-directory: common/jenkins-agents/nodejs20/docker @@ -89,7 +89,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Build docker image working-directory: common/jenkins-agents/nodejs22/docker @@ -105,7 +105,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Build docker image working-directory: common/jenkins-agents/python/docker @@ -118,7 +118,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Build docker image working-directory: common/jenkins-agents/rust/docker @@ -138,7 +138,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Build docker image working-directory: common/jenkins-agents/terraform/docker @@ -151,7 +151,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Build docker image working-directory: common/jenkins-agents/terraform-2306/docker @@ -164,7 +164,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Build docker image working-directory: common/jenkins-agents/scala/docker diff --git a/CHANGELOG.md b/CHANGELOG.md index 693a8de6a..044ea3870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - Add microsoft-edge to nodejs agents for using with cypress ([#1063](https://github.com/opendevstack/ods-quickstarters/pull/1063)) - Replaced centos8 repository for AlmaLinux 8 due to deprecation ([#1063](https://github.com/opendevstack/ods-quickstarters/pull/1063)) - Replaced centos8 repository for rockylinux 8 due to deprecation in terraform agents ([#1067](https://github.com/opendevstack/ods-quickstarters/pull/1067)) +- Improvements in the reporter for cypress ([#1042](https://github.com/opendevstack/ods-quickstarters/issues/1042)) ### Added @@ -467,4 +468,4 @@ ## [0.1.0 ods-project-quickstarters] - 2018-07-27 -Initial release. +Initial release. \ No newline at end of file diff --git a/docs/modules/quickstarters/pages/e2e-cypress.adoc b/docs/modules/quickstarters/pages/e2e-cypress.adoc index 279575bcd..6ed365ffe 100644 --- a/docs/modules/quickstarters/pages/e2e-cypress.adoc +++ b/docs/modules/quickstarters/pages/e2e-cypress.adoc @@ -13,7 +13,9 @@ This is a Cypress end-to-end testing project quickstarter with basic setup for h ├── fixtures │ └── example.json ├── plugins -│ └── index.js +│ ├── index.ts +│ ├── screenshot.ts +│ └── screenshot.types.ts ├── reporters │ └── custom-reporter.js ├── support diff --git a/e2e-cypress/Jenkinsfile.template b/e2e-cypress/Jenkinsfile.template index dff307c88..d592732b3 100644 --- a/e2e-cypress/Jenkinsfile.template +++ b/e2e-cypress/Jenkinsfile.template @@ -29,12 +29,24 @@ odsComponentPipeline( // 'release/': 'test' ] ) { context -> - + def targetDirectory = "${context.projectId}/${context.componentId}/${context.gitBranch.replaceAll('/', '-')}/${context.buildNumber}" + stageTest(context) odsComponentStageScanWithSonar(context) - + + if (fileExists('cypress/screenshots.zip')) { + odsComponentStageUploadToNexus(context, + [ + distributionFile: 'cypress/screenshots.zip', + repository: 'leva-documentation', + repositoryType: 'raw', + targetDirectory: "${targetDirectory}" + ] + ) } +} + def stageTest(def context) { stage('Integration Test') { // OPTIONAL: load environment variables for Azure SSO with MSALv2; please adapt variable names to your OpenShift config @@ -54,6 +66,8 @@ def stageTest(def context) { // "CYPRESS_CLIENT_SECRET=${azureClientSecret}", // "CYPRESS_USERNAME=${cypressUser}", // "CYPRESS_PASSWORD=${cypressPassword}" + "COMMIT_INFO_SHA=${context.gitCommit}", + "BUILD_NUMBER=${context.buildNumber}", ]) { sh 'npm install' def status = sh(script: 'npm run e2e', returnStatus: true) @@ -62,14 +76,19 @@ def stageTest(def context) { stash(name: "installation-test-reports-junit-xml-${context.componentId}-${context.buildNumber}", includes: 'build/test-results/installation-junit.xml', allowEmpty: true) stash(name: "integration-test-reports-junit-xml-${context.componentId}-${context.buildNumber}", includes: 'build/test-results/integration-junit.xml', allowEmpty: true) stash(name: "acceptance-test-reports-junit-xml-${context.componentId}-${context.buildNumber}", includes: 'build/test-results/acceptance-junit.xml', allowEmpty: true) - zip zipFile: 'cypress/videos.zip', archive: false, dir: 'cypress/videos' - stash(name: "acceptance-test-videos-${context.componentId}-${context.buildNumber}", includes: 'cypress/videos.zip', allowEmpty: true) - archiveArtifacts artifacts: 'cypress/videos.zip', fingerprint: true, daysToKeep: 2, numToKeep: 3 - if (status != 0) { + + if (fileExists('cypress/videos')) { + zip zipFile: 'cypress/videos.zip', archive: false, dir: 'cypress/videos' + stash(name: "acceptance-test-videos-${context.componentId}-${context.buildNumber}", includes: 'cypress/videos.zip', allowEmpty: true) + archiveArtifacts artifacts: 'cypress/videos.zip', fingerprint: true, daysToKeep: 2, numToKeep: 3 + } + + if (fileExists('cypress/screenshots')) { zip zipFile: 'cypress/screenshots.zip', archive: false, dir: 'cypress/screenshots' stash(name: "acceptance-test-screenshots-${context.componentId}-${context.buildNumber}", includes: 'cypress/screenshots.zip', allowEmpty: true) archiveArtifacts artifacts: 'cypress/screenshots.zip', fingerprint: true, daysToKeep: 2, numToKeep: 3 } + return status } } diff --git a/e2e-cypress/files/cypress-acceptance.config.ts b/e2e-cypress/files/cypress-acceptance.config.ts index e5be54c48..034f50bf1 100644 --- a/e2e-cypress/files/cypress-acceptance.config.ts +++ b/e2e-cypress/files/cypress-acceptance.config.ts @@ -16,8 +16,8 @@ export default defineConfig({ viewportHeight: 660, experimentalModifyObstructiveThirdPartyCode:true, video: true, - setupNodeEvents(on, config) { - return require('./plugins/index.js')(on, config) + async setupNodeEvents(on, config) { + return (await import('./plugins/index')).default(on, config); }, }, }) diff --git a/e2e-cypress/files/cypress-installation.config.ts b/e2e-cypress/files/cypress-installation.config.ts index 75cadcc17..d7d447e9a 100644 --- a/e2e-cypress/files/cypress-installation.config.ts +++ b/e2e-cypress/files/cypress-installation.config.ts @@ -16,8 +16,8 @@ export default defineConfig({ viewportHeight: 660, experimentalModifyObstructiveThirdPartyCode:true, video: true, - setupNodeEvents(on, config) { - return require('./plugins/index.js')(on, config) + async setupNodeEvents(on, config) { + return (await import('./plugins/index')).default(on, config); }, }, }) diff --git a/e2e-cypress/files/cypress-integration.config.ts b/e2e-cypress/files/cypress-integration.config.ts index 3dac0e72f..cd53bd0e9 100644 --- a/e2e-cypress/files/cypress-integration.config.ts +++ b/e2e-cypress/files/cypress-integration.config.ts @@ -16,8 +16,8 @@ export default defineConfig({ viewportHeight: 660, experimentalModifyObstructiveThirdPartyCode:true, video: true, - setupNodeEvents(on, config) { - return require('./plugins/index.js')(on, config) + async setupNodeEvents(on, config) { + return (await import('./plugins/index')).default(on, config); }, }, }) diff --git a/e2e-cypress/files/cypress.config.ts b/e2e-cypress/files/cypress.config.ts index a5274d943..a5dad8e2a 100644 --- a/e2e-cypress/files/cypress.config.ts +++ b/e2e-cypress/files/cypress.config.ts @@ -15,8 +15,8 @@ export default defineConfig({ viewportHeight: 660, experimentalModifyObstructiveThirdPartyCode: true, video: true, - setupNodeEvents(on, config) { - return require('./plugins/index.js')(on, config) + async setupNodeEvents(on, config) { + return (await import('./plugins/index')).default(on, config); }, }, }) diff --git a/e2e-cypress/files/package.json b/e2e-cypress/files/package.json index ddfbceeef..5fdeda39b 100644 --- a/e2e-cypress/files/package.json +++ b/e2e-cypress/files/package.json @@ -25,6 +25,7 @@ "mocha-junit-reporter": "^2.2.1", "npm-run-all": "^4.1.5", "rimraf": "^6.0.1", + "sharp": "^0.33.5", "typescript": "^5.5.4" } } diff --git a/e2e-cypress/files/plugins/index.js b/e2e-cypress/files/plugins/index.ts similarity index 64% rename from e2e-cypress/files/plugins/index.js rename to e2e-cypress/files/plugins/index.ts index 7e2b53f41..95b3d9fde 100644 --- a/e2e-cypress/files/plugins/index.js +++ b/e2e-cypress/files/plugins/index.ts @@ -8,10 +8,13 @@ // https://on.cypress.io/plugins-guide // *********************************************************** +import type { ScreenshotEvidenceData } from './screenshot.types'; +import { addEvidenceMetaToScreenshot } from './screenshot'; + // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) -module.exports = (on, config) => { +const setupNodeEvents: NonNullable = (on, config) => { // `on` is used to hook into various events Cypress emits // `config` is the resolved Cypress config on('task', { @@ -19,5 +22,10 @@ module.exports = (on, config) => { console.log(message); return null; }, + async takeScreenshotEvidence(data: ScreenshotEvidenceData) { + return await addEvidenceMetaToScreenshot(data); + } }); }; + +export default setupNodeEvents; diff --git a/e2e-cypress/files/plugins/screenshot.ts b/e2e-cypress/files/plugins/screenshot.ts new file mode 100644 index 000000000..aa76e7003 --- /dev/null +++ b/e2e-cypress/files/plugins/screenshot.ts @@ -0,0 +1,61 @@ +import { createHash } from 'crypto'; +import { writeFile } from 'fs/promises'; +import * as sharp from 'sharp'; +import { ScreenshotEvidenceData, ScreenshotEvidenceResult } from './screenshot.types'; + +const SCREENSHOT_METADATA = { + height: 50, + margin: 10, + textAlign: 'left', + textColor: '#000', + textSize: 'large', + backgroundColor: '#fff', + maxNameLength: 100, +} as const; +const EVIDENCE_HASH_ALGORITHM = 'sha256' as const; + +export const addEvidenceMetaToScreenshot = async (data: ScreenshotEvidenceData): Promise => { + const metadata: [string, string][] = [ + ['Timestamp', data.takenAt], + ['Testname', data.name.substring(0, SCREENSHOT_METADATA.maxNameLength)], + ['Step', data.step.toString()], + ['Screenshot', data.subStep.toString()], + ['Build number', process.env.BUILD_NUMBER ?? '-'], + ['Git commit', process.env.COMMIT_INFO_SHA ?? '-'], + ]; + + const image = sharp(data.path); + const imageMetadata = await image.metadata(); + const imageBuffer = await image + .resize({ + background: SCREENSHOT_METADATA.backgroundColor, + fit: 'contain', + height: (imageMetadata.height ?? 0) + SCREENSHOT_METADATA.height, + position: 'bottom', + width: imageMetadata.width ?? 0, + }) + .composite([ + { + input: { + text: { + align: SCREENSHOT_METADATA.textAlign, + rgba: true, + text: metadata + .map(([key, value]) => `${key}: ${value}`) + .join('\t'), + width: (imageMetadata.width ?? 0) - SCREENSHOT_METADATA.margin * 2, + }, + }, + left: SCREENSHOT_METADATA.margin, + top: SCREENSHOT_METADATA.margin, + }, + ]) + .toBuffer(); + await writeFile(data.path, imageBuffer); + + const hash = createHash(EVIDENCE_HASH_ALGORITHM).update(imageBuffer).digest('hex'); + return { + hash, + path: data.path, + } +}; diff --git a/e2e-cypress/files/plugins/screenshot.types.ts b/e2e-cypress/files/plugins/screenshot.types.ts new file mode 100644 index 000000000..f03dcf932 --- /dev/null +++ b/e2e-cypress/files/plugins/screenshot.types.ts @@ -0,0 +1,21 @@ +export type ScreenshotEvidenceData = { + name: string; + path: string; + step: number; + subStep: number; + takenAt: string; +}; + +export type ScreenshotEvidenceResult = { + hash: string; + path: string; +}; +export const isScreenshotEvidenceResult = (candidate: unknown): candidate is ScreenshotEvidenceResult => + Boolean( + typeof candidate === 'object' && + candidate && + 'hash' in candidate && + 'path' in candidate && + typeof candidate.hash === 'string' && + typeof candidate.path === 'string' + ); diff --git a/e2e-cypress/files/support/e2e.ts b/e2e-cypress/files/support/e2e.ts index 02c8ec5d6..f42298464 100644 --- a/e2e-cypress/files/support/e2e.ts +++ b/e2e-cypress/files/support/e2e.ts @@ -68,17 +68,10 @@ Cypress.Commands.add('loginToAAD', (username: string, password: string) => { log.end() }) -let consoleLogs: string[] = [] - -Cypress.on('log:added', (options) => { - const message = options.message; - if(message) { - consoleLogs.push(message); - } -}); +export const consoleLogs: string[] = []; beforeEach(function() { - consoleLogs = []; + consoleLogs.splice(0); }) afterEach(function() { @@ -88,5 +81,5 @@ afterEach(function() { cy.writeFile(filePath, consoleLogs.join('\n')); - consoleLogs = []; + consoleLogs.splice(0); }) diff --git a/e2e-cypress/files/support/test-evidence.ts b/e2e-cypress/files/support/test-evidence.ts index c9049c3e1..6db14be4d 100644 --- a/e2e-cypress/files/support/test-evidence.ts +++ b/e2e-cypress/files/support/test-evidence.ts @@ -1,16 +1,70 @@ -export const printTestEvidence = (testName: string, testStep: number, selector: string, description: string) => { +import * as path from 'path'; +import { isScreenshotEvidenceResult, ScreenshotEvidenceData } from "../plugins/screenshot.types"; +import { consoleLogs } from "./e2e"; + +const logEvidence = (name: string, step: number, description: string, evidenceLogs: string[]) => { + cy.url().then(url => { + const logs: string[] = []; + logs.push('====================================='); + logs.push(`Testname: ${name} // step: ${step}`); + logs.push(`URL: ${url}`); + logs.push(`Description: ${description}`); + logs.push('----- Test Evidence starts here ----'); + logs.push(...evidenceLogs); + logs.push('----- Test Evidence ends here ----'); + consoleLogs.push(...logs); + cy.task('log', logs.join('\n')); + }); +} + +export const printTestDOMEvidence = (testName: string, testStep: number, selector: string, description: string) => { if (!selector) { throw new Error('selector must not NOT be undefined'); } - cy.task('log', '====================================='); - cy.task('log', 'Testname: ' + testName + ' // step: ' + testStep); - cy.url().then(urlString => { - cy.task('log', 'URL: ' + urlString); - }); - cy.task('log', 'Description: ' + description); - cy.task('log', '----- Test Evidence starts here ----'); cy.get(selector).then($selectedElement => { - cy.task('log', 'Selector: ' + selector + '\r ' + $selectedElement.get(0).outerHTML); + logEvidence(testName, testStep, description, [`Selector: ${selector}\n ${$selectedElement.get(0).outerHTML}`]); + }); +}; + +export const printTestPlainEvidence = (testName: string, testStep: number, expectedValue: string, actualValue: string, description: string) => { + if (!expectedValue || !actualValue) { + throw new Error('expectedValue and actualValue must not NOT be undefined'); + } + logEvidence(testName, testStep, description, [ + `Expected Result:\n ${String(expectedValue)}`, + `Actual Result:\n ${String(actualValue)}` + ]); +}; + +export const takeScreenshotEvidence = (testName: string, testStep: number, testSubStep: number = 1, description: string, skipMeta = false) => { + cy.wrap(null).then(() => { + const data: Omit & + Partial> = { + name: testName, + step: testStep, + subStep: testSubStep, + }; + + cy.screenshot(`testevidence_${testName}_${testStep}_${testSubStep}`, { + onAfterScreenshot(_, { path, takenAt }) { + data.path = path; + data.takenAt = new Date(takenAt).toISOString(); + }, + }) + .then(() => { + if (!data.path || !data.takenAt || skipMeta) { + return null; + } + cy.task('takeScreenshotEvidence', data); + }) + .then((result) => { + if (!isScreenshotEvidenceResult(result)) { + return null; + } + + logEvidence(testName, testStep, description, [ + `Stored screenshot "${path.basename(result.path)}" with hash (sha256) ${result.hash} taken at ${String(data.takenAt)} as evidence.` + ]); + }); }); - cy.task('log', '----- Test Evidence ends here ----'); }; diff --git a/e2e-cypress/files/tests/acceptance/acceptance.spec.cy.ts b/e2e-cypress/files/tests/acceptance/acceptance.spec.cy.ts index a222e568e..81803b840 100644 --- a/e2e-cypress/files/tests/acceptance/acceptance.spec.cy.ts +++ b/e2e-cypress/files/tests/acceptance/acceptance.spec.cy.ts @@ -1,4 +1,4 @@ -import { printTestEvidence } from '../../support/test-evidence'; +import { printTestDOMEvidence, printTestPlainEvidence, takeScreenshotEvidence } from '../../support/test-evidence'; /* tslint:disable:no-unused-expression */ // describe('ADD login example test', () => { @@ -19,7 +19,12 @@ describe('W3 application test', () => { it('Application is reachable', function () { cy.visit('/html/tryit.asp?filename=tryhtml_basic_paragraphs'); cy.title().should('include', 'Tryit Editor'); - printTestEvidence(this.test.fullTitle(), 1, '#textareaCode', 'code area'); - printTestEvidence(this.test.fullTitle(), 2, '#iframecontainer', 'rendered code area'); + printTestDOMEvidence(this.test.fullTitle(), 1, '#textareaCode', 'code area'); + printTestDOMEvidence(this.test.fullTitle(), 2, '#iframecontainer', 'rendered code area'); + takeScreenshotEvidence(this.test.fullTitle(), 3, 1, 'screenshot'); + takeScreenshotEvidence(this.test.fullTitle(), 3, 2, 'screenshot substep 2'); + cy.title().then(title => { + printTestPlainEvidence(this.test.fullTitle(), 4, title, 'Tryit Editor', 'Title should include Tryit Editor'); + }); }); });