├── .github └── workflows │ ├── release-beta.yml │ └── release-main.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE ├── README.md ├── assets ├── failure.png ├── powerautomate-settings.png └── success.png ├── jest.config.js ├── package-lock.json ├── package.json ├── playwright.config.ts ├── scripts └── update-package-version.js ├── src ├── constants │ ├── Images.ts │ ├── PluginDefaults.ts │ ├── baseAdaptiveCard.ts │ ├── baseTable.ts │ └── index.ts ├── index.ts ├── models │ ├── AdaptiveCard.ts │ ├── Table.ts │ ├── TestStatuses.ts │ ├── WebhookType.ts │ └── index.ts ├── processResults.test.ts ├── processResults.ts └── utils │ ├── createTableRow.test.ts │ ├── createTableRow.ts │ ├── getMentions.test.ts │ ├── getMentions.ts │ ├── getNotificationBackground.test.ts │ ├── getNotificationBackground.ts │ ├── getNotificationColor.test.ts │ ├── getNotificationColor.ts │ ├── getNotificationOutcome.ts │ ├── getNotificationTitle.test.ts │ ├── getNotificationTitle.ts │ ├── getTotalStatus.test.ts │ ├── getTotalStatus.ts │ ├── index.ts │ ├── validateWebhookUrl.test.ts │ └── validateWebhookUrl.ts ├── tests ├── fail.spec.ts ├── homepage.spec.ts ├── retry.spec.ts ├── setup │ └── setup.spec.ts ├── speaking.spec.ts └── timeout.spec.ts └── tsconfig.json /.github/workflows/release-beta.yml: -------------------------------------------------------------------------------- 1 | name: Beta release 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: npm 17 | 18 | - name: Install npm dependencies 19 | run: npm ci 20 | 21 | - name: Run tests 22 | run: npm run test:jest 23 | 24 | release: 25 | runs-on: ubuntu-latest 26 | needs: test 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: actions/setup-node@v4 30 | with: 31 | node-version: 20 32 | cache: npm 33 | registry-url: https://registry.npmjs.org 34 | 35 | - name: Install npm dependencies 36 | run: npm ci 37 | 38 | - name: Run build 39 | run: npm run build 40 | 41 | - name: Update the package version 42 | if: github.ref == 'refs/heads/dev' 43 | run: node scripts/update-package-version.js $GITHUB_RUN_ID 44 | 45 | - name: Publish release 46 | run: npm publish --tag next --access public 47 | env: 48 | NODE_AUTH_TOKEN: ${{secrets.NPM_PUBLISH_TOKEN}} 49 | -------------------------------------------------------------------------------- /.github/workflows/release-main.yml: -------------------------------------------------------------------------------- 1 | name: Release main 2 | on: 3 | release: 4 | types: 5 | - published 6 | workflow_dispatch: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: npm 17 | 18 | - name: Install npm dependencies 19 | run: npm ci 20 | 21 | - name: Run tests 22 | run: npm run test:jest 23 | 24 | release: 25 | runs-on: ubuntu-latest 26 | needs: test 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: actions/setup-node@v4 30 | with: 31 | node-version: 20 32 | cache: npm 33 | registry-url: https://registry.npmjs.org 34 | 35 | - name: Install npm dependencies 36 | run: npm ci 37 | 38 | - name: Run build 39 | run: npm run build 40 | 41 | - name: Publish release 42 | run: npm publish 43 | env: 44 | NODE_AUTH_TOKEN: ${{secrets.NPM_PUBLISH_TOKEN}} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules 4 | test-results 5 | playwright-report 6 | coverage 7 | 8 | .env -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | assets 3 | coverage 4 | node_modules 5 | src 6 | test-results 7 | tests 8 | scripts 9 | CODEOWNERS 10 | 11 | playwright.config.ts 12 | tsconfig.json 13 | jest.config.js -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.0.12] 6 | 7 | - [#18](https://github.com/playwright-community/playwright-msteams-reporter/issues/18): Fix issue in MS Teams webhook URL validation 8 | 9 | ## [0.0.11] 10 | 11 | - [#15](https://github.com/playwright-community/playwright-msteams-reporter/pull/15): New option `shouldRun` to control when to report based on suite values 12 | 13 | ## [0.0.10] 14 | 15 | - [#10](https://github.com/playwright-community/playwright-msteams-reporter/issues/10): Update to the `linkToResultsUrl` and `linkUrlOnFailure` options to support a function that returns the URL 16 | 17 | ## [0.0.9] 18 | 19 | - [#7](https://github.com/playwright-community/playwright-msteams-reporter/issues/7): Fix for Power Automate webhook URL validation 20 | 21 | ## [0.0.8] 22 | 23 | - [#4](https://github.com/playwright-community/playwright-msteams-reporter/issues/4): Added support for flaky tests 24 | - [#5](https://github.com/playwright-community/playwright-msteams-reporter/issues/5): Added the `enableEmoji` setting to show an emoji based on the test status 25 | 26 | ## [0.0.7] 27 | 28 | - Included the type definition files 29 | - Updated project information 30 | - [#2](https://github.com/playwright-community/playwright-msteams-reporter/issues/2): Added the `linkUrlOnFailure` and `linkTextOnFailure` options 31 | - [#3](https://github.com/playwright-community/playwright-msteams-reporter/issues/3): Sending duplicate results to the webhook 32 | 33 | ## [0.0.6] 34 | 35 | - Set the default value for the `webhookType` option to `powerautomate`. 36 | 37 | ## [0.0.5] 38 | 39 | - The reporter now supports Microsoft Teams incoming webhooks and Power Automate webhooks. You can configure this in the `webhookType` option. 40 | 41 | ## [0.0.4] 42 | 43 | - Added Microsoft Teams incoming webhook URL validation 44 | - Added Jest tests 45 | 46 | ## [0.0.3] 47 | 48 | - Added `debug` option to show the options that are used, plus the payload that is sent to the Microsoft Teams webhook. 49 | 50 | ## [0.0.2] 51 | 52 | - Update readme with more information on how to use the configuration options. 53 | 54 | ## [0.0.1] 55 | 56 | - Initial release of the `playwright-msteams-reporter`. 57 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @estruyf -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Elio Struyf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Microsoft Teams reporter for Playwright 2 | 3 | [![npm version](https://badge.fury.io/js/playwright-msteams-reporter.svg)](https://badge.fury.io/js/playwright-msteams-reporter) 4 | [![Downloads](https://img.shields.io/npm/dt/playwright-msteams-reporter)](https://www.npmjs.com/package/playwright-msteams-reporter) 5 | ![License](https://img.shields.io/github/license/estruyf/playwright-msteams-reporter) 6 | 7 | This reporter for Playwright allows you to send the test results to a Microsoft Teams channel and mention users on failure. 8 | 9 | Here you can see an example card for successful test results: 10 | 11 | ![Microsoft Teams card for successful test results](./assets/success.png) 12 | 13 | Here you can see an example card for failed test results: 14 | 15 | ![Microsoft Teams card for failed test results](./assets/failure.png) 16 | 17 | ## Prerequisites 18 | 19 | To use this reporter, you must have a Microsoft Teams webhook URL. You can create a webhook URL using the Microsoft Teams Power Automate connector or the Microsoft Teams incoming webhook functionality. 20 | 21 | As the incoming webhook functionality will stop working on October 1, 2024 (extended to December 2025), it is recommended to use the Power Automate connector functionality. 22 | 23 | > **Important**: You need to copy the `webhook URL` from the configuration, as you will need it to configure the reporter. 24 | 25 | > **Info**: The [Retirement of Office 365 connectors within Microsoft Teams](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/) article provides more information on the retirement of the incoming webhook functionality. 26 | 27 | ### Microsoft Teams Power Automate webhook 28 | 29 | To create a Power Automate webhook for Microsoft Teams, you can follow these steps: 30 | 31 | - Start with the following [Post to a channel when a webhook request is received](https://make.preview.powerautomate.com/galleries/public/templates/d271a6f01c2545a28348d8f2cddf4c8f/post-to-a-channel-when-a-webhook-request-is-received) template 32 | - Click continue to use the template 33 | - Click on the **Post your own adaptive card as the Flow bot to a channel** action 34 | - Configure the action with the following settings: 35 | - **Team**: Select the team where you want to post the message 36 | - **Channel**: Select the channel where you want to post the message 37 | 38 | ![Power Automate connector configuration](./assets/powerautomate-settings.png) 39 | 40 | - Click on the **Save** button 41 | - Click on **When a Teams webhook request is received** and copy the **HTTP URL** 42 | 43 | > [!WARNING] 44 | > When using the PowerAutomate template, a template footer will automatically be included like: ` used a Workflow template to send this card. Get template`. 45 | > You can remove this footer by creating a copy of the flow, and use the new one instead. 46 | > You can find more information about this procedure in the following blog post: [How to remove " used a Workflow template to send this card. Get template"](https://docs.hetrixtools.com/microsoft-teams-how-to-remove-name-used-a-workflow-template-to-send-this-card-get-template/). 47 | 48 | ### Microsoft Teams incoming webhook (retiring October 1, 2024) 49 | 50 | To use this reporter, you need to create an incoming webhook for your Microsoft Teams channel. You can find more information on how to do this in the [Microsoft documentation](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook?tabs=newteams%2Cdotnet#create-an-incoming-webhook). 51 | 52 | > **Important**: You need to copy the `webhook URL` from the configuration, as you will need it to configure the reporter. 53 | 54 | ## Installation 55 | 56 | Install from npm: 57 | 58 | ```bash 59 | npm install playwright-msteams-reporter 60 | ``` 61 | 62 | ## Usage 63 | 64 | You can configure the reporter by adding it to the `playwright.config.js` file: 65 | 66 | ```javascript 67 | import { defineConfig } from '@playwright/test'; 68 | import type { MsTeamsReporterOptions } from "playwright-msteams-reporter"; 69 | 70 | export default defineConfig({ 71 | reporter: [ 72 | ['list'], 73 | [ 74 | 'playwright-msteams-reporter', 75 | { 76 | webhookUrl: "", 77 | webhookType: "powerautomate", // or "msteams" 78 | } 79 | ] 80 | ], 81 | }); 82 | ``` 83 | 84 | > More information on how to use reporters can be found in the [Playwright documentation](https://playwright.dev/docs/test-reporters). 85 | 86 | ## Configuration 87 | 88 | The reporter supports the following configuration options: 89 | 90 | | Option | Description | Type | Required | Default | 91 | | --- | --- | --- | --- | --- | 92 | | `webhookUrl` | The Microsoft Teams webhook URL | `boolean` | `true` | `undefined` | 93 | | `webhookType` | The type of the webhook (`msteams` or `powerautomate`) | `string` | `false` | `powerautomate` | 94 | | `title` | The notification title | `string` | `false` | `Playwright Test Results` | 95 | | `linkToResultsUrl` | Link to the test results | `string \| () => string` | `false` | `undefined` | 96 | | `linkToResultsText` | Text for the link to the test results | `string` | `false` | `View test results` | 97 | | `linkUrlOnFailure` | Link to page where you can view, trigger, etc. the failed tests | `string \| () => string` | `false` | `undefined` | 98 | | `linkTextOnFailure` | Text for the failed tests link action | `string` | `false` | `undefined` | 99 | | `notifyOnSuccess` | Notify on success | `boolean` | `false` | `true` | 100 | | `mentionOnFailure` | Mention users on failure (comma separated list) | `string` | `false` | `undefined` | 101 | | `mentionOnFailureText` | Text to mention users on failure | `string` | `false` | `{mentions} please validate the test results.` | 102 | | `enableEmoji` | Show an emoji based on the test status | `boolean` | `false` | `false` | 103 | | `quiet` | Do not show any output in the console | `boolean` | `false` | `false` | 104 | | `debug` | Show debug information | `boolean` | `false` | `false` | 105 | | `shouldRun` | Conditional reporting | ` Suite => boolean` | `false` | `true` | 106 | 107 | ### Mention users 108 | 109 | With the `mentionOnFailure` option, you can mention users in the Microsoft Teams channel when a test fails. You can provide an array of users to mention. 110 | 111 | ### Mention users with the Power Automate connector 112 | 113 | You can mention users by providing their email addresses when using the Power Automate connector. The reporter will replace the `{mentions}` placeholder in the `mentionOnFailureText` with the mentioned users. 114 | 115 | ```javascript 116 | { 117 | mentionOnFailure: "mail1@elio.dev,mail2@elio.dev", 118 | mentionOnFailureText: "{mentions} check those failed tests!" 119 | } 120 | ``` 121 | 122 | ### Mention users with the Microsoft Teams Incoming Webhook 123 | 124 | The format can be either the full name and email (`"Full name "`) or just the email address (`email`). The reporter will replace the `{mentions}` placeholder in the `mentionOnFailureText` with the mentioned users. 125 | 126 | ```javascript 127 | { 128 | mentionOnFailure: "Elio Struyf ,mail@elio.dev", 129 | mentionOnFailureText: "{mentions} check those failed tests!" 130 | } 131 | ``` 132 | 133 | ### Link to the results 134 | 135 | With the `linkToResultsUrl` option, you can provide a link to the test results. For example, you can view the test results on your CI/CD platform. 136 | 137 | ### Conditional reporting (shouldRun) 138 | 139 | Example (report only from jenkins runs - project name set as 'dev__jenkins'): 140 | ```javascript 141 | shouldRun: (suite) => { 142 | if (suite.suites[0].project()?.name.includes('_jenkins')) return true 143 | 144 | return false 145 | } 146 | ``` 147 | 148 | #### Github 149 | 150 | ```javascript 151 | { 152 | // The link to your GitHub Actions workflow run 153 | linkToResultsUrl: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`, 154 | } 155 | ``` 156 | #### Azure Devops 157 | 158 | ```javascript 159 | { 160 | // The link to your Azure DevOps pipeline run (add &view=artifacts&type=publishedArtifacts to access linked artifacts directly) 161 | linkToResultsUrl: `${process.env.AZURE_SERVER_URL}/${process.env.AZURE_PROJECT}/_build/results?buildId=${process.env.AZURE_RUN_ID}`, 162 | } 163 | ``` 164 | 165 | Make sure to provide the environment variables in your Azure DevOps pipeline: 166 | 167 | ```yaml 168 | - script: npx playwright test 169 | displayName: "Run Playwright tests" 170 | name: "playwright" 171 | env: 172 | CI: "true" 173 | AZURE_SERVER_URL: $(System.CollectionUri) 174 | AZURE_PROJECT: $(System.TeamProject) 175 | AZURE_RUN_ID: $(Build.BuildId) 176 | ``` 177 | 178 | ### Combine the reporter with the Playwright Azure Reporter 179 | 180 | You can combine the Microsoft Teams reporter with the Playwright Azure Reporter to link to create a link to the test plan results on Azure DevOps. The following example shows how you can combine both reporters: 181 | 182 | ```typescript 183 | import { defineConfig } from "@playwright/test"; 184 | import type { AzureReporterOptions } from "@alex_neo/playwright-azure-reporter"; 185 | import type { MsTeamsReporterOptions } from "playwright-msteams-reporter"; 186 | 187 | export default defineConfig({ 188 | reporter: [ 189 | ["list"], 190 | // First define the Azure reporter 191 | [ 192 | "@alex_neo/playwright-azure-reporter", 193 | { 194 | ... 195 | }, 196 | ], 197 | // Then define the Microsoft Teams reporter 198 | [ 199 | 'playwright-msteams-reporter', 200 | { 201 | webhookUrl: "", 202 | // Instead of providing the URL directly, you need to provide a function that returns the URL. 203 | // The AZURE_PW_TEST_RUN_ID variable is only available once the Azure reporter has run. 204 | linkToResultsUrl: () => `${process.env.AZURE_SERVER_URL}/${process.env.AZURE_PROJECT}/_testManagement/runs?runId=${process.env.AZURE_PW_TEST_RUN_ID}&_a=runCharts`, 205 | linkToResultsText: "View test plan results", 206 | } 207 | ] 208 | ] 209 | }); 210 | ``` 211 | 212 | Make sure to provide the environment variables in your Azure DevOps pipeline: 213 | 214 | ```yaml 215 | - script: npx playwright test 216 | displayName: "Run Playwright tests" 217 | name: "playwright" 218 | env: 219 | CI: "true" 220 | AZURE_SERVER_URL: $(System.CollectionUri) 221 | AZURE_PROJECT: $(System.TeamProject) 222 | AZURE_RUN_ID: $(Build.BuildId) 223 | ``` 224 | 225 |
226 | 227 | [![Visitors](https://api.visitorbadge.io/api/visitors?path=https%3A%2F%2Fgithub.com%2Festruyf%2Fplaywright-msteams-reporter&countColor=%23263759)](https://visitorbadge.io/status?path=https%3A%2F%2Fgithub.com%2Festruyf%2Fplaywright-msteams-reporter) 228 | -------------------------------------------------------------------------------- /assets/failure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playwright-community/playwright-msteams-reporter/62a2201420e71b5c29bc32bc3050ccb966e7f435/assets/failure.png -------------------------------------------------------------------------------- /assets/powerautomate-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playwright-community/playwright-msteams-reporter/62a2201420e71b5c29bc32bc3050ccb966e7f435/assets/powerautomate-settings.png -------------------------------------------------------------------------------- /assets/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playwright-community/playwright-msteams-reporter/62a2201420e71b5c29bc32bc3050ccb966e7f435/assets/success.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | rootDir: "./src", 6 | testMatch: ['**/*.test.ts'], 7 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playwright-msteams-reporter", 3 | "version": "0.0.12", 4 | "description": "Microsoft Teams reporter for Playwright which allows you to send notifications about the status of your E2E tests.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "watch": "tsc -w", 9 | "test": "NODE_ENV=development npx playwright test", 10 | "test:jest": "jest --coverage --coverageDirectory=../coverage" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/playwright-community/playwright-msteams-reporter.git" 15 | }, 16 | "funding": { 17 | "type": "github", 18 | "url": "https://github.com/sponsors/estruyf" 19 | }, 20 | "keywords": [ 21 | "playwright", 22 | "msteams", 23 | "Microsoft", 24 | "Teams", 25 | "e2e", 26 | "testing" 27 | ], 28 | "author": "Elio Struyf ", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/playwright-community/playwright-msteams-reporter/issues" 32 | }, 33 | "homepage": "https://github.com/playwright-community/playwright-msteams-reporter#readme", 34 | "devDependencies": { 35 | "@playwright/test": "^1.45.0", 36 | "@tsconfig/recommended": "^1.0.7", 37 | "@types/jest": "^29.5.12", 38 | "@types/node": "^20.14.9", 39 | "dotenv": "^16.4.5", 40 | "jest": "^29.7.0", 41 | "ts-jest": "^29.1.5", 42 | "typescript": "^5.5.2" 43 | }, 44 | "peerDependencies": { 45 | "@playwright/test": "^1.45.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { MsTeamsReporterOptions } from "./src"; 2 | import { PlaywrightTestConfig, defineConfig, devices } from "@playwright/test"; 3 | 4 | if (process.env.NODE_ENV === "development") { 5 | require("dotenv").config({ path: ".env" }); 6 | } 7 | 8 | const config: PlaywrightTestConfig<{}, {}> = { 9 | testDir: "./tests", 10 | timeout: 3 * 60 * 1000, 11 | expect: { 12 | timeout: 30 * 1000, 13 | }, 14 | fullyParallel: false, 15 | forbidOnly: !!process.env.CI, 16 | retries: process.env.CI ? 2 : 2, 17 | workers: process.env.CI ? 1 : 1, 18 | reporter: [ 19 | [ 20 | "./src/index.ts", 21 | { 22 | webhookUrl: process.env.FLOW_WEBHOOK_URL, 23 | title: "E2E Test Results", 24 | linkToResultsUrl: "https://eliostruyf.com", 25 | linkToResultsText: "View results", 26 | linkUrlOnFailure: 27 | "https://github.com/playwright-community/playwright-msteams-reporter/issues", 28 | linkTextOnFailure: "Report an issue", 29 | mentionOnFailure: "elio@struyfconsulting.be", 30 | mentionOnFailureText: "", 31 | enableEmoji: false, 32 | debug: true, 33 | shouldRun: () => true, 34 | }, 35 | ], 36 | ], 37 | use: { 38 | actionTimeout: 0, 39 | trace: "on-first-retry", 40 | }, 41 | projects: [ 42 | { 43 | name: "setup", 44 | testMatch: "setup.spec.ts", 45 | }, 46 | { 47 | name: "chromium", 48 | // dependencies: ["setup"], 49 | use: { 50 | ...devices["Desktop Chrome"], 51 | viewport: { width: 1920, height: 1080 }, 52 | }, 53 | }, 54 | ], 55 | }; 56 | 57 | /** 58 | * See https://playwright.dev/docs/test-configuration. 59 | */ 60 | export default defineConfig(config); 61 | -------------------------------------------------------------------------------- /scripts/update-package-version.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const packageJson = require('../package.json'); 4 | packageJson.version += `-beta.${process.argv[process.argv.length - 1].substring(0, 7)}`; 5 | console.log(packageJson.version); 6 | fs.writeFileSync(path.join(path.resolve('.'), 'package.json'), JSON.stringify(packageJson, null, 2)); -------------------------------------------------------------------------------- /src/constants/Images.ts: -------------------------------------------------------------------------------- 1 | export const Images = { 2 | success: `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAFCAIAAAAL5hHIAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAEklEQVQImWOweTCViYGBARkDAB59Abohe/o4AAAAAElFTkSuQmCC`, 3 | flaky: `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAFCAIAAAAL5hHIAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAEUlEQVQImWP4nszDxMDAgIwBGnsBb7ZK8egAAAAASUVORK5CYII=`, 4 | failed: `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAFCAIAAAAL5hHIAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAEUlEQVQImWP45+XJxMDAgIwBHUsBmph35dMAAAAASUVORK5CYII=`, 5 | }; 6 | -------------------------------------------------------------------------------- /src/constants/PluginDefaults.ts: -------------------------------------------------------------------------------- 1 | export const PluginDefaults = { 2 | mentionPlaceholder: "{mentions}", 3 | }; 4 | -------------------------------------------------------------------------------- /src/constants/baseAdaptiveCard.ts: -------------------------------------------------------------------------------- 1 | import { AdaptiveCard } from "../models"; 2 | 3 | export const BaseAdaptiveCard = { 4 | type: "AdaptiveCard", 5 | body: [], 6 | msteams: { 7 | width: "Full", 8 | }, 9 | actions: [], 10 | $schema: "http://adaptivecards.io/schemas/adaptive-card.json", 11 | version: "1.6", 12 | }; 13 | -------------------------------------------------------------------------------- /src/constants/baseTable.ts: -------------------------------------------------------------------------------- 1 | import { Table } from "../models"; 2 | 3 | export const BaseTable: Table = { 4 | type: "Table", 5 | columns: [ 6 | { 7 | width: 2, 8 | }, 9 | { 10 | width: 1, 11 | }, 12 | ], 13 | rows: [], 14 | }; 15 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Images"; 2 | export * from "./PluginDefaults"; 3 | export * from "./baseAdaptiveCard"; 4 | export * from "./baseTable"; 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Reporter, 3 | FullConfig, 4 | Suite, 5 | TestCase, 6 | TestResult, 7 | FullResult, 8 | } from "@playwright/test/reporter"; 9 | import { processResults } from "./processResults"; 10 | import { WebhookType } from "./models"; 11 | 12 | export interface MsTeamsReporterOptions { 13 | webhookUrl?: string; 14 | webhookType?: WebhookType; 15 | title?: string; 16 | linkToResultsUrl?: string | (() => string); 17 | linkToResultsText?: string; 18 | linkUrlOnFailure?: string | (() => string); 19 | linkTextOnFailure?: string; 20 | notifyOnSuccess?: boolean; 21 | mentionOnFailure?: string; 22 | mentionOnFailureText?: string; 23 | enableEmoji?: boolean; 24 | quiet?: boolean; 25 | debug?: boolean; 26 | shouldRun?: ((suite: Suite) => boolean); 27 | } 28 | 29 | export default class MsTeamsReporter implements Reporter { 30 | private suite: Suite | undefined; 31 | 32 | constructor(private options: MsTeamsReporterOptions) { 33 | const defaultOptions: MsTeamsReporterOptions = { 34 | webhookUrl: undefined, 35 | webhookType: "powerautomate", 36 | title: "Playwright Test Results", 37 | linkToResultsUrl: undefined, 38 | linkToResultsText: "View test results", 39 | notifyOnSuccess: true, 40 | mentionOnFailure: undefined, 41 | mentionOnFailureText: "{mentions} please validate the test results.", 42 | enableEmoji: false, 43 | quiet: false, 44 | debug: false, 45 | shouldRun: () => true 46 | }; 47 | 48 | this.options = { ...defaultOptions, ...options }; 49 | 50 | console.log(`Using Microsoft Teams reporter`); 51 | 52 | if (process.env.NODE_ENV === "development" || this.options.debug) { 53 | console.log(`Using development mode`); 54 | console.log(`Options: ${JSON.stringify(this.options, null, 2)}`); 55 | } 56 | } 57 | 58 | onBegin(_: FullConfig, suite: Suite) { 59 | this.suite = suite; 60 | } 61 | 62 | onStdOut( 63 | chunk: string | Buffer, 64 | _: void | TestCase, 65 | __: void | TestResult 66 | ): void { 67 | if (this.options.quiet) { 68 | return; 69 | } 70 | 71 | const text = chunk.toString("utf-8"); 72 | process.stdout.write(text); 73 | } 74 | 75 | onStdErr(chunk: string | Buffer, _: TestCase, __: TestResult) { 76 | if (this.options.quiet) { 77 | return; 78 | } 79 | 80 | const text = chunk.toString("utf-8"); 81 | process.stderr.write(text); 82 | } 83 | 84 | async onEnd(_: FullResult) { 85 | await processResults(this.suite, this.options); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/models/AdaptiveCard.ts: -------------------------------------------------------------------------------- 1 | export interface AdaptiveCard { 2 | type: "AdaptiveCard"; 3 | version: string; 4 | body: any[]; 5 | msteams: { 6 | width?: "Full"; 7 | entities?: { 8 | type: "mention"; 9 | text: string; 10 | mentioned: { 11 | id: string; 12 | name: string; 13 | }; 14 | }[]; 15 | }; 16 | actions: any[]; 17 | } 18 | -------------------------------------------------------------------------------- /src/models/Table.ts: -------------------------------------------------------------------------------- 1 | export interface Table { 2 | type: "Table"; 3 | columns: Column[]; 4 | rows: TableRow[]; 5 | } 6 | 7 | export interface Column { 8 | width: number; 9 | } 10 | 11 | export interface TableRow { 12 | type: "TableRow"; 13 | cells: TableCell[]; 14 | } 15 | 16 | export interface TableCell { 17 | type: "TableCell"; 18 | items: TextBlock[]; 19 | style?: TableCellStyle; 20 | } 21 | 22 | export type TableCellStyle = "attention" | "good" | "warning" | "accent"; 23 | 24 | export interface TextBlock { 25 | type: "TextBlock"; 26 | text: string; 27 | wrap?: boolean; 28 | isSubtle?: boolean; 29 | weight?: "Bolder"; 30 | } 31 | -------------------------------------------------------------------------------- /src/models/TestStatuses.ts: -------------------------------------------------------------------------------- 1 | export interface TestStatuses { 2 | passed: number; 3 | flaky: number; 4 | failed: number; 5 | skipped: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/models/WebhookType.ts: -------------------------------------------------------------------------------- 1 | export type WebhookType = "msteams" | "powerautomate"; 2 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./AdaptiveCard"; 2 | export * from "./Table"; 3 | export * from "./TestStatuses"; 4 | export * from "./WebhookType"; 5 | -------------------------------------------------------------------------------- /src/processResults.test.ts: -------------------------------------------------------------------------------- 1 | import { MsTeamsReporterOptions } from "."; 2 | import { processResults } from "./processResults"; 3 | 4 | const MSTEAMS_WEBHOOK_URL = `https://tenant.webhook.office.com/webhookb2/12345678-1234-1234-1234-123456789abc@12345678-1234-1234-1234-123456789abc/IncomingWebhook/123456789abcdef123456789abcdef12/12345678-1234-1234-1234-123456789abc`; 5 | const FLOW_WEBHOOK_URL = `https://prod-00.westus.logic.azure.com:443/workflows/1234567890abcdef1234567890abcdef/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=1234567890abcdef1234567890abcdef`; 6 | 7 | const DEFAULT_OPTIONS: MsTeamsReporterOptions = { 8 | webhookUrl: undefined, 9 | webhookType: "powerautomate", 10 | title: "Playwright Test Results", 11 | linkToResultsUrl: undefined, 12 | linkToResultsText: "View test results", 13 | notifyOnSuccess: true, 14 | mentionOnFailure: undefined, 15 | mentionOnFailureText: "{mentions} please validate the test results.", 16 | quiet: false, 17 | debug: false, 18 | shouldRun: () => true 19 | }; 20 | 21 | const SUITE_MOCK_PASSED = { 22 | suites: [ 23 | { 24 | allTests: () => [{ outcome: () => "expected" }], 25 | }, 26 | { 27 | allTests: () => [ 28 | { outcome: () => "expected" }, 29 | { outcome: () => "expected" }, 30 | ], 31 | }, 32 | ], 33 | allTests: () => [{}, {}, {}], 34 | }; 35 | 36 | const SUITE_MOCK_FLAKY = { 37 | suites: [ 38 | { 39 | allTests: () => [{ outcome: () => "expected" }], 40 | }, 41 | { 42 | allTests: () => [ 43 | { outcome: () => "expected" }, 44 | { outcome: () => "expected" }, 45 | ], 46 | }, 47 | { 48 | allTests: () => [{ outcome: () => "flaky" }], 49 | }, 50 | ], 51 | allTests: () => [{}, {}, {}], 52 | }; 53 | 54 | const SUITE_MOCK_FAILED = { 55 | suites: [ 56 | { 57 | allTests: () => [{ outcome: () => "unexpected" }], 58 | }, 59 | { 60 | allTests: () => [ 61 | { outcome: () => "expected" }, 62 | { outcome: () => "expected" }, 63 | ], 64 | }, 65 | ], 66 | allTests: () => [{}, {}, {}], 67 | }; 68 | 69 | describe("processResults", () => { 70 | it("should return early if no webhook URL is provided", async () => { 71 | const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); 72 | const options = { 73 | ...DEFAULT_OPTIONS, 74 | webhookUrl: undefined, 75 | }; 76 | await processResults(undefined, options); 77 | expect(consoleErrorSpy).toHaveBeenCalledWith("No webhook URL provided"); 78 | 79 | consoleErrorSpy.mockReset(); 80 | }); 81 | 82 | it("should return early if an invalid webhook URL is provided", async () => { 83 | const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); 84 | const options = { 85 | ...DEFAULT_OPTIONS, 86 | webhookUrl: "invalid-url", 87 | }; 88 | await processResults(undefined, options); 89 | expect(consoleErrorSpy).toHaveBeenCalledWith("Invalid webhook URL"); 90 | 91 | consoleErrorSpy.mockReset(); 92 | }); 93 | 94 | it("should return early if no test suite is found", async () => { 95 | const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); 96 | const options: MsTeamsReporterOptions = { 97 | ...DEFAULT_OPTIONS, 98 | webhookUrl: MSTEAMS_WEBHOOK_URL, 99 | webhookType: "msteams", 100 | }; 101 | await processResults(undefined, options); 102 | expect(consoleErrorSpy).toHaveBeenCalledWith("No test suite found"); 103 | 104 | consoleErrorSpy.mockReset(); 105 | }); 106 | 107 | it("should skip notification if there are no failed tests and notifyOnSuccess is false", async () => { 108 | const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(); 109 | const options: MsTeamsReporterOptions = { 110 | ...DEFAULT_OPTIONS, 111 | webhookUrl: MSTEAMS_WEBHOOK_URL, 112 | webhookType: "msteams", 113 | notifyOnSuccess: false, 114 | }; 115 | const suite: any = { 116 | suites: [], 117 | allTests: () => [], 118 | }; 119 | await processResults(suite, options); 120 | expect(consoleLogSpy).toHaveBeenCalledWith( 121 | "No failed tests, skipping notification" 122 | ); 123 | 124 | consoleLogSpy.mockReset(); 125 | }); 126 | 127 | it("should return early if shouldRun is false", async () => { 128 | const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(); 129 | 130 | const fetchMock = jest.fn(); 131 | global.fetch = fetchMock; 132 | const options = { 133 | ...DEFAULT_OPTIONS, 134 | webhookUrl: undefined, 135 | shouldRun: () => false 136 | }; 137 | await processResults(undefined, options); 138 | expect(fetchMock).not.toHaveBeenCalled(); 139 | expect(consoleLogSpy).not.toHaveBeenCalled(); 140 | 141 | consoleLogSpy.mockReset(); 142 | }); 143 | 144 | it("should send a message successfully", async () => { 145 | const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(); 146 | const fetchMock = jest 147 | .fn() 148 | .mockResolvedValue({ ok: true, text: () => "1" }); 149 | global.fetch = fetchMock; 150 | const options: MsTeamsReporterOptions = { 151 | ...DEFAULT_OPTIONS, 152 | webhookUrl: MSTEAMS_WEBHOOK_URL, 153 | webhookType: "msteams", 154 | }; 155 | await processResults(SUITE_MOCK_PASSED as any, options); 156 | expect(fetchMock).toHaveBeenCalledWith( 157 | MSTEAMS_WEBHOOK_URL, 158 | expect.any(Object) 159 | ); 160 | expect(consoleLogSpy).toHaveBeenCalledWith("Message sent successfully"); 161 | 162 | consoleLogSpy.mockReset(); 163 | }); 164 | 165 | it("should send a message successfully with API outcome", async () => { 166 | const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(); 167 | const fetchMock = jest 168 | .fn() 169 | .mockResolvedValue({ ok: true, text: () => "Some fake message" }); 170 | global.fetch = fetchMock; 171 | const options: MsTeamsReporterOptions = { 172 | ...DEFAULT_OPTIONS, 173 | webhookUrl: MSTEAMS_WEBHOOK_URL, 174 | webhookType: "msteams", 175 | }; 176 | await processResults(SUITE_MOCK_PASSED as any, options); 177 | expect(consoleLogSpy).toHaveBeenCalledWith("Some fake message"); 178 | 179 | consoleLogSpy.mockReset(); 180 | }); 181 | 182 | it("should log an error if sending the message fails", async () => { 183 | const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); 184 | const fetchMock = jest 185 | .fn() 186 | .mockResolvedValue({ ok: false, text: () => "Error" }); 187 | global.fetch = fetchMock; 188 | const options: MsTeamsReporterOptions = { 189 | ...DEFAULT_OPTIONS, 190 | webhookUrl: MSTEAMS_WEBHOOK_URL, 191 | webhookType: "msteams", 192 | }; 193 | await processResults(SUITE_MOCK_PASSED as any, options); 194 | expect(fetchMock).toHaveBeenCalledWith( 195 | MSTEAMS_WEBHOOK_URL, 196 | expect.any(Object) 197 | ); 198 | expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to send message"); 199 | expect(consoleErrorSpy).toHaveBeenCalledWith("Error"); 200 | 201 | consoleErrorSpy.mockReset(); 202 | }); 203 | 204 | it("should include a flaky row", async () => { 205 | const consoleLogSpy = jest 206 | .spyOn(console, "log") 207 | .mockImplementation((message) => { 208 | if (message.includes("message") && message.includes("Flaky")) { 209 | console.log(`Flaky`); 210 | } 211 | }); 212 | const fetchMock = jest 213 | .fn() 214 | .mockResolvedValue({ ok: true, text: () => "1" }); 215 | global.fetch = fetchMock; 216 | const options: MsTeamsReporterOptions = { 217 | ...DEFAULT_OPTIONS, 218 | webhookUrl: FLOW_WEBHOOK_URL, 219 | webhookType: "powerautomate", 220 | debug: true, 221 | }; 222 | await processResults(SUITE_MOCK_FLAKY as any, options); 223 | expect(consoleLogSpy).toHaveBeenCalledWith("Flaky"); 224 | 225 | consoleLogSpy.mockReset(); 226 | }); 227 | 228 | it("should use version 1.4 for adaptive card", async () => { 229 | const consoleLogSpy = jest 230 | .spyOn(console, "log") 231 | .mockImplementation((message) => { 232 | if (message.includes("message") && message.includes("version")) { 233 | const msgBody = JSON.parse(message); 234 | console.log(`version: ${msgBody.attachments[0].content.version}`); 235 | } 236 | }); 237 | const fetchMock = jest 238 | .fn() 239 | .mockResolvedValue({ ok: true, text: () => "1" }); 240 | global.fetch = fetchMock; 241 | const options: MsTeamsReporterOptions = { 242 | ...DEFAULT_OPTIONS, 243 | webhookUrl: FLOW_WEBHOOK_URL, 244 | webhookType: "powerautomate", 245 | debug: true, 246 | }; 247 | await processResults(SUITE_MOCK_PASSED as any, options); 248 | expect(consoleLogSpy).toHaveBeenCalledWith("version: 1.4"); 249 | 250 | consoleLogSpy.mockReset(); 251 | }); 252 | 253 | it("should include mentions", async () => { 254 | const fakeEmail = "fake@mail.be"; 255 | const consoleLogSpy = jest 256 | .spyOn(console, "log") 257 | .mockImplementation((message) => { 258 | if ( 259 | message.includes("message") && 260 | message.includes(`${fakeEmail}`) 261 | ) { 262 | console.log(`${fakeEmail}`); 263 | } 264 | }); 265 | const fetchMock = jest 266 | .fn() 267 | .mockResolvedValue({ ok: true, text: () => "1" }); 268 | global.fetch = fetchMock; 269 | const options: MsTeamsReporterOptions = { 270 | ...DEFAULT_OPTIONS, 271 | webhookUrl: FLOW_WEBHOOK_URL, 272 | webhookType: "powerautomate", 273 | mentionOnFailure: fakeEmail, 274 | debug: true, 275 | }; 276 | await processResults(SUITE_MOCK_FAILED as any, options); 277 | expect(consoleLogSpy).toHaveBeenCalledWith(`${fakeEmail}`); 278 | 279 | consoleLogSpy.mockReset(); 280 | }); 281 | 282 | it("should include mention message", async () => { 283 | const fakeEmail = "fake@mail.be"; 284 | const fakeMessage = " validate the tests."; 285 | const consoleLogSpy = jest 286 | .spyOn(console, "log") 287 | .mockImplementation((message) => { 288 | if ( 289 | message.includes("message") && 290 | message.includes(`${fakeEmail}`) && 291 | message.includes(fakeMessage) 292 | ) { 293 | console.log(fakeMessage); 294 | } 295 | }); 296 | const fetchMock = jest 297 | .fn() 298 | .mockResolvedValue({ ok: true, text: () => "1" }); 299 | global.fetch = fetchMock; 300 | const options: MsTeamsReporterOptions = { 301 | ...DEFAULT_OPTIONS, 302 | webhookUrl: FLOW_WEBHOOK_URL, 303 | webhookType: "powerautomate", 304 | mentionOnFailure: fakeEmail, 305 | mentionOnFailureText: `{mentions}${fakeMessage}`, 306 | debug: true, 307 | }; 308 | await processResults(SUITE_MOCK_FAILED as any, options); 309 | expect(consoleLogSpy).toHaveBeenCalledWith(fakeMessage); 310 | 311 | consoleLogSpy.mockReset(); 312 | }); 313 | 314 | it("should include the link", async () => { 315 | const fakeLink = "https://github.com/estruyf/playwright-msteams-reporter"; 316 | const consoleLogSpy = jest 317 | .spyOn(console, "log") 318 | .mockImplementation((message) => { 319 | if (message.includes("message") && message.includes(fakeLink)) { 320 | console.log(fakeLink); 321 | } 322 | }); 323 | const fetchMock = jest 324 | .fn() 325 | .mockResolvedValue({ ok: true, text: () => "1" }); 326 | global.fetch = fetchMock; 327 | const options: MsTeamsReporterOptions = { 328 | ...DEFAULT_OPTIONS, 329 | webhookUrl: FLOW_WEBHOOK_URL, 330 | webhookType: "powerautomate", 331 | linkToResultsUrl: fakeLink, 332 | debug: true, 333 | }; 334 | await processResults(SUITE_MOCK_FAILED as any, options); 335 | expect(consoleLogSpy).toHaveBeenCalledWith(fakeLink); 336 | 337 | consoleLogSpy.mockReset(); 338 | }); 339 | 340 | it("should include the link from the function", async () => { 341 | const fakeLink = "https://github.com/estruyf/playwright-msteams-reporter"; 342 | const consoleLogSpy = jest 343 | .spyOn(console, "log") 344 | .mockImplementation((message) => { 345 | if (message.includes("message") && message.includes(fakeLink)) { 346 | console.log(fakeLink); 347 | } 348 | }); 349 | const fetchMock = jest 350 | .fn() 351 | .mockResolvedValue({ ok: true, text: () => "1" }); 352 | global.fetch = fetchMock; 353 | const options: MsTeamsReporterOptions = { 354 | ...DEFAULT_OPTIONS, 355 | webhookUrl: FLOW_WEBHOOK_URL, 356 | webhookType: "powerautomate", 357 | linkToResultsUrl: () => fakeLink, 358 | debug: true, 359 | }; 360 | await processResults(SUITE_MOCK_FAILED as any, options); 361 | expect(consoleLogSpy).toHaveBeenCalledWith(fakeLink); 362 | 363 | consoleLogSpy.mockReset(); 364 | }); 365 | 366 | it("should not include the link from the function when not a string (number)", async () => { 367 | const fakeLink = 123; 368 | const consoleLogSpy = jest 369 | .spyOn(console, "log") 370 | .mockImplementation((message) => { 371 | if (message.includes("message") && !message.includes(fakeLink)) { 372 | console.log(`Did not include ${fakeLink}`); 373 | } 374 | }); 375 | const fetchMock = jest 376 | .fn() 377 | .mockResolvedValue({ ok: true, text: () => "1" }); 378 | global.fetch = fetchMock; 379 | const options: MsTeamsReporterOptions = { 380 | ...DEFAULT_OPTIONS, 381 | webhookUrl: FLOW_WEBHOOK_URL, 382 | webhookType: "powerautomate", 383 | linkToResultsUrl: (): any => fakeLink, 384 | debug: true, 385 | }; 386 | await processResults(SUITE_MOCK_FAILED as any, options); 387 | expect(consoleLogSpy).toHaveBeenCalledWith(`Did not include ${fakeLink}`); 388 | 389 | consoleLogSpy.mockReset(); 390 | }); 391 | 392 | it("should not include the link from the function when not a string (undefined)", async () => { 393 | const fakeLink = undefined; 394 | const consoleLogSpy = jest 395 | .spyOn(console, "log") 396 | .mockImplementation((message) => { 397 | if (message.includes("message") && !message.includes(fakeLink)) { 398 | console.log(`Did not include ${fakeLink}`); 399 | } 400 | }); 401 | const fetchMock = jest 402 | .fn() 403 | .mockResolvedValue({ ok: true, text: () => "1" }); 404 | global.fetch = fetchMock; 405 | const options: MsTeamsReporterOptions = { 406 | ...DEFAULT_OPTIONS, 407 | webhookUrl: FLOW_WEBHOOK_URL, 408 | webhookType: "powerautomate", 409 | linkToResultsUrl: (): any => fakeLink, 410 | debug: true, 411 | }; 412 | await processResults(SUITE_MOCK_FAILED as any, options); 413 | expect(consoleLogSpy).toHaveBeenCalledWith(`Did not include ${fakeLink}`); 414 | 415 | consoleLogSpy.mockReset(); 416 | }); 417 | 418 | it("should include the failure link", async () => { 419 | const fakeFailureLink = 420 | "https://github.com/estruyf/playwright-msteams-reporter"; 421 | const fakeFailureText = "View the failed tests"; 422 | const consoleLogSpy = jest 423 | .spyOn(console, "log") 424 | .mockImplementation((message) => { 425 | if ( 426 | message.includes("message") && 427 | message.includes(fakeFailureLink) && 428 | message.includes(fakeFailureText) 429 | ) { 430 | console.log(fakeFailureText); 431 | } 432 | }); 433 | const fetchMock = jest 434 | .fn() 435 | .mockResolvedValue({ ok: true, text: () => "1" }); 436 | global.fetch = fetchMock; 437 | const options: MsTeamsReporterOptions = { 438 | ...DEFAULT_OPTIONS, 439 | webhookUrl: FLOW_WEBHOOK_URL, 440 | webhookType: "powerautomate", 441 | linkUrlOnFailure: fakeFailureLink, 442 | linkTextOnFailure: "View the failed tests", 443 | debug: true, 444 | }; 445 | await processResults(SUITE_MOCK_FAILED as any, options); 446 | expect(consoleLogSpy).toHaveBeenCalledWith(fakeFailureText); 447 | 448 | consoleLogSpy.mockReset(); 449 | }); 450 | 451 | it("should include the failure link from the function", async () => { 452 | const fakeFailureLink = 453 | "https://github.com/estruyf/playwright-msteams-reporter"; 454 | const fakeFailureText = "View the failed tests"; 455 | const consoleLogSpy = jest 456 | .spyOn(console, "log") 457 | .mockImplementation((message) => { 458 | if ( 459 | message.includes("message") && 460 | message.includes(fakeFailureLink) && 461 | message.includes(fakeFailureText) 462 | ) { 463 | console.log(fakeFailureText); 464 | } 465 | }); 466 | const fetchMock = jest 467 | .fn() 468 | .mockResolvedValue({ ok: true, text: () => "1" }); 469 | global.fetch = fetchMock; 470 | const options: MsTeamsReporterOptions = { 471 | ...DEFAULT_OPTIONS, 472 | webhookUrl: FLOW_WEBHOOK_URL, 473 | webhookType: "powerautomate", 474 | linkUrlOnFailure: () => fakeFailureLink, 475 | linkTextOnFailure: "View the failed tests", 476 | debug: true, 477 | }; 478 | await processResults(SUITE_MOCK_FAILED as any, options); 479 | expect(consoleLogSpy).toHaveBeenCalledWith(fakeFailureText); 480 | 481 | consoleLogSpy.mockReset(); 482 | }); 483 | 484 | it("should not include the failure link from the function when not a string (number)", async () => { 485 | const fakeFailureLink = 123; 486 | const fakeFailureText = "View the failed tests"; 487 | const consoleLogSpy = jest 488 | .spyOn(console, "log") 489 | .mockImplementation((message) => { 490 | if ( 491 | message.includes("message") && 492 | !message.includes(fakeFailureLink) && 493 | !message.includes(fakeFailureText) 494 | ) { 495 | console.log(`Did not include ${fakeFailureLink}`); 496 | } 497 | }); 498 | const fetchMock = jest 499 | .fn() 500 | .mockResolvedValue({ ok: true, text: () => "1" }); 501 | global.fetch = fetchMock; 502 | const options: MsTeamsReporterOptions = { 503 | ...DEFAULT_OPTIONS, 504 | webhookUrl: FLOW_WEBHOOK_URL, 505 | webhookType: "powerautomate", 506 | linkUrlOnFailure: (): any => fakeFailureLink, 507 | linkTextOnFailure: "View the failed tests", 508 | debug: true, 509 | }; 510 | await processResults(SUITE_MOCK_FAILED as any, options); 511 | expect(consoleLogSpy).toHaveBeenCalledWith( 512 | `Did not include ${fakeFailureLink}` 513 | ); 514 | 515 | consoleLogSpy.mockReset(); 516 | }); 517 | 518 | it("should not include the failure link from the function when not a string (undefined)", async () => { 519 | const fakeFailureLink = undefined; 520 | const fakeFailureText = "View the failed tests"; 521 | const consoleLogSpy = jest 522 | .spyOn(console, "log") 523 | .mockImplementation((message) => { 524 | if ( 525 | message.includes("message") && 526 | !message.includes(fakeFailureLink) && 527 | !message.includes(fakeFailureText) 528 | ) { 529 | console.log(`Did not include ${fakeFailureLink}`); 530 | } 531 | }); 532 | const fetchMock = jest 533 | .fn() 534 | .mockResolvedValue({ ok: true, text: () => "1" }); 535 | global.fetch = fetchMock; 536 | const options: MsTeamsReporterOptions = { 537 | ...DEFAULT_OPTIONS, 538 | webhookUrl: FLOW_WEBHOOK_URL, 539 | webhookType: "powerautomate", 540 | linkUrlOnFailure: (): any => fakeFailureLink, 541 | linkTextOnFailure: "View the failed tests", 542 | debug: true, 543 | }; 544 | await processResults(SUITE_MOCK_FAILED as any, options); 545 | expect(consoleLogSpy).toHaveBeenCalledWith( 546 | `Did not include ${fakeFailureLink}` 547 | ); 548 | 549 | consoleLogSpy.mockReset(); 550 | }); 551 | 552 | it("should show debug message", async () => { 553 | const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(); 554 | const fetchMock = jest 555 | .fn() 556 | .mockResolvedValue({ ok: true, text: () => "1" }); 557 | global.fetch = fetchMock; 558 | const options: MsTeamsReporterOptions = { 559 | ...DEFAULT_OPTIONS, 560 | webhookUrl: MSTEAMS_WEBHOOK_URL, 561 | webhookType: "msteams", 562 | debug: true, 563 | }; 564 | await processResults(SUITE_MOCK_PASSED as any, options); 565 | expect(consoleLogSpy).toHaveBeenCalledWith( 566 | "Sending the following message:" 567 | ); 568 | 569 | consoleLogSpy.mockReset(); 570 | }); 571 | }); 572 | -------------------------------------------------------------------------------- /src/processResults.ts: -------------------------------------------------------------------------------- 1 | import { Suite } from "@playwright/test/reporter"; 2 | import { MsTeamsReporterOptions } from "."; 3 | import { 4 | createTableRow, 5 | getMentions, 6 | getNotificationBackground, 7 | getNotificationColor, 8 | getNotificationTitle, 9 | getTotalStatus, 10 | validateWebhookUrl, 11 | } from "./utils"; 12 | import { BaseAdaptiveCard, BaseTable } from "./constants"; 13 | 14 | export const processResults = async ( 15 | suite: Suite | undefined, 16 | options: MsTeamsReporterOptions 17 | ) => { 18 | if (!options.webhookUrl) { 19 | console.error("No webhook URL provided"); 20 | return; 21 | } 22 | 23 | if (!validateWebhookUrl(options.webhookUrl, options.webhookType)) { 24 | console.error("Invalid webhook URL"); 25 | return; 26 | } 27 | 28 | if (!suite) { 29 | console.error("No test suite found"); 30 | return; 31 | } 32 | 33 | if (options.shouldRun && !options?.shouldRun(suite)) return 34 | 35 | // Clone the base adaptive card and table 36 | const adaptiveCard = structuredClone(BaseAdaptiveCard); 37 | const table = structuredClone(BaseTable); 38 | 39 | const totalStatus = getTotalStatus(suite.suites); 40 | const totalTests = suite.allTests().length; 41 | const isSuccess = totalStatus.failed === 0; 42 | 43 | if (isSuccess && !options.notifyOnSuccess) { 44 | if (!options.quiet) { 45 | console.log("No failed tests, skipping notification"); 46 | } 47 | return; 48 | } 49 | 50 | table.rows.push(createTableRow("Type", "Total")); 51 | 52 | table.rows.push( 53 | createTableRow( 54 | `${options.enableEmoji ? "✅ " : ""}Passed`, 55 | totalStatus.passed, 56 | { style: "good" } 57 | ) 58 | ); 59 | if (totalStatus.flaky) { 60 | table.rows.push( 61 | createTableRow( 62 | `${options.enableEmoji ? "⚠️ " : ""}Flaky`, 63 | totalStatus.flaky, 64 | { style: "warning" } 65 | ) 66 | ); 67 | } 68 | table.rows.push( 69 | createTableRow( 70 | `${options.enableEmoji ? "❌ " : ""}Failed`, 71 | totalStatus.failed, 72 | { style: "attention" } 73 | ) 74 | ); 75 | table.rows.push( 76 | createTableRow( 77 | `${options.enableEmoji ? "⏭️ " : ""}Skipped`, 78 | totalStatus.skipped, 79 | { style: "accent" } 80 | ) 81 | ); 82 | table.rows.push( 83 | createTableRow("Total tests", totalTests, { 84 | isSubtle: true, 85 | weight: "Bolder", 86 | }) 87 | ); 88 | 89 | const container = { 90 | type: "Container", 91 | items: [ 92 | { 93 | type: "TextBlock", 94 | size: "ExtraLarge", 95 | weight: "Bolder", 96 | text: options.title, 97 | }, 98 | { 99 | type: "TextBlock", 100 | size: "Large", 101 | weight: "Bolder", 102 | text: getNotificationTitle(totalStatus), 103 | color: getNotificationColor(totalStatus), 104 | }, 105 | table, 106 | ] as any[], 107 | bleed: true, 108 | backgroundImage: { 109 | url: getNotificationBackground(totalStatus), 110 | fillMode: "RepeatHorizontally", 111 | }, 112 | }; 113 | 114 | // Check if we should ping on failure 115 | if (!isSuccess) { 116 | const mentionData = getMentions( 117 | options.mentionOnFailure, 118 | options.mentionOnFailureText 119 | ); 120 | if (mentionData?.message && mentionData.mentions.length > 0) { 121 | container.items.push({ 122 | type: "TextBlock", 123 | size: "Medium", 124 | text: mentionData.message, 125 | wrap: true, 126 | }); 127 | 128 | adaptiveCard.msteams.entities = mentionData.mentions.map((mention) => ({ 129 | type: "mention", 130 | text: `${mention.email}`, 131 | mentioned: { 132 | id: mention.email, 133 | name: mention.name, 134 | }, 135 | })); 136 | } 137 | } 138 | 139 | // Add the container to the body 140 | adaptiveCard.body.push(container); 141 | 142 | // Get the github actions run URL 143 | if (options.linkToResultsUrl) { 144 | let linkToResultsUrl: string; 145 | if (typeof options.linkToResultsUrl === "string") { 146 | linkToResultsUrl = options.linkToResultsUrl; 147 | } else { 148 | linkToResultsUrl = options.linkToResultsUrl(); 149 | } 150 | 151 | if (linkToResultsUrl && typeof linkToResultsUrl === "string") { 152 | adaptiveCard.actions.push({ 153 | type: "Action.OpenUrl", 154 | title: options.linkToResultsText, 155 | url: linkToResultsUrl, 156 | }); 157 | } 158 | } 159 | 160 | if (!isSuccess && options.linkTextOnFailure && options.linkUrlOnFailure) { 161 | let linkUrlOnFailure: string; 162 | if (typeof options.linkUrlOnFailure === "string") { 163 | linkUrlOnFailure = options.linkUrlOnFailure; 164 | } else { 165 | linkUrlOnFailure = options.linkUrlOnFailure(); 166 | } 167 | 168 | if (linkUrlOnFailure && typeof linkUrlOnFailure === "string") { 169 | adaptiveCard.actions.push({ 170 | type: "Action.OpenUrl", 171 | title: options.linkTextOnFailure, 172 | url: linkUrlOnFailure, 173 | }); 174 | } 175 | } 176 | 177 | if (options.webhookType === "powerautomate") { 178 | adaptiveCard.version = "1.4"; 179 | } 180 | 181 | const body = JSON.stringify({ 182 | type: "message", 183 | attachments: [ 184 | { 185 | contentType: "application/vnd.microsoft.card.adaptive", 186 | contentUrl: null, 187 | content: adaptiveCard, 188 | }, 189 | ], 190 | }); 191 | 192 | if (options.debug) { 193 | console.log("Sending the following message:"); 194 | console.log(body); 195 | } 196 | 197 | const response = await fetch(options.webhookUrl, { 198 | method: "POST", 199 | headers: { 200 | "Content-Type": "application/json", 201 | }, 202 | body, 203 | }); 204 | 205 | if (response.ok) { 206 | if (!options.quiet) { 207 | console.log("Message sent successfully"); 208 | const responseText = await response.text(); 209 | if (responseText !== "1") { 210 | console.log(responseText); 211 | } 212 | } 213 | } else { 214 | console.error("Failed to send message"); 215 | console.error(await response.text()); 216 | } 217 | }; 218 | -------------------------------------------------------------------------------- /src/utils/createTableRow.test.ts: -------------------------------------------------------------------------------- 1 | import { createTableRow } from "./createTableRow"; 2 | 3 | describe("createTableRow", () => { 4 | it("should create a table row with default options", () => { 5 | const type = "Type"; 6 | const total = "Total"; 7 | const result = createTableRow(type, total); 8 | expect(result).toEqual({ 9 | type: "TableRow", 10 | cells: [ 11 | { 12 | type: "TableCell", 13 | items: [ 14 | { 15 | type: "TextBlock", 16 | text: type, 17 | wrap: true, 18 | }, 19 | ], 20 | }, 21 | { 22 | type: "TableCell", 23 | items: [ 24 | { 25 | type: "TextBlock", 26 | text: total, 27 | wrap: true, 28 | }, 29 | ], 30 | }, 31 | ], 32 | }); 33 | }); 34 | 35 | it("should create a table row with custom style", () => { 36 | const type = "Type"; 37 | const total = "Total"; 38 | const style = "attention"; 39 | const result = createTableRow(type, total, { style }); 40 | expect(result.cells[0].style).toEqual(style); 41 | }); 42 | 43 | it("should create a table row with subtle text", () => { 44 | const type = "Type"; 45 | const total = "Total"; 46 | const result = createTableRow(type, total, { isSubtle: true }); 47 | expect(result.cells[0].items[0].isSubtle).toBe(true); 48 | expect(result.cells[1].items[0].isSubtle).toBe(true); 49 | }); 50 | 51 | it("should create a table row with bold text", () => { 52 | const type = "Type"; 53 | const total = "Total"; 54 | const result = createTableRow(type, total, { weight: "Bolder" }); 55 | expect(result.cells[0].items[0].weight).toBe("Bolder"); 56 | expect(result.cells[1].items[0].weight).toBe("Bolder"); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/utils/createTableRow.ts: -------------------------------------------------------------------------------- 1 | import { TableCellStyle, TableRow } from "../models"; 2 | 3 | export const createTableRow = ( 4 | type: string, 5 | total: string | number, 6 | options?: { 7 | style?: TableCellStyle; 8 | isSubtle?: boolean; 9 | weight?: "Bolder"; 10 | } 11 | ): TableRow => { 12 | const row: TableRow = { 13 | type: "TableRow", 14 | cells: [ 15 | { 16 | type: "TableCell", 17 | items: [ 18 | { 19 | type: "TextBlock", 20 | text: type, 21 | wrap: true, 22 | }, 23 | ], 24 | style: options?.style || undefined, 25 | }, 26 | { 27 | type: "TableCell", 28 | items: [ 29 | { 30 | type: "TextBlock", 31 | text: `${total}`, 32 | wrap: true, 33 | }, 34 | ], 35 | }, 36 | ], 37 | }; 38 | 39 | if (options?.style) { 40 | row.cells[0].style = options.style; 41 | } 42 | 43 | if (options?.isSubtle) { 44 | row.cells[0].items[0].isSubtle = options?.isSubtle; 45 | row.cells[1].items[0].isSubtle = options?.isSubtle; 46 | } 47 | 48 | if (options?.weight) { 49 | row.cells[0].items[0].weight = options.weight; 50 | row.cells[1].items[0].weight = options.weight; 51 | } 52 | 53 | return row; 54 | }; 55 | -------------------------------------------------------------------------------- /src/utils/getMentions.test.ts: -------------------------------------------------------------------------------- 1 | import { getMentions } from "./getMentions"; 2 | 3 | describe("getMentions", () => { 4 | it("should return undefined for undefined mentionOnFailure", () => { 5 | const result = getMentions(undefined); 6 | expect(result).toBeUndefined(); 7 | }); 8 | 9 | it("should correctly parse mentionOnFailure with only emails", () => { 10 | const result = getMentions("email1@example.com, email2@example.com"); 11 | expect(result).toEqual({ 12 | message: "email1@example.com, email2@example.com", 13 | mentions: [ 14 | { name: "email1@example.com", email: "email1@example.com" }, 15 | { name: "email2@example.com", email: "email2@example.com" }, 16 | ], 17 | }); 18 | }); 19 | 20 | it("should correctly parse mentionOnFailure with full names and emails", () => { 21 | const result = getMentions( 22 | "John Doe , Jane Doe " 23 | ); 24 | expect(result).toEqual({ 25 | message: "john@example.com, jane@example.com", 26 | mentions: [ 27 | { name: "John Doe", email: "john@example.com" }, 28 | { name: "Jane Doe", email: "jane@example.com" }, 29 | ], 30 | }); 31 | }); 32 | 33 | it("should handle a mix of emails and full names with emails", () => { 34 | const result = getMentions( 35 | "email1@example.com, John Doe " 36 | ); 37 | expect(result).toEqual({ 38 | message: "email1@example.com, john@example.com", 39 | mentions: [ 40 | { name: "email1@example.com", email: "email1@example.com" }, 41 | { name: "John Doe", email: "john@example.com" }, 42 | ], 43 | }); 44 | }); 45 | 46 | it("should return undefined for empty string", () => { 47 | const result = getMentions(" "); 48 | expect(result).toBeUndefined(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/utils/getMentions.ts: -------------------------------------------------------------------------------- 1 | import { PluginDefaults } from "../constants"; 2 | 3 | /** 4 | * Retrieves mentions from a string and returns an object containing the message and mentions. 5 | * @param mentionOnFailure - The string containing the mentions. 6 | * @param mentionOnFailureText - Optional text to replace the mention placeholder in the message. 7 | * @returns An object containing the message and mentions, or undefined if no mentions are found. 8 | */ 9 | export const getMentions = ( 10 | mentionOnFailure: string | undefined, 11 | mentionOnFailureText?: string 12 | ): 13 | | { message: string; mentions: { name: string; email: string }[] } 14 | | undefined => { 15 | if (!mentionOnFailure) { 16 | return; 17 | } 18 | 19 | const mentions = mentionOnFailure 20 | .split(",") 21 | .filter((m) => m.trim() !== "") 22 | .map((mention) => { 23 | mention = mention.trim(); 24 | 25 | // Mention can be just an "email" or "full name " 26 | if (!mention.includes("<")) { 27 | return { 28 | name: mention, 29 | email: mention, 30 | }; 31 | } else { 32 | const parts = mention.split("<"); 33 | return { 34 | name: parts[0].trim(), 35 | email: parts[1].replace(">", "").trim(), 36 | }; 37 | } 38 | }); 39 | 40 | if (mentions.length > 0) { 41 | const mentionsText = mentions 42 | .map((mention) => `${mention.email}`) 43 | .join(", "); 44 | 45 | const message = ( 46 | mentionOnFailureText || PluginDefaults.mentionPlaceholder 47 | ).replace(PluginDefaults.mentionPlaceholder, mentionsText); 48 | 49 | return { 50 | message, 51 | mentions, 52 | }; 53 | } 54 | 55 | return; 56 | }; 57 | -------------------------------------------------------------------------------- /src/utils/getNotificationBackground.test.ts: -------------------------------------------------------------------------------- 1 | import { TestStatuses } from "../models"; 2 | import { getNotificationBackground } from "."; 3 | import { Images } from "../constants"; 4 | 5 | describe("getNotificationBackground", () => { 6 | it("Should return 'success' background", () => { 7 | const statuses: TestStatuses = { 8 | passed: 1, 9 | failed: 0, 10 | flaky: 0, 11 | skipped: 0, 12 | }; 13 | 14 | const title = getNotificationBackground(statuses); 15 | 16 | expect(title).toBe(Images.success); 17 | }); 18 | 19 | it("Should return 'flaky' background", () => { 20 | const statuses: TestStatuses = { 21 | passed: 1, 22 | failed: 0, 23 | flaky: 1, 24 | skipped: 0, 25 | }; 26 | 27 | const title = getNotificationBackground(statuses); 28 | 29 | expect(title).toBe(Images.flaky); 30 | }); 31 | 32 | it("Should return 'failed' background", () => { 33 | const statuses: TestStatuses = { 34 | passed: 1, 35 | failed: 1, 36 | flaky: 1, 37 | skipped: 0, 38 | }; 39 | 40 | const title = getNotificationBackground(statuses); 41 | 42 | expect(title).toBe(Images.failed); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/utils/getNotificationBackground.ts: -------------------------------------------------------------------------------- 1 | import { TestStatuses } from "../models"; 2 | import { getNotificationOutcome } from "."; 3 | import { Images } from "../constants"; 4 | 5 | export const getNotificationBackground = (statuses: TestStatuses) => { 6 | const outcome = getNotificationOutcome(statuses); 7 | 8 | if (outcome === "passed") { 9 | return Images.success; 10 | } 11 | 12 | if (outcome === "flaky") { 13 | return Images.flaky; 14 | } 15 | 16 | return Images.failed; 17 | }; 18 | -------------------------------------------------------------------------------- /src/utils/getNotificationColor.test.ts: -------------------------------------------------------------------------------- 1 | import { TestStatuses } from "../models"; 2 | import { getNotificationColor } from "."; 3 | 4 | describe("getNotificationColor", () => { 5 | it("Should return 'good' background", () => { 6 | const statuses: TestStatuses = { 7 | passed: 1, 8 | failed: 0, 9 | flaky: 0, 10 | skipped: 0, 11 | }; 12 | 13 | const title = getNotificationColor(statuses); 14 | 15 | expect(title).toBe("Good"); 16 | }); 17 | 18 | it("Should return 'warning' background", () => { 19 | const statuses: TestStatuses = { 20 | passed: 1, 21 | failed: 0, 22 | flaky: 1, 23 | skipped: 0, 24 | }; 25 | 26 | const title = getNotificationColor(statuses); 27 | 28 | expect(title).toBe("Warning"); 29 | }); 30 | 31 | it("Should return 'attention' background", () => { 32 | const statuses: TestStatuses = { 33 | passed: 1, 34 | failed: 1, 35 | flaky: 1, 36 | skipped: 0, 37 | }; 38 | 39 | const title = getNotificationColor(statuses); 40 | 41 | expect(title).toBe("Attention"); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/utils/getNotificationColor.ts: -------------------------------------------------------------------------------- 1 | import { TestStatuses } from "../models"; 2 | import { getNotificationOutcome } from "."; 3 | 4 | export const getNotificationColor = ( 5 | statuses: TestStatuses 6 | ): "Good" | "Warning" | "Attention" => { 7 | const outcome = getNotificationOutcome(statuses); 8 | 9 | if (outcome === "passed") { 10 | return "Good"; 11 | } 12 | 13 | if (outcome === "flaky") { 14 | return "Warning"; 15 | } 16 | 17 | return "Attention"; 18 | }; 19 | -------------------------------------------------------------------------------- /src/utils/getNotificationOutcome.ts: -------------------------------------------------------------------------------- 1 | import { TestStatuses } from "../models"; 2 | 3 | export const getNotificationOutcome = ( 4 | statuses: TestStatuses 5 | ): "passed" | "flaky" | "failed" => { 6 | const isSuccess = statuses.failed === 0; 7 | const hasFlakyTests = statuses.flaky > 0; 8 | 9 | if (isSuccess && !hasFlakyTests) { 10 | return "passed"; 11 | } 12 | 13 | if (isSuccess && hasFlakyTests) { 14 | return "flaky"; 15 | } 16 | 17 | return "failed"; 18 | }; 19 | -------------------------------------------------------------------------------- /src/utils/getNotificationTitle.test.ts: -------------------------------------------------------------------------------- 1 | import { TestStatuses } from "../models"; 2 | import { getNotificationTitle } from "."; 3 | 4 | describe("getNotificationTitle", () => { 5 | it("should return 'Tests passed' when the outcome is 'passed'", () => { 6 | const statuses: TestStatuses = { 7 | passed: 1, 8 | failed: 0, 9 | flaky: 0, 10 | skipped: 0, 11 | }; 12 | 13 | const title = getNotificationTitle(statuses); 14 | 15 | expect(title).toBe("Tests passed"); 16 | }); 17 | 18 | it("should return 'Tests passed with flaky tests' when the outcome is 'flaky'", () => { 19 | const statuses: TestStatuses = { 20 | passed: 1, 21 | failed: 0, 22 | flaky: 1, 23 | skipped: 0, 24 | }; 25 | 26 | const title = getNotificationTitle(statuses); 27 | 28 | expect(title).toBe("Tests passed with flaky tests"); 29 | }); 30 | 31 | it("should return 'Tests failed' when the outcome is neither 'passed' nor 'flaky'", () => { 32 | const statuses: TestStatuses = { 33 | passed: 1, 34 | failed: 1, 35 | flaky: 1, 36 | skipped: 0, 37 | }; 38 | 39 | const title = getNotificationTitle(statuses); 40 | 41 | expect(title).toBe("Tests failed"); 42 | }); 43 | 44 | it("should return 'Tests passed' when only skipped", () => { 45 | const statuses: TestStatuses = { 46 | passed: 0, 47 | failed: 0, 48 | flaky: 0, 49 | skipped: 1, 50 | }; 51 | 52 | const title = getNotificationTitle(statuses); 53 | 54 | expect(title).toBe("Tests passed"); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/utils/getNotificationTitle.ts: -------------------------------------------------------------------------------- 1 | import { TestStatuses } from "../models"; 2 | import { getNotificationOutcome } from "."; 3 | 4 | export const getNotificationTitle = (statuses: TestStatuses): string => { 5 | const outcome = getNotificationOutcome(statuses); 6 | 7 | if (outcome === "passed") { 8 | return "Tests passed"; 9 | } 10 | 11 | if (outcome === "flaky") { 12 | return "Tests passed with flaky tests"; 13 | } 14 | 15 | return "Tests failed"; 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/getTotalStatus.test.ts: -------------------------------------------------------------------------------- 1 | import { Suite } from "@playwright/test/reporter"; 2 | import { getTotalStatus } from "./getTotalStatus"; 3 | 4 | const baseSuite: Suite = { 5 | tests: [], 6 | title: "test", 7 | suites: [], 8 | titlePath: () => [""], 9 | project: () => undefined, 10 | allTests: () => [], 11 | entries: () => [], 12 | type: "root", 13 | }; 14 | 15 | describe("getTotalStatus", () => { 16 | it("should return the correct total status when all tests have passed", () => { 17 | const suites: Suite[] = [ 18 | { 19 | ...baseSuite, 20 | allTests: () => 21 | [ 22 | { outcome: () => "expected" }, 23 | { outcome: () => "expected" }, 24 | ] as any[], 25 | }, 26 | ]; 27 | 28 | const result = getTotalStatus(suites); 29 | 30 | expect(result).toEqual({ 31 | passed: 2, 32 | failed: 0, 33 | flaky: 0, 34 | skipped: 0, 35 | }); 36 | }); 37 | 38 | it("should return the correct flaky total when there are flaky tests", () => { 39 | const suites: Suite[] = [ 40 | { 41 | ...baseSuite, 42 | allTests: () => 43 | [ 44 | { outcome: () => "expected" }, 45 | { outcome: () => "expected" }, 46 | { outcome: () => "unexpected" }, 47 | { outcome: () => "flaky" }, 48 | ] as any[], 49 | }, 50 | ]; 51 | 52 | const result = getTotalStatus(suites); 53 | 54 | expect(result).toEqual({ 55 | passed: 2, 56 | failed: 1, 57 | flaky: 1, 58 | skipped: 0, 59 | }); 60 | }); 61 | 62 | it("should return the correct total status when there are failed, skipped tests", () => { 63 | const suites: Suite[] = [ 64 | { 65 | ...baseSuite, 66 | allTests: () => 67 | [ 68 | { outcome: () => "expected" }, 69 | { outcome: () => "unexpected" }, 70 | { outcome: () => "flaky" }, 71 | { outcome: () => "unexpected" }, 72 | { outcome: () => "skipped" }, 73 | ] as any[], 74 | }, 75 | ]; 76 | 77 | const result = getTotalStatus(suites); 78 | 79 | expect(result).toEqual({ 80 | passed: 1, 81 | failed: 2, 82 | flaky: 1, 83 | skipped: 1, 84 | }); 85 | }); 86 | 87 | it("should return the correct total status when there are no tests", () => { 88 | const suites: Suite[] = []; 89 | 90 | const result = getTotalStatus(suites); 91 | 92 | expect(result).toEqual({ 93 | passed: 0, 94 | failed: 0, 95 | flaky: 0, 96 | skipped: 0, 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/utils/getTotalStatus.ts: -------------------------------------------------------------------------------- 1 | import { Suite } from "@playwright/test/reporter"; 2 | import { TestStatuses } from "../models"; 3 | 4 | export const getTotalStatus = (suites: Suite[]): TestStatuses => { 5 | let total = { 6 | passed: 0, 7 | flaky: 0, 8 | failed: 0, 9 | skipped: 0, 10 | }; 11 | 12 | for (const suite of suites) { 13 | const testOutcome = suite.allTests().map((test) => { 14 | return test.outcome(); 15 | }); 16 | 17 | for (const outcome of testOutcome) { 18 | if (outcome === "expected") { 19 | total.passed++; 20 | } else if (outcome === "flaky") { 21 | total.flaky++; 22 | } else if (outcome === "unexpected") { 23 | total.failed++; 24 | } else if (outcome === "skipped") { 25 | total.skipped++; 26 | } 27 | } 28 | } 29 | 30 | return total; 31 | }; 32 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./createTableRow"; 2 | export * from "./getMentions"; 3 | export * from "./getNotificationBackground"; 4 | export * from "./getNotificationColor"; 5 | export * from "./getNotificationOutcome"; 6 | export * from "./getNotificationTitle"; 7 | export * from "./getTotalStatus"; 8 | export * from "./validateWebhookUrl"; 9 | -------------------------------------------------------------------------------- /src/utils/validateWebhookUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { validateWebhookUrl } from "./validateWebhookUrl"; 2 | 3 | describe("validateWebhookUrl", () => { 4 | it("Valid Power Automate webhook URL", () => { 5 | expect( 6 | validateWebhookUrl( 7 | "https://prod-00.westus.logic.azure.com:443/workflows/1234567890abcdef1234567890abcdef/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=1234567890abcdef1234567890abcdef" 8 | ) 9 | ).toBe(true); 10 | }); 11 | 12 | it("Valid Power Automate webhook URL (with argument)", () => { 13 | expect( 14 | validateWebhookUrl( 15 | "https://prod-00.westus.logic.azure.com:443/workflows/1234567890abcdef1234567890abcdef/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=1234567890abcdef1234567890abcdef", 16 | "powerautomate" 17 | ) 18 | ).toBe(true); 19 | }); 20 | 21 | it("Valid Power Automate webhook URL (France) (with argument)", () => { 22 | expect( 23 | validateWebhookUrl( 24 | "https://prod2-00.francecentral.logic.azure.com:443/workflows/1234567890abcdef1234567890abcdef/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=1234567890abcdef1234567890abcdef", 25 | "powerautomate" 26 | ) 27 | ).toBe(true); 28 | }); 29 | 30 | it("Invalid Power Automate webhook URL 1", () => { 31 | expect( 32 | validateWebhookUrl( 33 | "https://prod-00.westus.azure.com:443/workflows/1234567890abcdef1234567890abcdef/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=1234567890abcdef1234567890abcdef", 34 | "powerautomate" 35 | ) 36 | ).toBe(false); 37 | }); 38 | 39 | it("Invalid Power Automate webhook URL 2", () => { 40 | expect( 41 | validateWebhookUrl( 42 | "https://prod-00.westus.logic.azure.com:443/workflows/1234567890abcdef1234567890abcdef/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=1234567890abcdef1234567890abcdef", 43 | "powerautomate" 44 | ) 45 | ).toBe(false); 46 | }); 47 | 48 | it("Valid MS Teams webhook URL", () => { 49 | expect( 50 | validateWebhookUrl( 51 | "https://tenant.webhook.office.com/webhookb2/12345678-1234-1234-1234-123456789abc@12345678-1234-1234-1234-123456789abc/IncomingWebhook/123456789abcdef123456789abcdef12/12345678-1234-1234-1234-123456789abc", 52 | "msteams" 53 | ) 54 | ).toBe(true); 55 | }); 56 | 57 | it("Valid MS Teams webhook URL with a number 1", () => { 58 | expect( 59 | validateWebhookUrl( 60 | "https://tenant9.webhook.office.com/webhookb2/0daed572-448a-4d0a-8ea8-b6a49e4625b3@b32d3268-2794-435f-865f-ab4779cae11e/IncomingWebhook/3814d9fc895847eb92cba3be342f1ce9/0ed480cd-fb54-4931-90da-1df7a1181c66/V2oHGgdElj-mUr4VOwzCnl8lvZhVHqSyqAkJRaCMi9QOI1", 61 | "msteams" 62 | ) 63 | ).toBe(true); 64 | }); 65 | 66 | it("Valid MS Teams webhook URL with a number 2", () => { 67 | expect( 68 | validateWebhookUrl( 69 | "https://tenant2share.webhook.office.com/webhookb2/0daed572-448a-4d0a-8ea8-b6a49e4625b3@b32d3268-2794-435f-865f-ab4779cae11e/IncomingWebhook/3814d9fc895847eb92cba3be342f1ce9/0ed480cd-fb54-4931-90da-1df7a1181c66/V2oHGgdElj-mUr4VOwzCnl8lvZhVHqSyqAkJRaCMi9QOI1", 70 | "msteams" 71 | ) 72 | ).toBe(true); 73 | }); 74 | 75 | it("Invalid if MS Teams webhook URL is passed as Power Automate webhook URL", () => { 76 | expect( 77 | validateWebhookUrl( 78 | "https://tenant.webhook.office.com/webhookb2/12345678-1234-1234-1234-123456789abc@12345678-1234-1234-1234-123456789abc/IncomingWebhook/123456789abcdef123456789abcdef12/12345678-1234-1234-1234-123456789abc" 79 | ) 80 | ).toBe(false); 81 | }); 82 | 83 | it("Invalid MS Teams webhook URL 1", () => { 84 | expect( 85 | validateWebhookUrl( 86 | "https://tenant.webhook.office.com/webhookb2/12345678-1234-1234-1234-123456789abc@12345678-1234-1234-1234-123456789abc/IncomingWebhook/123456789abcdef123456789abcdef12/12345678-1234-1234-1234", 87 | "msteams" 88 | ) 89 | ).toBe(false); 90 | }); 91 | 92 | it("Invalid MS Teams webhook URL 2", () => { 93 | expect( 94 | validateWebhookUrl( 95 | "https://tenant.webhook.office.com/webhookb2/12345678-1x34-1234-1234-123456789@abc12345678-1234-1234-1234-123456789abc/IncomingWebhook/123456789abcdef123456789abcdef12/12345678-1234-1234-1234-123456789abc", 96 | "msteams" 97 | ) 98 | ).toBe(false); 99 | }); 100 | 101 | it("Invalid MS Teams webhook URL 3", () => { 102 | expect( 103 | validateWebhookUrl( 104 | "https://tenant.webhook.office.com/webhookb2/12345678-1234-1234-1234-123456789abc@12345678-1234-1234-1234-123456789abc/IncomingWebhooks/123456789abcdef123456789abcdef12/12345678-1234-1234-1234-123456789abc", 105 | "msteams" 106 | ) 107 | ).toBe(false); 108 | }); 109 | 110 | it("Incorrect type", () => { 111 | expect( 112 | validateWebhookUrl("https://just-a-random-url.com", "fake" as any) 113 | ).toBe(false); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/utils/validateWebhookUrl.ts: -------------------------------------------------------------------------------- 1 | import { WebhookType } from "../models"; 2 | 3 | /** 4 | * Validates a webhook URL to ensure it meets the required format. 5 | * 6 | * @param webhookUrl - The webhook URL to validate. 7 | * @param type - The type of webhook to validate. 8 | * @returns A boolean indicating whether the webhook URL is valid. 9 | */ 10 | export const validateWebhookUrl = ( 11 | webhookUrl: string, 12 | type?: WebhookType 13 | ): boolean => { 14 | if (!type || type === "powerautomate") { 15 | // https://prod-{int}.{region}.logic.azure.com:443/workflows/{id}/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig={sig} 16 | return !!( 17 | webhookUrl && 18 | webhookUrl.startsWith("https://prod") && 19 | webhookUrl.includes("logic.azure.com") && 20 | webhookUrl.includes("/workflows/") && 21 | webhookUrl.includes("/triggers/") 22 | ); 23 | } else if (type === "msteams") { 24 | // https://tenant.webhook.office.com/webhookb2/{uuid}@{uuid}/IncomingWebhook/{id}/{uuid} 25 | const webhook_pattern = 26 | /^https:\/\/[a-zA-Z0-9]+.webhook.office.com\/webhookb2\/[{]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[}]?\S+\/IncomingWebhook\/[a-zA-Z0-9]+\/[{]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[}]?/; 27 | 28 | return webhook_pattern.test(webhookUrl); 29 | } else { 30 | return false; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /tests/fail.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, Page } from "@playwright/test"; 2 | 3 | test.describe("Failing test", () => { 4 | let page: Page; 5 | 6 | test.beforeAll(async ({ browser }) => { 7 | page = await browser.newPage(); 8 | 9 | await page.goto("https://www.eliostruyf.com/stickers/", { 10 | waitUntil: "domcontentloaded", 11 | }); 12 | }); 13 | 14 | test.afterAll(async ({ browser }) => { 15 | await page.close(); 16 | await browser.close(); 17 | }); 18 | 19 | test("Test should fail", async () => { 20 | const logo = page.locator(`#logo`); 21 | await logo.waitFor(); 22 | 23 | expect((await logo.allInnerTexts()).join()).toBe("PYOD"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/homepage.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, Page } from "@playwright/test"; 2 | 3 | console.log("Running the homepage tests."); 4 | 5 | test.describe("Homepage", () => { 6 | let page: Page; 7 | console.log("Running tests for the homepage."); 8 | 9 | test.beforeAll(async ({ browser }) => { 10 | page = await browser.newPage(); 11 | console.log("Opening the homepage."); 12 | 13 | await page.goto("https://www.eliostruyf.com", { 14 | waitUntil: "domcontentloaded", 15 | }); 16 | }); 17 | 18 | test.afterAll(async ({ browser }) => { 19 | console.log("Closing the homepage."); 20 | await page.close(); 21 | await browser.close(); 22 | }); 23 | 24 | test( 25 | "Check logo", 26 | { 27 | tag: "@website", 28 | annotation: { 29 | type: "info", 30 | description: "A test to check the logo.", 31 | }, 32 | }, 33 | async () => { 34 | console.log("Checking the logo."); 35 | const logo = page.locator(`#logo span`); 36 | const firstName = logo.first(); 37 | const lastName = logo.last(); 38 | 39 | expect(await firstName.innerText()).toBe("ELIO"); 40 | expect(await lastName.innerText()).toBe("STRUYF"); 41 | } 42 | ); 43 | 44 | test( 45 | "Check navigation", 46 | { 47 | tag: ["@website", "@navigation"], 48 | annotation: [ 49 | { 50 | type: "info", 51 | description: "A test to check the navigation links.", 52 | }, 53 | { 54 | type: "website", 55 | description: "https://www.eliostruyf.com", 56 | }, 57 | ], 58 | }, 59 | async () => { 60 | console.log("Checking the navigation."); 61 | const nav = page.locator(`.navigation nav`); 62 | await nav.waitFor(); 63 | 64 | await expect(page.locator(".navigation nav a")).toHaveCount(5); 65 | } 66 | ); 67 | }); 68 | -------------------------------------------------------------------------------- /tests/retry.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, Page } from "@playwright/test"; 2 | 3 | test.describe("Test retry", () => { 4 | test.setTimeout(10000); 5 | 6 | test("First test should fail, next should work", async ({}, testInfo) => { 7 | if (testInfo.retry === 0) { 8 | expect(true).toBeFalsy(); 9 | } 10 | expect(true).toBeTruthy(); 11 | }); 12 | 13 | test("Flaky test", async ({ page }, testInfo) => { 14 | await page.goto("https://www.eliostruyf.com", { 15 | waitUntil: "domcontentloaded", 16 | }); 17 | 18 | if (testInfo.retry === 0) { 19 | await page.evaluate(() => { 20 | const logo: HTMLDivElement | null = 21 | window.document.querySelector(`#logo`); 22 | if (logo) { 23 | logo.style.display = "none"; 24 | } 25 | }); 26 | } 27 | 28 | let header = page.locator(`#logo`); 29 | await expect(header).toBeVisible({ timeout: 1000 }); 30 | }); 31 | 32 | test("Skip the test", async () => { 33 | test.skip(true, "Don't need to test this."); 34 | }); 35 | 36 | test("Should work fine", async () => { 37 | expect(true).toBeTruthy(); 38 | }); 39 | 40 | test("Unexpected", async () => { 41 | test.skip(true, "Don't need to test this."); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/setup/setup.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, Page } from "@playwright/test"; 2 | 3 | test.describe.serial("Setup", () => { 4 | let page: Page; 5 | 6 | test.setTimeout(5000); 7 | test.beforeAll(async ({ browser }) => { 8 | page = await browser.newPage(); 9 | 10 | await page.goto("https://www.google.com/", { 11 | waitUntil: "domcontentloaded", 12 | }); 13 | }); 14 | test("Fake timeout 1", async () => { 15 | await page.waitForURL("ssss"); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/speaking.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, Page } from "@playwright/test"; 2 | 3 | test.describe("Speaking", () => { 4 | let page: Page; 5 | 6 | test.beforeAll(async ({ browser }) => { 7 | page = await browser.newPage(); 8 | 9 | await page.goto("https://www.eliostruyf.com/speaking/", { 10 | waitUntil: "domcontentloaded", 11 | }); 12 | }); 13 | 14 | test.afterAll(async ({ browser }) => { 15 | await page.close(); 16 | await browser.close(); 17 | }); 18 | 19 | test("Check figure", async () => { 20 | const figure = page.locator(`.content_zone figure`); 21 | await figure.waitFor(); 22 | 23 | expect(figure.locator(`img`)).toBeVisible(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/timeout.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, Page } from "@playwright/test"; 2 | 3 | test.describe.serial("Timeout test", () => { 4 | let page: Page; 5 | 6 | test.setTimeout(2000); 7 | 8 | test.beforeAll(async ({ browser }) => { 9 | page = await browser.newPage(); 10 | 11 | await page.goto("https://www.google.com/", { 12 | waitUntil: "domcontentloaded", 13 | }); 14 | }); 15 | 16 | test.afterAll(async ({ browser }) => { 17 | await page.close(); 18 | await browser.close(); 19 | }); 20 | 21 | test("Fake timeout 1", async () => { 22 | await page.waitForTimeout(100); 23 | throw new Error("Fake error"); 24 | }); 25 | 26 | test("Fake timeout 2", async () => { 27 | await page.waitForTimeout(3000); 28 | }); 29 | 30 | test("Fake timeout 3", async () => { 31 | await page.waitForTimeout(3000); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "include": [ 4 | "src/**/*.ts" 5 | ], 6 | "exclude": [ 7 | "node_modules" 8 | ], 9 | "compilerOptions": { 10 | "outDir": "dist", 11 | "declaration": true 12 | } 13 | } --------------------------------------------------------------------------------