From 17b7865cb96a35edcc045dc923e558fa16808cdd Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Wed, 11 Oct 2023 10:44:30 +1000 Subject: [PATCH 1/3] Update test to newer methods --- tests/src/core/testqgscompositionconverter.cpp | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/src/core/testqgscompositionconverter.cpp b/tests/src/core/testqgscompositionconverter.cpp index 65fee245680c..f7133a5ad1f1 100644 --- a/tests/src/core/testqgscompositionconverter.cpp +++ b/tests/src/core/testqgscompositionconverter.cpp @@ -22,7 +22,6 @@ #include "qgscompositionconverter.h" #include "qgsproject.h" #include "qgsreadwritecontext.h" -#include "qgsmultirenderchecker.h" #include "qgslayoutmanager.h" #include "qgslayoutpagecollection.h" #include "qgslayoutitemlabel.h" @@ -54,7 +53,7 @@ class TestQgsCompositionConverter: public QgsTest Q_OBJECT public: - TestQgsCompositionConverter() : QgsTest( QStringLiteral( "Composition Converter Tests" ) ) {} + TestQgsCompositionConverter() : QgsTest( QStringLiteral( "Composition Converter Tests" ), QStringLiteral( "compositionconverter" ) ) {} private slots: void initTestCase();// will be called before the first testfunction is executed. @@ -711,14 +710,16 @@ void TestQgsCompositionConverter::importComposerAtlas() void TestQgsCompositionConverter::checkRenderedImage( QgsLayout *layout, const QString &testName, const int pageNumber ) { - QgsLayoutChecker checker( testName + '_' + QString::number( pageNumber ), layout ); - QSize size( layout->pageCollection()->page( pageNumber )->sizeWithUnits().width() * 3.77, layout->pageCollection()->page( pageNumber )->sizeWithUnits().height() * 3.77 ); - checker.setSize( size ); - checker.setControlPathPrefix( QStringLiteral( "compositionconverter" ) ); - QVERIFY( checker.testLayout( mReport, pageNumber, 0, false ) ); + const QSize size( layout->pageCollection()->page( pageNumber )->sizeWithUnits().width() * 3.77, layout->pageCollection()->page( pageNumber )->sizeWithUnits().height() * 3.77 ); + + QVERIFY( + layoutCheck( + testName + '_' + QString::number( pageNumber ), + layout, + pageNumber, 0, size, 0 ) + ); } - QDomElement TestQgsCompositionConverter::loadComposer( const QString &name ) { QString templatePath( QStringLiteral( TEST_DATA_DIR ) + "/layouts/" + name ); From 2286b6dbfec707341407675cc34e30a2906e5461 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Wed, 11 Oct 2023 10:45:00 +1000 Subject: [PATCH 2/3] Generate a markdown summary of test failures along with existing HTML report --- .../qgsmultirenderchecker.sip.in | 15 ++++- .../auto_generated/qgsrenderchecker.sip.in | 14 +++++ src/core/qgsmultirenderchecker.cpp | 10 ++++ src/core/qgsmultirenderchecker.h | 15 ++++- src/core/qgsrenderchecker.cpp | 26 +++++++++ src/core/qgsrenderchecker.h | 16 ++++++ src/test/qgstest.h | 56 ++++++++++++++++--- 7 files changed, 143 insertions(+), 9 deletions(-) diff --git a/python/core/auto_generated/qgsmultirenderchecker.sip.in b/python/core/auto_generated/qgsmultirenderchecker.sip.in index 5e14d27db78e..5d3fa1d859ef 100644 --- a/python/core/auto_generated/qgsmultirenderchecker.sip.in +++ b/python/core/auto_generated/qgsmultirenderchecker.sip.in @@ -124,9 +124,22 @@ Test using renderer to generate the image to be compared. QString report() const; %Docstring -Returns a report for this test. +Returns a HTML report for this test. The report will be empty if the test was successfully run. + +.. seealso:: :py:func:`markdownReport` +%End + + QString markdownReport() const; +%Docstring +Returns a markdown report for this test. + +The report will be empty if the test was successfully run. + +.. seealso:: :py:func:`report` + +.. versionadded:: 3.34 %End QString controlImagePath() const; diff --git a/python/core/auto_generated/qgsrenderchecker.sip.in b/python/core/auto_generated/qgsrenderchecker.sip.in index 7b52dcc66b90..42c65b30a987 100644 --- a/python/core/auto_generated/qgsrenderchecker.sip.in +++ b/python/core/auto_generated/qgsrenderchecker.sip.in @@ -72,6 +72,20 @@ Returns the HTML report describing the results of the test run. If ``ignoreSuccess`` is ``True`` then the report will always be empty if the test was successful. + +.. seealso:: :py:func:`markdownReport` +%End + + QString markdownReport( bool ignoreSuccess = true ) const; +%Docstring +Returns the markdown report describing the results of the test run. + +If ``ignoreSuccess`` is ``True`` then the report will always be empty if +the test was successful. + +.. seealso:: :py:func:`report` + +.. versionadded:: 3.34 %End float matchPercent() const; diff --git a/src/core/qgsmultirenderchecker.cpp b/src/core/qgsmultirenderchecker.cpp index de4f5e05cd5e..881513782d1c 100644 --- a/src/core/qgsmultirenderchecker.cpp +++ b/src/core/qgsmultirenderchecker.cpp @@ -44,6 +44,7 @@ bool QgsMultiRenderChecker::runTest( const QString &testName, unsigned int misma mResult = false; mReport += "

" + testName + "

\n"; + mMarkdownReport += QStringLiteral( "### %1\n\n" ).arg( testName ); const QString baseDir = controlImagePath(); if ( !QFile::exists( baseDir ) ) @@ -97,6 +98,10 @@ bool QgsMultiRenderChecker::runTest( const QString &testName, unsigned int misma dartMeasurements << checker.dartMeasurements(); mReport += checker.report( false ); + if ( subDirs.count() > 1 ) + mMarkdownReport += QStringLiteral( "* " ) + checker.markdownReport( false ); + else + mMarkdownReport += checker.markdownReport( false ); if ( !mResult && diffImageFile.isEmpty() ) { @@ -173,6 +178,11 @@ QString QgsMultiRenderChecker::report() const return !mResult ? mReport : QString(); } +QString QgsMultiRenderChecker::markdownReport() const +{ + return !mResult ? mMarkdownReport : QString(); +} + QString QgsMultiRenderChecker::controlImagePath() const { QString myDataDir( TEST_DATA_DIR ); //defined in CmakeLists.txt diff --git a/src/core/qgsmultirenderchecker.h b/src/core/qgsmultirenderchecker.h index 5e34ebdaa371..46eacac9a3a0 100644 --- a/src/core/qgsmultirenderchecker.h +++ b/src/core/qgsmultirenderchecker.h @@ -128,12 +128,24 @@ class CORE_EXPORT QgsMultiRenderChecker bool runTest( const QString &testName, unsigned int mismatchCount = 0 ); /** - * Returns a report for this test. + * Returns a HTML report for this test. * * The report will be empty if the test was successfully run. + * + * \see markdownReport() */ QString report() const; + /** + * Returns a markdown report for this test. + * + * The report will be empty if the test was successfully run. + * + * \see report() + * \since QGIS 3.34 + */ + QString markdownReport() const; + /** * Returns the path to the control images. */ @@ -148,6 +160,7 @@ class CORE_EXPORT QgsMultiRenderChecker private: bool mResult = false; QString mReport; + QString mMarkdownReport; QString mRenderedImage; QString mControlName; QString mControlPathPrefix; diff --git a/src/core/qgsrenderchecker.cpp b/src/core/qgsrenderchecker.cpp index 2d3b1f1603ae..9152ed505fbd 100644 --- a/src/core/qgsrenderchecker.cpp +++ b/src/core/qgsrenderchecker.cpp @@ -62,6 +62,11 @@ QString QgsRenderChecker::report( bool ignoreSuccess ) const return ( ( ignoreSuccess && mResult ) || ( mExpectFail && !mResult ) ) ? QString() : mReport; } +QString QgsRenderChecker::markdownReport( bool ignoreSuccess ) const +{ + return ( ( ignoreSuccess && mResult ) || ( mExpectFail && !mResult ) ) ? QString() : mMarkdownReport; +} + void QgsRenderChecker::setControlName( const QString &name ) { mControlName = name; @@ -252,6 +257,7 @@ bool QgsRenderChecker::runTest( const QString &testName, "Test Result:Expected Result:\n" "Nothing rendered\nFailed because Expected " "Image File not set.\n"; + mMarkdownReport = QStringLiteral( "Failed because expected image file not set\n" ); performPostTestActions( flags ); return mResult; } @@ -266,6 +272,7 @@ bool QgsRenderChecker::runTest( const QString &testName, "Test Result:Expected Result:\n" "Nothing rendered\nFailed because Expected " "Image File could not be loaded.\n"; + mMarkdownReport = QStringLiteral( "Failed because expected image file (%1) could not be loaded\n" ).arg( mExpectedImageFile ); performPostTestActions( flags ); return mResult; } @@ -304,6 +311,8 @@ bool QgsRenderChecker::runTest( const QString &testName, "Test Result:Expected Result:\n" "Nothing rendered\nFailed because Rendered " "Image File could not be saved.\n"; + mMarkdownReport = QStringLiteral( "Failed because rendered image file could not be saved to %1\n" ).arg( mRenderedImageFile ); + performPostTestActions( flags ); return mResult; } @@ -340,6 +349,8 @@ bool QgsRenderChecker::compareImages( const QString &testName, "Test Result:Expected Result:\n" "Nothing rendered\nFailed because Expected " "Image File not set.\n"; + mMarkdownReport = QStringLiteral( "Failed because expected image file was not set\n" ); + performPostTestActions( flags ); return mResult; } @@ -365,6 +376,7 @@ bool QgsRenderChecker::compareImages( const QString &testName, const QString &re "Test Result:Expected Result:\n" "Nothing rendered\nFailed because Rendered " "Image File not set.\n"; + mMarkdownReport = QStringLiteral( "Failed because rendered image file was not set\n" ); performPostTestActions( flags ); return mResult; } @@ -380,6 +392,7 @@ bool QgsRenderChecker::compareImages( const QString &testName, const QString &re "Test Result:Expected Result:\n" "Nothing rendered\nFailed because control " "image file could not be loaded.\n"; + mMarkdownReport = QStringLiteral( "Failed because expected image file (%1) could not be loaded\n" ).arg( referenceImageFile ); performPostTestActions( flags ); return mResult; } @@ -400,6 +413,7 @@ bool QgsRenderChecker::compareImages( const QString &testName, const QString &re "Test Result:%1:\n" "Nothing rendered\nFailed because Rendered " "Image File could not be loaded.\n" ).arg( upperFirst( expectedImageString ) ); + mMarkdownReport = QStringLiteral( "Failed because rendered image (%1) could not be loaded\n" ).arg( mRenderedImageFile ); performPostTestActions( flags ); return mResult; } @@ -508,6 +522,11 @@ bool QgsRenderChecker::compareImages( const QString &testName, const QString &re mReport += QLatin1String( "" ); mReport += QStringLiteral( "%1 and %2 for " ).arg( upperFirst( expectedImageString ), renderedImageString ) + testName + " are different dimensions - FAILING!"; mReport += QLatin1String( "" ); + mMarkdownReport += QStringLiteral( "Failed because rendered image and expected image are different dimensions (%1x%2 v2 %3x%4)\n" ) + .arg( myResultImage.width() ) + .arg( myResultImage.height() ) + .arg( expectedImage.width() ) + .arg( expectedImage.height() ); const QString diffSizeImagesString = QString( "" @@ -548,6 +567,8 @@ bool QgsRenderChecker::compareImages( const QString &testName, const QString &re mReport += "Expected image and rendered image for " + testName + " have different formats (8bit format is expected) - FAILING!"; mReport += QLatin1String( "" ); mReport += myImagesString; + + mMarkdownReport += QStringLiteral( "Failed because rendered image and expected image have different formats (8bit format is expected)\n" ); performPostTestActions( flags ); return mResult; } @@ -655,6 +676,9 @@ bool QgsRenderChecker::compareImages( const QString &testName, const QString &re mReport += QLatin1String( "Test failed because render step took too long" ); mReport += QLatin1String( "" ); mReport += myImagesString; + + mMarkdownReport += QStringLiteral( "Test failed because render step took too long\n" ); + performPostTestActions( flags ); return mResult; } @@ -680,6 +704,8 @@ bool QgsRenderChecker::compareImages( const QString &testName, const QString &re mReport += QLatin1String( "" ); mReport += myImagesString; + mMarkdownReport += QStringLiteral( "Rendered image did not match %1 (found %2 pixels different)\n" ).arg( referenceImageFile ).arg( mMismatchCount ); + performPostTestActions( flags ); return mResult; } diff --git a/src/core/qgsrenderchecker.h b/src/core/qgsrenderchecker.h index 367ae541ab9f..d0763e1115d1 100644 --- a/src/core/qgsrenderchecker.h +++ b/src/core/qgsrenderchecker.h @@ -86,9 +86,22 @@ class CORE_EXPORT QgsRenderChecker * * If \a ignoreSuccess is TRUE then the report will always be empty if * the test was successful. + * + * \see markdownReport() */ QString report( bool ignoreSuccess = true ) const; + /** + * Returns the markdown report describing the results of the test run. + * + * If \a ignoreSuccess is TRUE then the report will always be empty if + * the test was successful. + * + * \see report() + * \since QGIS 3.34 + */ + QString markdownReport( bool ignoreSuccess = true ) const; + /** * Returns the percent of pixels which matched the control image. */ @@ -277,7 +290,10 @@ class CORE_EXPORT QgsRenderChecker QVector dartMeasurements() const { return mDashMessages; } protected: + //! HTML format report QString mReport; + //! Markdown report + QString mMarkdownReport; unsigned int mMatchTarget = 0; int mElapsedTime = 0; QString mRenderedImageFile; diff --git a/src/test/qgstest.h b/src/test/qgstest.h index 5f678361392f..f5b1cddbcb08 100644 --- a/src/test/qgstest.h +++ b/src/test/qgstest.h @@ -151,6 +151,8 @@ class TEST_EXPORT QgsTest : public QObject { if ( !mReport.isEmpty() ) writeLocalHtmlReport( mReport ); + if ( !mMarkdownReport.isEmpty() ) + writeMarkdownReport( mMarkdownReport ); } /** @@ -264,7 +266,7 @@ class TEST_EXPORT QgsTest : public QObject const bool result = checker.runTest( name, allowedMismatch ); if ( !result ) { - appendToReport( name, checker.report() ); + appendToReport( name, checker.report(), checker.markdownReport() ); } return result; @@ -290,7 +292,7 @@ class TEST_EXPORT QgsTest : public QObject const bool result = checker.runTest( name, allowedMismatch ); if ( !result ) { - appendToReport( name, checker.report() ); + appendToReport( name, checker.report(), checker.markdownReport() ); } return result; } @@ -308,29 +310,42 @@ class TEST_EXPORT QgsTest : public QObject const bool result = checker.testLayout( report, page, allowedMismatch ); if ( !result ) { - appendToReport( name, report ); + appendToReport( name, report, checker.markdownReport() ); } return result; } /** - * Appends some \a content to the test report. + * Appends some \a html and \a markdown to the test report. * * This should be used only for appending useful information when a test fails. */ - void appendToReport( const QString &testName, const QString &content ) + void appendToReport( const QString &testName, const QString &html, const QString &markdown = QString() ) { QString testIdentifier; if ( QTest::currentDataTag() ) testIdentifier = QStringLiteral( "%1 (%2: %3)" ).arg( testName, QTest::currentTestFunction(), QTest::currentDataTag() ); else testIdentifier = QStringLiteral( "%1 (%2)" ).arg( testName, QTest::currentTestFunction() ); - mReport += QStringLiteral( "

%1

\n" ).arg( testIdentifier ); - mReport += content; + + if ( !html.isEmpty() ) + { + mReport += QStringLiteral( "

%1

\n" ).arg( testIdentifier ); + mReport += html; + } + + const QString markdownContent = markdown.isEmpty() ? html : markdown; + if ( !markdownContent.isEmpty() ) + { + mMarkdownReport += QStringLiteral( "## %1\n\n" ).arg( testIdentifier ); + mMarkdownReport += markdownContent + QStringLiteral( "\n\n" ); + } } private: + QString mMarkdownReport; + /** * Writes out a HTML report to a temporary file for visual comparison * of test results on a local build. @@ -363,6 +378,33 @@ class TEST_EXPORT QgsTest : public QObject } } + /** + * Writes out a markdown report to a temporary file for use on CI runs. + */ + void writeMarkdownReport( const QString &report ) + { + const QDir reportDir = QgsRenderChecker::testReportDir(); + if ( !reportDir.exists() ) + QDir().mkpath( reportDir.path() ); + + const QString reportFile = reportDir.filePath( "summary.md" ); + QFile file( reportFile ); + + QFile::OpenMode mode = QIODevice::WriteOnly; + if ( qgetenv( "QGIS_CONTINUOUS_INTEGRATION_RUN" ) == QStringLiteral( "true" ) + || qgetenv( "QGIS_APPEND_TO_TEST_REPORT" ) == QStringLiteral( "true" ) ) + mode |= QIODevice::Append; + else + mode |= QIODevice::Truncate; + + if ( file.open( mode ) ) + { + QTextStream stream( &file ); + stream << report; + file.close(); + } + } + }; /** From 8e618afc40b251dbb8d13abeb2634b7e18aa2ad6 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Wed, 11 Oct 2023 10:55:51 +1000 Subject: [PATCH 3/3] Add github workflow to upload test failure markdown summary report as a comment to PRs when tests fail --- .github/workflows/run-tests.yml | 9 +++- .github/workflows/write_failure_comment.yml | 55 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/write_failure_comment.yml diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 12700042f270..46a3e77d656f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -416,11 +416,18 @@ jobs: mkdir -p /tmp/minio_tests/test-bucket && chmod -R 777 /tmp/minio_tests docker-compose -f .docker/$DOCKERFILE run qgis-deps /root/QGIS/.docker/docker-qgis-test.sh $TEST_BATCH + - name: Save PR number to test report + if: ${{ failure() }} + env: + PR_NUMBER: ${{ github.event.number }} + run: | + echo $PR_NUMBER > qgis_test_report/pr_number + - name: Archive test results report if: ${{ failure() }} uses: actions/upload-artifact@v3 with: - name: test-results-${{ matrix.distro-version }}-qt${{ matrix.qt-version }} + name: test-results-qt${{ matrix.qt-version }} path: qgis_test_report clang-tidy: diff --git a/.github/workflows/write_failure_comment.yml b/.github/workflows/write_failure_comment.yml new file mode 100644 index 000000000000..ee148ea43520 --- /dev/null +++ b/.github/workflows/write_failure_comment.yml @@ -0,0 +1,55 @@ +name: Write test failure comment + +on: + workflow_run: + workflows: [🧪 QGIS tests] + types: + - completed + +jobs: + strategy: + matrix: + qt-version: [ 5, 6 ] + + on-failure: + download: + runs-on: ubuntu-latest + steps: + - name: 'Download artifact' + uses: actions/github-script@v6 + with: + script: | + let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { + return artifact.name == "test-results-qt${{ matrix.qt-version }}" + })[0]; + let download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + let fs = require('fs'); + fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/test-results-qt${{ matrix.qt-version }}.zip`, Buffer.from(download.data)); + + - name: 'Unzip artifact' + run: unzip test-results-qt${{ matrix.qt-version }}.zip + + - name: 'Post test report markdown summary as comment on PR' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + let fs = require('fs'); + let issue_number = Number(fs.readFileSync('./test-results-qt${{ matrix.qt-version }}')); + let body = String(fs.readFileSync('./summary.md')); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue_number, + body: body + });