├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── docs ├── assets │ ├── css │ │ ├── main.css │ │ └── pages.css │ ├── images │ │ ├── icons.png │ │ ├── icons@2x.png │ │ ├── page-icon.svg │ │ ├── widgets.png │ │ └── widgets@2x.png │ └── js │ │ ├── main.js │ │ └── search.json ├── classes │ ├── attribute.html │ ├── button.html │ ├── control.html │ ├── dialog.html │ ├── entity.html │ ├── extendederror.html │ ├── form.html │ ├── grid.html │ ├── navigation.html │ ├── portaluitest.html │ ├── rethrownerror.html │ ├── section.html │ ├── subgrid.html │ ├── tab.html │ ├── testutils.inflightrequests.html │ ├── webapi.html │ └── xrmuitest.html ├── globals.html ├── index.html ├── interfaces │ ├── __global.window.html │ ├── buttonidentifier.html │ ├── controlstate.html │ ├── formidentifier.html │ ├── formnavigationsettings.html │ ├── formselectoritem.html │ ├── navigationsettings.html │ ├── openproperties.html │ ├── sectionstate.html │ ├── setvaluesettings.html │ ├── tabstate.html │ └── testsettings.html ├── modules │ ├── __global.html │ └── testutils.html └── pages │ └── Tutorials │ ├── 01_Startup.html │ ├── 02_Navigation.html │ ├── 03_Attributes.html │ ├── 04_Controls.html │ ├── 05_Buttons.html │ ├── 06_Subgrids.html │ ├── 07_Tabs.html │ ├── 08_Entity.html │ ├── 09_Dialogs.html │ ├── 10_DevOps.html │ ├── 11_FAQs.html │ ├── 12_TestUtils.html │ ├── 13_Troubleshooting.html │ └── 14_Sections.html ├── jest.config.js ├── package-lock.json ├── package.json ├── pagesconfig.json ├── spec ├── portal-test.spec.ts └── xrm-uci-ui-test.spec.ts ├── src ├── domain │ ├── D365Selectors.ts │ ├── SharedLogic.ts │ └── TestSettings.ts ├── index.ts ├── portal │ └── PortalUITest.ts ├── utils │ ├── RethrownError.ts │ └── TestUtils.ts └── xrm │ ├── Attribute.ts │ ├── Button.ts │ ├── Control.ts │ ├── Dialog.ts │ ├── Entity.ts │ ├── Form.ts │ ├── Global.ts │ ├── Grid.ts │ ├── Navigation.ts │ ├── Section.ts │ ├── SubGrid.ts │ ├── Tab.ts │ ├── WebApi.ts │ └── XrmUITest.ts ├── tsconfig.json ├── tslint.json ├── tutorials ├── 01_Startup.md ├── 02_Navigation.md ├── 03_Attributes.md ├── 04_Controls.md ├── 05_Buttons.md ├── 06_Subgrids.md ├── 07_Tabs.md ├── 08_Entity.md ├── 09_Dialogs.md ├── 10_DevOps.md ├── 11_FAQs.md ├── 12_TestUtils.md ├── 13_Troubleshooting.md └── 14_Sections.md └── typedoc.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # These files are text and should be normalized (convert crlf => lf) 2 | *.cmd text 3 | *.config text 4 | *.Config text 5 | *.cs text diff=csharp 6 | *.csproj text 7 | *.datasource text 8 | *.disco text 9 | *.edmx text 10 | *.map text 11 | *.md text 12 | *.msbuild text 13 | *.ps1 text 14 | *.settings text 15 | *.sln text 16 | *.svcinfo text 17 | *.svcmap text 18 | *.t4properties text 19 | *.tt text 20 | *.txt text 21 | *.vspscc text 22 | *.wsdl text 23 | *.xaml text 24 | *.xsd text 25 | 26 | # Images should be treated as binary 27 | # (binary is a macro for -text -diff) 28 | *.ico binary 29 | *.jepg binary 30 | *.jpg binary 31 | *.sdf binary 32 | *.pdf binary 33 | *.png binary 34 | 35 | # Exclude report coverage from linguist 36 | reports/* linguist-documentation 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | release: 13 | types: [released] 14 | 15 | 16 | # Allows you to run this workflow manually from the Actions tab 17 | workflow_dispatch: 18 | 19 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 20 | jobs: 21 | # This workflow contains a single job called "build" 22 | build: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Branch name 27 | if: startsWith(github.ref, 'refs/tags/') 28 | id: branch_name 29 | run: echo ::set-output name=SOURCE_TAG::${GITHUB_REF#refs/tags/} 30 | - uses: actions/checkout@v2 31 | # Setup .npmrc file to publish to npm 32 | - uses: actions/setup-node@v1 33 | with: 34 | node-version: '12.x' 35 | registry-url: 'https://registry.npmjs.org' 36 | - name: Bump version number locally on tag 37 | if: startsWith(github.ref, 'refs/tags/') 38 | run: npm version --no-git-tag-version $SOURCE_TAG 39 | env: 40 | SOURCE_TAG: ${{ steps.branch_name.outputs.SOURCE_TAG }} 41 | - run: npm ci 42 | - run: npm run build 43 | - name: Publish to npm on release 44 | if: startsWith(github.ref, 'refs/tags/') 45 | run: npm publish 46 | env: 47 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | #ignore thumbnails created by windows 3 | Thumbs.db 4 | #Ignore files build by Visual Studio 5 | *.obj 6 | *.exe 7 | *.pdb 8 | *.user 9 | *.aps 10 | *.pch 11 | *.vspscc 12 | *_i.c 13 | *_p.c 14 | *.ncb 15 | *.suo 16 | *.tlb 17 | *.tlh 18 | *.bak 19 | *.cache 20 | *.ilk 21 | *.log 22 | [Bb]in 23 | [Dd]ebug*/ 24 | *.lib 25 | *.sbr 26 | obj/ 27 | [Rr]elease*/ 28 | _ReSharper*/ 29 | [Tt]est[Rr]esult* 30 | packages/* 31 | tools/* 32 | build/* 33 | *.sln.DotSettings 34 | *.sln.ide/* 35 | nuget/* 36 | Publish/* 37 | test/* 38 | .fake/* 39 | paket-files/* 40 | .vs/* 41 | **/node_modules/* 42 | **/dist/* 43 | temp/* 44 | reports/* 45 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Florian Krönert 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 | # D365-UI-Test [![Known Vulnerabilities](https://snyk.io/test/github/digitalflow/d365-ui-test/badge.svg)](https://snyk.io/test/github/digitalflow/d365-ui-test) [![CI](https://github.com/XRM-OSS/D365-UI-Test/actions/workflows/main.yml/badge.svg)](https://github.com/XRM-OSS/D365-UI-Test/actions/workflows/main.yml) [![npm version](https://badge.fury.io/js/d365-ui-test.svg)](https://badge.fury.io/js/d365-ui-test) ![NPM](https://img.shields.io/npm/l/d365-ui-test) [![npm downloads](https://img.shields.io/npm/dt/d365-ui-test.svg)](http://npm-stats.org/#/d365-ui-test) 2 | 3 | ## What's this? 4 | D365-UI-Test is an UI testing framework for easy and robust UI testing in Dynamics 365 CE and Dynamics 365 Portals. 5 | It is powered by TypeScript and playwright. You can write your tests in plain JS or in TypeScript. 6 | Various functions for interacting with CRM are implemented and can be used for executing your tests. 7 | 8 | ## Quick Showcase 9 | https://user-images.githubusercontent.com/4287938/103551344-818f6f80-4eaa-11eb-8e77-db6e6ceb71be.mp4 10 | 11 | ## What does a test look like? 12 | D365-UI-Test is unopinionated, so we don't enforce a specific testing library. 13 | The demo tests use [jest](https://jestjs.io/), but you could just as well use Mocha or someting completely different. 14 | 15 | Jest Test: 16 | ```TypeScript 17 | describe("Basic operations UCI", () => { 18 | beforeAll(async () => { 19 | jest.setTimeout(60000); 20 | 21 | await xrmTest.launch("chromium", { 22 | headless: false, 23 | args: ["--start-fullscreen"] 24 | }) 25 | .then(([b, c, p]) => { 26 | browser = b; 27 | context = c; 28 | page = p; 29 | }); 30 | }); 31 | 32 | test("Start D365", async () => { 33 | const config = fs.readFileSync(path.join(__dirname, "../../settings.txt"), {encoding: 'utf-8'}); 34 | const [url, user, password] = config.split(","); 35 | }); 36 | 37 | test("Open new account form", async () => { 38 | await xrmTest.Navigation.openCreateForm("account"); 39 | }); 40 | 41 | afterAll(() => { 42 | return xrmTest.close(); 43 | }); 44 | }); 45 | ``` 46 | 47 | ## Getting started 48 | ### Writing tests 49 | Thera are demo projects for getting started with various test frameworks: 50 | - Jest: https://github.com/DigitalFlow/D365-UI-Test-Jest-Demo 51 | - Mocha / Chai: https://github.com/paulbreuler/D365-UI-Mocha-Test (Thanks Paul!) 52 | 53 | Just follow the instructions in there for getting started. 54 | 55 | ### Designing test using D365-UI-Test-Designer 56 | There is an official browser extension for Google Chrome and MS Edge available here: https://github.com/XRM-OSS/D365-UI-Test-Designer 57 | It can help you to record form actions as UI tests and to assist you in defining tests. 58 | 59 | ### Without template project 60 | Install this project using npm to get started: `npm install d365-ui-test`. 61 | 62 | Afterwards you can import it in your code like `import { XrmUiTest } from "d365-ui-test";`. 63 | 64 | Use a testing framework such as Jest or Mocha for creating a test suite and set up a XrmUiTest instance in the startup step for launching a Chrome session. 65 | Each of your tests can then be written inside the testing framework just as you're used to. 66 | 67 | You might want to create your own settings.txt file as in the example above or just enter your credentials inline. 68 | The demo tests reside at `spec/xrm-ui-test.spec.ts`, the demo project can be found in the previous section. 69 | This might give you an idea. 70 | 71 | ## What's the difference to EasyRepro? 72 | EasyRepro focuses on interacting with the form mainly by simulating user inputs. 73 | When setting lookups, dealing with localization, renaming of labels and more topics, this seemed not the best option. 74 | The CRM provides us with various global JS objects, which allow interacting with the system. 75 | D365-UI-Test tries to use these JS objects (such as Xrm.Navigation) as much as possible, as this API is not expected to change unexpectedly, yields fast and stable results and causes no issues with localization. 76 | 77 | D365-UI-Test also does not limit itself to Dynamics 365 CE, but also for testing connected Portals. 78 | 79 | ## Continuous Integration 80 | D365-UI-Test is cross platform. You can run it on Windows, Linux, Mac and of course also on Azure or any other CI platform. 81 | For getting started as fast as possible, there is a fully functioning predefined yaml pipeline definition for Azure DevOps available in the documentation: https://xrm-oss.github.io/D365-UI-Test/pages/Tutorials/10_DevOps.html 82 | 83 | ## Current Feature Set 84 | - Open and log in to D365 (MS OTP tokens / two factor auth is also supported) 85 | - Open an App 86 | - Open Create / Update Forms 87 | - Set values for all CRM field types 88 | - Get values of all CRM field types 89 | - Get visibility and readonly state for controls 90 | - Get subgrid record count, refresh subgrid, open n-th record of subgrid 91 | - Click ribbon Buttons 92 | - Download files 93 | - Runs on Windows, Linux, Mac (also on DevOps) 94 | -------------------------------------------------------------------------------- /docs/assets/css/pages.css: -------------------------------------------------------------------------------- 1 | h2 code { 2 | font-size: 1em; 3 | } 4 | 5 | h3 code { 6 | font-size: 1em; 7 | } 8 | 9 | .tsd-navigation.primary ul { 10 | border-bottom: none; 11 | } 12 | 13 | .tsd-navigation.primary li { 14 | border-top: none; 15 | } 16 | 17 | .tsd-navigation li.label.pp-nav.pp-group:first-child span { 18 | padding-top: 0; 19 | } 20 | 21 | .tsd-navigation li.label.pp-nav.pp-group { 22 | font-weight: 700; 23 | border-bottom: 1px solid #eee; 24 | } 25 | 26 | .tsd-navigation li.label.pp-nav.pp-group span { 27 | color: #222; 28 | } 29 | 30 | .tsd-navigation li.pp-nav.pp-page.current { 31 | background-color: #f8f8f8; 32 | border-left: 2px solid #222; 33 | } 34 | 35 | .tsd-navigation li.pp-nav.pp-page.current a { 36 | color: #222; 37 | } 38 | 39 | .tsd-navigation li.pp-nav.pp-page.pp-parent.pp-active { 40 | border-left: 2px solid #eee; 41 | } 42 | 43 | .tsd-navigation li.pp-nav.pp-page.pp-child { 44 | border-left: 2px solid #eee; 45 | padding-left: 15px; 46 | } 47 | 48 | .tsd-navigation li.pp-nav.pp-page.pp-child.current { 49 | border-left: 2px solid #222; 50 | } 51 | 52 | .tsd-kind-page .tsd-kind-icon:before { 53 | display: inline-block; 54 | vertical-align: middle; 55 | height: 16px; 56 | width: 16px; 57 | content: ""; 58 | background-image: url("../images/page-icon.svg"); 59 | background-size: 16px 16px; 60 | } 61 | 62 | #tsd-search .results span.parent { 63 | color: #b3b2b2 !important; 64 | } -------------------------------------------------------------------------------- /docs/assets/images/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XRM-OSS/D365-UI-Test/5c6259390b8e1243f16208e8e60472ae35c433c1/docs/assets/images/icons.png -------------------------------------------------------------------------------- /docs/assets/images/icons@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XRM-OSS/D365-UI-Test/5c6259390b8e1243f16208e8e60472ae35c433c1/docs/assets/images/icons@2x.png -------------------------------------------------------------------------------- /docs/assets/images/page-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/images/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XRM-OSS/D365-UI-Test/5c6259390b8e1243f16208e8e60472ae35c433c1/docs/assets/images/widgets.png -------------------------------------------------------------------------------- /docs/assets/images/widgets@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XRM-OSS/D365-UI-Test/5c6259390b8e1243f16208e8e60472ae35c433c1/docs/assets/images/widgets@2x.png -------------------------------------------------------------------------------- /docs/interfaces/__global.window.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Window | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

Interface Window

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |

Hierarchy

73 |
    74 |
  • 75 | Window 76 |
  • 77 |
78 |
79 |
80 |

Index

81 |
82 |
83 |
84 |

Properties

85 | 88 |
89 |
90 |
91 |
92 |
93 |

Properties

94 |
95 | 96 |

oss_FindXrm

97 |
oss_FindXrm: () => XrmStatic
98 | 103 |
104 |

Type declaration

105 |
    106 |
  • 107 |
      108 |
    • (): XrmStatic
    • 109 |
    110 |
      111 |
    • 112 |

      Returns XrmStatic

      113 |
    • 114 |
    115 |
  • 116 |
117 |
118 |
119 |
120 |
121 | 200 |
201 |
202 | 223 |
224 |

Generated using TypeDoc

225 |
226 |
227 | 228 | 229 | 230 | -------------------------------------------------------------------------------- /docs/modules/__global.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | __global | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 61 |

Namespace __global

62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |

Index

70 |
71 |
72 |
73 |

Interfaces

74 | 77 |
78 |
79 |
80 |
81 |
82 | 152 |
153 |
154 | 175 |
176 |

Generated using TypeDoc

177 |
178 |
179 | 180 | 181 | 182 | -------------------------------------------------------------------------------- /docs/pages/Tutorials/02_Navigation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Navigation | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

Navigation

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |

Navigation

74 |
75 |

There is support for opening UCI apps, update forms and create forms. 76 | All of these calls use parametrized URLs and wait for the page to fully load and all network requests to be finished. This guarantees for a robust and fast usage experience.

77 | 78 |

Open UCI apps

79 |
80 |

UCI apps are currently opened by their appId. You can find it out by opening the app in your D365 organization and taking a look into the URL bar in your browser.

81 |

There will be a parameter called appId, which will print the id of the currently opened app. 82 | You can use it like that afterwards:

83 |
await xrmTest.Navigation.openAppById("3a5ff736-45a5-4318-a05e-c8a98761e64a");
 84 | 
85 | 86 |

Open create forms

87 |
88 |

Opening create forms just requires the entity logical name of the entity form that you want to open:

89 |
await xrmTest.Navigation.openCreateForm("account");
 90 | 
91 | 92 |

Open update forms

93 |
94 |

This allows to open forms with existing records. It works just like the openCreateForm function, but takes another parameter for the record id:

95 |
await xrmTest.Navigation.openUpdateForm("account", "83702a07-d3eb-4774-bdab-1d768a2f94d6");
 96 | 
97 | 98 |

Open quick create

99 |
100 |

You can open the global quick create very much like openCreateForm by calling its function with the entity logical name as parameter. Afterwards you :

101 |
await xrmTest.Navigation.openQuickCreate("account");
102 | 
103 | // This will already execute inside the quick create and set the account name
104 | await xrmTest.Attribute.setValue("name", "Test name");
105 | 
106 | // This will return the id of the record that was created
107 | const id = await xrmTest.Entity.save();
108 | 
109 | 110 |

Other navigation options (EntityList, WebResource, EntityRecord)

111 |
112 |

There is a navigateTo function which allows for flexible navigation inside the system. Client SDK is used for issuing navigation calls.

113 | 114 |

Open Entity List

115 |
116 |
await xrmTest.Navigation.navigateTo({
117 |     pageType: "entitylist",
118 |     entityName: "account"
119 | });
120 | 
121 |
122 |
123 | 190 |
191 |
192 | 213 |
214 |

Generated using TypeDoc

215 |
216 |
217 | 218 | 219 | 220 | -------------------------------------------------------------------------------- /docs/pages/Tutorials/04_Controls.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Controls | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

Controls

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |

Controls

74 |
75 |

Controls can currently be checked for their disabled state, their hidden state and their option set values (to check if filtering works).

76 | 77 |

Hidden / Disabled State

78 |
79 |

Hidden and disabled state can be retrieved as a combined object:

80 |
const { isVisible, isDisabled } = await xrmTest.Control.get("name");
 81 | 
82 | 83 |

Options

84 |
85 |

The array of currently available options for a control can be retrieved like this:

86 |
const options = await xrmTest.Control.getOptions("industrycode");
 87 | 
88 |
89 |
90 | 157 |
158 |
159 | 180 |
181 |

Generated using TypeDoc

182 |
183 |
184 | 185 | 186 | 187 | -------------------------------------------------------------------------------- /docs/pages/Tutorials/05_Buttons.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Buttons | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

Buttons

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |

Buttons

74 |
75 | 76 |

Click

77 |
78 |

Buttons can be clicked either by name or by data-id. 79 | The data-ids can be found in the HTML DOM, the labels are just the ones you see in the D365 UI. 80 | This should also work for clicking subgrid buttons.

81 | 82 |

By Label

83 |
84 |

Clicks the first button with the specified label:

85 |
await xrmTest.Button.click({ byLabel: "Create Document" });
 86 | 
87 | 88 |

By Data-Id

89 |
90 |

Clicks the first button with the specified data-id:

91 |
await xrmTest.Button.click({ byDataId: "account|NoRelationship|Form|mscrmaddons.am.form.createworkingitem.account" });
 92 | 
93 |
94 |
95 | 162 |
163 |
164 | 185 |
186 |

Generated using TypeDoc

187 |
188 |
189 | 190 | 191 | 192 | -------------------------------------------------------------------------------- /docs/pages/Tutorials/06_Subgrids.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Subgrids | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

Subgrids

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |

Subgrids

74 |
75 |

There are various functions for interacting with subgrids, which are listed below.

76 | 77 |

Get record count

78 |
79 |

Gets the number of records that are currently displayed.

80 |
const count = await xrmUiTest.Subgrid.getRecordCount("subgrid1");
 81 | 
82 | 83 |

Open n-th record

84 |
85 |

Opens the update form for the record at position n.

86 |
await xrmUiTest.Subgrid.openNthRecord("subgrid1", 1);
 87 | 
88 | 89 |

Refresh

90 |
91 |

Refreshes the subgrid

92 |
await xrmUiTest.Subgrid.refresh("subgrid1");
 93 | 
94 | 95 |

Create new record

96 |
97 |

Takes care of opening the tab where the subgrid resides and clicking its "Add New Record" default button. 98 | If the button is hidden inside the overflow menu, the overflow menu is searched as well.

99 |

Note:

100 |
101 |

If the button fails to get clicked, check user permissions, button hide rules and whether you use custom create buttons, as we search for the default create button

102 |
103 |
await xrmUiTest.Subgrid.createNewRecord("subgrid1");
104 | 
105 |
106 |
107 | 174 |
175 |
176 | 197 |
198 |

Generated using TypeDoc

199 |
200 |
201 | 202 | 203 | 204 | -------------------------------------------------------------------------------- /docs/pages/Tutorials/07_Tabs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Tabs | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

Tabs

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |

Tabs

74 |
75 |

Tabs don't need to be used for getting or setting values. 76 | However you might want to open them for your IFrames to load correctly (hidden ones will not load on start in D365).

77 | 78 |

Open

79 |
80 |

You can expand / select the active tab like this:

81 |
await xrmTest.Tab.open("tab_1");
 82 | 
83 | 84 |

Hidden State

85 |
86 |

Hidden state can be retrieved like this:

87 |
const { isVisible } = await xrmTest.Tab.get("SUMMARY_TAB");
 88 | 
89 |
90 |
91 | 158 |
159 |
160 | 181 |
182 |

Generated using TypeDoc

183 |
184 |
185 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /docs/pages/Tutorials/08_Entity.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Entity | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

Entity

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |

Entity

74 |
75 |

These are functions for dealing with the entity data on a form.

76 | 77 |

No Submit

78 |
79 |

When just opening a create form and entering data, you might not even want to save your data. 80 | If you just try to navigate away without saving, then a confirm message will appear, reminding you that you have unsaved data.

81 |
await xrmTest.Entity.noSubmit();
 82 | 
83 |

If you really want to navigate to a different page without saving, call the xrmTest.Entity.noSubmit function for setting all attributes to submitMode never, so that none would be saved and thus CRM does not show the prompt.

84 | 85 |

Save

86 |
87 |

Saves the data on your current form.

88 |
await xrmTest.Entity.save();
 89 | 
90 |
91 |

This does not use the save button, but the SDK function for saving

92 |
93 | 94 |

Get Id

95 |
96 |

Gets the ID of the current record.

97 |
const id = await xrmTest.Entity.getId();
 98 | 
99 | 100 |

Get Entity Name

101 |
102 |

Gets the logical name of the current record (entity)

103 |
await xrmTest.Entity.getEntityName();
104 | 
105 | 106 |

Get Entity Reference

107 |
108 |

Gets an entity reference pointing to the current record.

109 |
const { id, entityName, name } = await xrmTest.Entity.getEntityReference();
110 | 
111 |
112 |

The return object has the schema { id: string, entityName: string, name: string }

113 |
114 | 115 |

Delete

116 |
117 |

Deletes the current record.

118 |
await xrmTest.Entity.delete();
119 | 
120 |
121 |

This function uses the delete button of the form.

122 |
123 | 124 |

Activate

125 |
126 |

Activates the current record

127 |
await xrmTest.Entity.activate();
128 | 
129 |
130 |

This function uses the activate button of the form

131 |
132 | 133 |

Deactivate

134 |
135 |

Deactivates the current record

136 |
await xrmTest.Entity.deactivate();
137 | 
138 |
139 |

This function uses the deactivate button of the form

140 |
141 |
142 |
143 | 210 |
211 |
212 | 233 |
234 |

Generated using TypeDoc

235 |
236 |
237 | 238 | 239 | 240 | -------------------------------------------------------------------------------- /docs/pages/Tutorials/09_Dialogs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dialogs | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

Dialogs

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |

Dialogs

74 |
75 |

This namespace is used for handlers that interact with D365 default dialogs. 76 | Previously the duplicate dialog was controlled from here, but it was integrated in the xrmTest.Entity.save function.

77 |

For now there is no functionality in here, but as soon as new default dialogs have to be handled, this will be done in here again.

78 |

For custom dialogs, you can always just use the page object that you retrieve from xrmTest.open. 79 | This allows to interact with the browser using playwright.

80 |
81 |
82 | 149 |
150 |
151 | 172 |
173 |

Generated using TypeDoc

174 |
175 |
176 | 177 | 178 | 179 | -------------------------------------------------------------------------------- /docs/pages/Tutorials/11_FAQs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FAQs | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

FAQs

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |

FAQs

74 |
75 | 76 |

Can I download files, e.g. reports, Excel exports or Documents Core Pack documents?

77 |
78 |

Yes, you can. You just need to set the acceptDownloads property on the browserContext settings:

79 |
await xrmTest.launch("chromium",
 80 |     {
 81 |         headless: false,
 82 |         args: [
 83 |             '--disable-setuid-sandbox',
 84 |             '--disable-infobars',
 85 |             '--start-fullscreen',
 86 |             '--window-position=0,0',
 87 |             '--window-size=1920,1080',
 88 |             '--user-agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"'
 89 |         ]
 90 |     },
 91 |     {
 92 |         allowDownloads: true
 93 |     })
 94 | 
95 |

This will accept all following downloads and save them with a generic file name. 96 | If you wish to save it with the name that the browser intended to, you can do so as follows:

97 |
const [ download ] = await Promise.all([
 98 |   page.waitForEvent('download'), // wait for download to start
 99 |   page.click('a') // Or any other event that triggers your download
100 | ]);
101 | 
102 | const suggestedFileName = download.suggestedFilename();
103 | await download.saveAs(`./yourDownloadFolder/suggestedFileName`);
104 | 
105 |

You can check whether the file was successfully downloaded using the checkForFile function of TestUtils:

106 |
await TestUtils.checkForFile(page, path.resolve("./reports"), [".pdf", ".pdf'"]);
107 | 
108 | 109 |

Can I interact with Xrm functions that are not implemented by now?

110 |
111 |

Yes, you can. You can use page.evaluate for doing this. You should be careful, as page.evaluate can not access variables from its outer context.

112 |

You have to pass all variables that you want to use as second argument in page.evaluate like this:

113 |
const logicalName = "account";
114 | const id = "someid";
115 | 
116 | await page.evaluate((l, i) => {
117 |     window.Xrm.WebApi.deleteRecord(l, i);
118 | }, [ logicalName, id ]);
119 | 
120 |
121 |
122 | 189 |
190 |
191 | 212 |
213 |

Generated using TypeDoc

214 |
215 |
216 | 217 | 218 | 219 | -------------------------------------------------------------------------------- /docs/pages/Tutorials/12_TestUtils.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TestUtils | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

TestUtils

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |

Test Utils

74 |
75 |

These are various utils that are supposed to help you achieve commonly needed tasks when doing UI tests.

76 | 77 |

clearfiles

78 |
79 |

This can be used for clearing downloads or reports files from previous runs.

80 | 81 |

checkForFile

82 |
83 |

This can be used for checking whether a specific file (most often by file ending) has been downloaded to a specific folder.

84 | 85 |

takeScreenShotOnFailure

86 |
87 |

This is useful for being able to see what happened when tests failed in DevOps. It will take a screenshot of the whole page and save it with the specified file name if a test fails. The error will be rethrown so that no information on the error is lost.

88 | 89 |

trackTimedOutRequest

90 |
91 |

This is useful when navigation timeout errors occur. 92 | When loading pages, we wait for all requests to finish (currently 60 seconds wait time by default, but you can increase it using xrmTest.settings = { timeout: 120 * 1000 } for example, for setting the default timeout to 120 seconds).

93 |

If you get errors nonetheless, please use this function for reporting the URLs that took longer to load. 94 | We already abort some URLs which have shown timeout issues without being necessary for D365 to work.

95 |
96 |
97 | 164 |
165 |
166 | 187 |
188 |

Generated using TypeDoc

189 |
190 |
191 | 192 | 193 | 194 | -------------------------------------------------------------------------------- /docs/pages/Tutorials/13_Troubleshooting.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Troubleshooting | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

Troubleshooting

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |

Troubleshooting

74 |
75 | 76 |

NavigationTimeouts lead to test failures

77 |
78 |

On most of the navigation functions we wait for the page to settle by checking that it stays idle for 2 seconds without interruption.

79 |

If it gets busy during those two seconds, we reset the 2 seconds wait time.

80 |

If you nonetheless get time out errors, you can track which requests timed out using the TestUtils.trackTimedOutRequest function:

81 |
TestUtils.trackTimedOutRequest(() => page, () => xrmTest.Navigation.openAppById("3a5ff736-45a5-4318-a05e-c8a98761e64a"));
 82 | 
83 |

If timeouts occur, D365-UI-Test will log the timed out request URLs to console.

84 |
85 |
86 | 153 |
154 |
155 | 176 |
177 |

Generated using TypeDoc

178 |
179 |
180 | 181 | 182 | 183 | -------------------------------------------------------------------------------- /docs/pages/Tutorials/14_Sections.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sections | D365-UI-Test 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 28 |
29 |
30 | Options 31 |
32 |
33 | All 34 |
    35 |
  • Public
  • 36 |
  • Public/Protected
  • 37 |
  • All
  • 38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 | Menu 47 |
48 |
49 |
50 |
51 |
52 |
53 | 64 |

Sections

65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |

Sectopms

74 |
75 |

Sections are only used for being able to get their visibility state.

76 | 77 |

Hidden State

78 |
79 |

Hidden state can be retrieved like this:

80 |
const { isVisible } = await xrmTest.Section.get("SUMMARY_TAB", "ACCOUNT_INFORMATION");
 81 | 
82 |
83 |
84 | 151 |
152 |
153 | 174 |
175 |

Generated using TypeDoc

176 |
177 |
178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['./src', './spec'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d365-ui-test", 3 | "version": "0.0.0", 4 | "description": "An unopinionated UI testing library for Dynamics 365 CE and Dynamics Portals. Powered by TypeScript and playwright", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "scripts": { 8 | "lint": "tslint --fix --project .", 9 | "test": "jest --runInBand", 10 | "prebuild": "npm run lint", 11 | "build": "tsc", 12 | "docs": "typedoc" 13 | }, 14 | "files": [ 15 | "LICENSE.md", 16 | "README.md", 17 | "dist" 18 | ], 19 | "author": "Florian Kroenert", 20 | "license": "MIT", 21 | "devDependencies": { 22 | "@types/jest": "^29.5.1", 23 | "jest": "^29.5.0", 24 | "ts-jest": "^29.1.0", 25 | "tslint": "^6.1.3", 26 | "typedoc": "^0.24.6", 27 | "typedoc-plugin-pages": "^1.1.0" 28 | }, 29 | "dependencies": { 30 | "@types/speakeasy": "^2.0.7", 31 | "@types/xrm": "^9.0.71", 32 | "playwright": "^1.32.3", 33 | "speakeasy": "^2.0.0", 34 | "typescript": "^5.0.4" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pagesconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "groups": [ 3 | { 4 | "title": "Tutorials", 5 | "pages": [ 6 | { 7 | "title": "Startup", 8 | "source": "./tutorials/01_Startup.md" 9 | }, 10 | { 11 | "title": "Navigation", 12 | "source": "./tutorials/02_Navigation.md" 13 | }, 14 | { 15 | "title": "Attributes", 16 | "source": "./tutorials/03_Attributes.md" 17 | }, 18 | { 19 | "title": "Controls", 20 | "source": "./tutorials/04_Controls.md" 21 | }, 22 | { 23 | "title": "Buttons", 24 | "source": "./tutorials/05_Buttons.md" 25 | }, 26 | { 27 | "title": "Subgrids", 28 | "source": "./tutorials/06_Subgrids.md" 29 | }, 30 | { 31 | "title": "Tabs", 32 | "source": "./tutorials/07_Tabs.md" 33 | }, 34 | { 35 | "title": "Sections", 36 | "source": "./tutorials/14_Sections.md" 37 | }, 38 | { 39 | "title": "Entity", 40 | "source": "./tutorials/08_Entity.md" 41 | }, 42 | { 43 | "title": "Dialogs", 44 | "source": "./tutorials/09_Dialogs.md" 45 | }, 46 | { 47 | "title": "DevOps", 48 | "source": "./tutorials/10_DevOps.md" 49 | }, 50 | { 51 | "title": "FAQs", 52 | "source": "./tutorials/11_FAQs.md" 53 | }, 54 | { 55 | "title": "TestUtils", 56 | "source": "./tutorials/12_TestUtils.md" 57 | }, 58 | { 59 | "title": "Troubleshooting", 60 | "source": "./tutorials/13_Troubleshooting.md" 61 | } 62 | ] 63 | } 64 | ] 65 | } -------------------------------------------------------------------------------- /spec/portal-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { PortalUiTest } from "../src"; 2 | import { TestUtils } from "../src/utils/TestUtils"; 3 | import * as playwright from "playwright"; 4 | import * as fs from "fs"; 5 | 6 | const portalTest = new PortalUiTest(); 7 | let browser: playwright.Browser; 8 | let context: playwright.BrowserContext; 9 | let page: playwright.Page; 10 | 11 | describe("Basic operations UCI", () => { 12 | beforeAll(async () => { 13 | jest.setTimeout(60000); 14 | 15 | await portalTest.launch("chromium", { 16 | headless: false, 17 | args: [ 18 | "--disable-setuid-sandbox", 19 | "--disable-infobars", 20 | "--start-fullscreen", 21 | "--window-position=0,0", 22 | "--window-size=1920,1080", 23 | '--user-agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"' 24 | ] 25 | }) 26 | .then(([b, c, p]) => { 27 | browser = b; 28 | context = c; 29 | page = p; 30 | }); 31 | 32 | await page.setViewportSize({ width: 1920, height: 1080 }); 33 | }); 34 | 35 | test("It should create email", TestUtils.takeScreenShotOnFailure(() => page, "./reports/emailTest.png", async () => { 36 | jest.setTimeout(60000); 37 | 38 | await page.goto("https://portal.microsoftportals.com/de-DE/"); 39 | 40 | const link = await page.waitForSelector("a[href*='kontakt']"); 41 | await link.click(); 42 | 43 | const emailLink = await page.waitForSelector("a[href*='email']"); 44 | await emailLink.click(); 45 | 46 | await page.waitForSelector("#demo_firstname"); 47 | await page.type("#demo_firstname", "UI Test Firstname"); 48 | await page.type("#demo_lastname", "UI Test Lastname"); 49 | 50 | await page.type("#emailaddress", "uitest@orbis.de"); 51 | await page.type("#demo_street", "Planckstraße 10"); 52 | await page.type("#demo_zippostalcode", "88677"); 53 | await page.type("#demo_city", "Markdorf"); 54 | await page.selectOption("#demo_countrycode", "100000085"); 55 | await page.type("#title", "Automated UI Test"); 56 | 57 | await page.type("#description", "UI Test Firstname"); 58 | 59 | await page.evaluate(() => { 60 | (document.querySelector("#InsertButton") as any).click(); 61 | }); 62 | 63 | await page.waitForNavigation({ waitUntil: "networkidle" }); 64 | 65 | const successDiv = await page.$(".alert-case-created"); 66 | expect(successDiv).toBeDefined(); 67 | })); 68 | 69 | afterAll(() => { 70 | return portalTest.close(); 71 | }); 72 | }); -------------------------------------------------------------------------------- /spec/xrm-uci-ui-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { XrmUiTest } from "../src"; 2 | import * as fs from "fs"; 3 | import * as playwright from "playwright"; 4 | import * as path from "path"; 5 | import { TestUtils } from "../src/utils/TestUtils"; 6 | 7 | const xrmTest = new XrmUiTest(); 8 | let browser: playwright.Browser; 9 | let context: playwright.BrowserContext; 10 | let page: playwright.Page; 11 | 12 | describe("Basic operations UCI", () => { 13 | beforeAll(async() => { 14 | jest.setTimeout(60000); 15 | 16 | await xrmTest.launch("chromium", { 17 | headless: false, 18 | args: [ 19 | "--disable-setuid-sandbox", 20 | "--disable-infobars", 21 | "--start-fullscreen", 22 | "--window-position=0,0", 23 | "--window-size=1920,1080", 24 | '--user-agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"' 25 | ] 26 | }) 27 | .then(([b, c, p]) => { 28 | browser = b; 29 | context = c; 30 | page = p; 31 | }); 32 | }); 33 | 34 | test("It should log in", async () => { 35 | const settingsPath = path.join(__dirname, "../../settings.txt"); 36 | const settingsFound = fs.existsSync(settingsPath); 37 | const config = settingsFound ? fs.readFileSync(settingsPath, {encoding: "utf-8"}) : `${process.env.D365_UI_TEST_URL ?? process.env.CRM_URL ?? ""},${process.env.D365_UI_TEST_USERNAME ?? process.env.USER_NAME ?? ""},${process.env.D365_UI_TEST_PASSWORD ?? process.env.USER_PASSWORD ?? ""},${process.env.D365_UI_TEST_MFA_SECRET ?? process.env.MFA_SECRET ?? ""}`; 38 | const [url, user, password, mfaSecret] = config.split(","); 39 | 40 | await xrmTest.open(url, { userName: user, password: password, mfaSecret: mfaSecret ?? undefined }); 41 | }, 120000); 42 | 43 | test("It should set string field", TestUtils.takeScreenShotOnFailure(() => page, path.resolve("reports", "dialogError.png"), async () => { 44 | await xrmTest.Navigation.openCreateForm("account"); 45 | await xrmTest.Attribute.setValue("name", "Test name"); 46 | 47 | await xrmTest.Entity.save(true); 48 | await xrmTest.Entity.delete(); 49 | }), 120000); 50 | 51 | test("It should set option field", async () => { 52 | await xrmTest.Navigation.openCreateForm("account"); 53 | 54 | await xrmTest.Attribute.setValues({ 55 | "address1_shippingmethodcode": 1 56 | }); 57 | 58 | const value = await xrmTest.Attribute.getValue("address1_shippingmethodcode"); 59 | expect(value).toBe(1); 60 | }, 60000); 61 | 62 | test("It should survive navigation popup", async () => { 63 | await xrmTest.Navigation.openCreateForm("account"); 64 | 65 | await xrmTest.Attribute.setValues({ 66 | "name": "Test name" 67 | }); 68 | 69 | await xrmTest.Entity.save(true); 70 | await xrmTest.Attribute.setValue("name", "Updated"); 71 | await xrmTest.Navigation.openCreateForm("account"); 72 | }, 60000); 73 | 74 | /* 75 | test("It should set quick create fields", async () => { 76 | jest.setTimeout(60000); 77 | 78 | await xrmTest.Navigation.openQuickCreate("account"); 79 | console.log("Form open"); 80 | await xrmTest.Attribute.setValue("name", "Test name"); 81 | await page.waitFor(50000); 82 | const value = await xrmTest.Attribute.getValue("name"); 83 | expect(value).toBe("Test name"); 84 | 85 | const id = await xrmTest.Entity.save(); 86 | }); 87 | */ 88 | 89 | afterAll(() => { 90 | return xrmTest.close(); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/domain/D365Selectors.ts: -------------------------------------------------------------------------------- 1 | export const D365Selectors = { 2 | Login: { 3 | userName: "#i0116", 4 | password: "#i0118", 5 | otp: "#idTxtBx_SAOTCC_OTC", 6 | dontRememberLogin: "#idBtn_Back" 7 | }, 8 | PopUp: { 9 | cancel: "#cancelButton", 10 | confirm: "#confirmButton" 11 | }, 12 | DuplicateDetection: { 13 | ignore: 'button[data-id="ignore_save"]', 14 | abort: 'button[data-id="close_dialog"]' 15 | }, 16 | Grid: { 17 | DataRowWithIndexCheckBox: "div[wj-part='root'] > div[wj-part='cells'] > div.wj-row[aria-rowindex='{0}'] > div.wj-cell.data-selectable" 18 | } 19 | }; -------------------------------------------------------------------------------- /src/domain/SharedLogic.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | 3 | export const isPageElement = (value: any): value is playwright.ElementHandle => { 4 | return !!value && (value as playwright.ElementHandle).click !== undefined; 5 | }; -------------------------------------------------------------------------------- /src/domain/TestSettings.ts: -------------------------------------------------------------------------------- 1 | export interface TestSettings { 2 | /** 3 | * Default timeout for navigation events in ms 4 | * 5 | * @default 60000 6 | */ 7 | timeout?: number; 8 | 9 | /** 10 | * Duration in milliseconds the page has to stay idle when checking for page idleness 11 | * 12 | * @default 2000 13 | */ 14 | settleTime?: number; 15 | 16 | /** 17 | * Debug mode prints more information on what is going on 18 | * 19 | * @default false 20 | */ 21 | debugMode?: boolean; 22 | 23 | /** 24 | * If performance mode is enabled, &perf=true is added into the Dynamics URL, for showing the MS performance overlay on all pages 25 | */ 26 | performanceMode?: boolean; 27 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./portal/PortalUITest"; 2 | export * from "./xrm/XrmUITest"; 3 | 4 | export * from "./utils/TestUtils"; -------------------------------------------------------------------------------- /src/portal/PortalUITest.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | 3 | /** 4 | * Main class for testing in Portals 5 | */ 6 | export class PortalUiTest { 7 | private _browser: playwright.Browser; 8 | private _context: playwright.BrowserContext; 9 | private _page: playwright.Page; 10 | private _portalUrl: string; 11 | 12 | get browser() { 13 | return this._browser; 14 | } 15 | 16 | get context() { 17 | return this._context; 18 | } 19 | 20 | get page() { 21 | return this._page; 22 | } 23 | 24 | /** 25 | * Function for launching a playwright instance 26 | * 27 | * @param {string} [browser] [chromium] Decide which browser to launch, options are chromium, firefox or webkit 28 | * @param {playwright.launchOptions} [launchOptions] Launch options for launching playwright. Will be used for calling playwright.launch. 29 | * @returns {Array => { 36 | this._browser = await playwright[browser].launch(launchOptions); 37 | // tslint:disable-next-line:no-null-keyword 38 | this._context = await this._browser.newContext({ viewport: null, ...contextOptions }); 39 | this._page = await this._context.newPage(); 40 | 41 | return [this._browser, this._context, this._page]; 42 | } 43 | 44 | /** 45 | * Open the portals instance by url 46 | * 47 | * @param {string} [url] Url of the portal you wish to open 48 | */ 49 | open = async (url: string) => { 50 | this._portalUrl = url; 51 | 52 | await Promise.all([ 53 | this.page.goto(`${this._portalUrl}`), 54 | this.page.waitForNavigation({ waitUntil: "networkidle" }) 55 | ]); 56 | } 57 | 58 | /** 59 | * Log in as portal user 60 | * 61 | * @param {string} user User name of the user to log in 62 | * @param {string} password Password of the user to log in 63 | */ 64 | login = async(userName: string, password: string) => { 65 | await this.page.goto(`${this._portalUrl}/signIn`); 66 | await this.page.waitForNavigation({ waitUntil: "networkidle" }); 67 | 68 | const userNameField = await this.page.waitForSelector("#Username"); 69 | await userNameField.type(userName); 70 | 71 | const passwordField = await this.page.waitForSelector("#Password"); 72 | await passwordField.type(password); 73 | 74 | await this.page.keyboard.press("enter"); 75 | 76 | await this.page.waitForNavigation({ waitUntil: "networkidle" }); 77 | } 78 | 79 | /** 80 | * Shut down 81 | */ 82 | close = async () => { 83 | await this.browser.close(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/RethrownError.ts: -------------------------------------------------------------------------------- 1 | class ExtendedError extends Error { 2 | constructor (message: string) { 3 | super(message); 4 | 5 | this.name = this.constructor.name; 6 | this.message = message; 7 | 8 | if (typeof Error.captureStackTrace === "function") { 9 | Error.captureStackTrace(this, this.constructor); 10 | } else { 11 | this.stack = (new Error(message)).stack; 12 | } 13 | } 14 | } 15 | 16 | export class RethrownError extends ExtendedError { 17 | constructor(message: string, error: Error) { 18 | super(message); 19 | 20 | if (!error) { 21 | throw message; 22 | } 23 | 24 | const messageLines = (this.message.match(/\n/g) || []).length + 1; 25 | this.stack = this.stack.split("\n").slice(0, messageLines + 1).join("\n") + "\n" + error.stack; 26 | } 27 | } -------------------------------------------------------------------------------- /src/utils/TestUtils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import * as playwright from "playwright"; 4 | 5 | export namespace TestUtils { 6 | /** 7 | * Clear all files in a folder (or matching a specified file ending) 8 | * 9 | * @param pathName Folder where files need to be cleaned 10 | * @param fileEndings Endings which determine which files to delete. Leave empty to delete all. @default undefined 11 | * @param createIfNotFound Create folder if not found. @default false 12 | */ 13 | export const clearFiles = async (pathName: string, fileEndings: Array = undefined, createIfNotFound = false): Promise => { 14 | const exists = await new Promise((resolve, reject) => fs.exists(pathName, (exists) => resolve(exists))); 15 | 16 | if (!exists && createIfNotFound) { 17 | fs.mkdirSync(pathName); 18 | } 19 | 20 | return new Promise((resolve, reject) => fs.readdir(pathName, (err, files) => err ? reject(err) : resolve(files.filter(f => !fileEndings || fileEndings.some(e => f.endsWith(e))).forEach(f => fs.unlinkSync(path.resolve(pathName, f)))))); 21 | }; 22 | 23 | /** 24 | * Take screenshots on function failure. Useful for taking screenshots for failed tests. Will rethrow the error. 25 | * 26 | * @param page Page object of current playwright session 27 | * @param filePath Path where the screenshot should be saved on failure. Has to include file ending such as .png. 28 | * @param func Your actual test 29 | * @example 30 | * test("Open UCI", takeScreenShotOnFailure("OpenUCI", async () => { 31 | * return xrmTest.Navigation.openAppById("default365"); 32 | * })); 33 | */ 34 | export const takeScreenShotOnFailure = (pageGetter: () => playwright.Page, filePath: string, func: () => void | Promise): any => { 35 | return async () => { 36 | try { 37 | await Promise.resolve(func()); 38 | } 39 | catch (e) { 40 | const page = pageGetter(); 41 | 42 | // Page can be null in case playwright fails to start. Accessing it to take a screenshot would fail and overwrite the root exception 43 | if (page) { 44 | const dirName = path.dirname(filePath); 45 | const folderExists = fs.existsSync(dirName); 46 | 47 | if (!folderExists) { 48 | fs.mkdirSync(dirName); 49 | } 50 | 51 | console.log("Saving error screenshot to " + filePath); 52 | 53 | await page.screenshot({ path: filePath }); 54 | } 55 | 56 | throw(e); 57 | } 58 | }; 59 | }; 60 | 61 | const checkForFileInternal = async (page: playwright.Page, pathName: string, fileEndings: Array, sleepTime: number, numberOfTries: number, tries = 0): Promise => { 62 | if (tries >= numberOfTries) { 63 | return Promise.reject(`Tried ${numberOfTries} times, aborting`); 64 | } 65 | 66 | const found = await new Promise((resolve, reject) => fs.readdir(pathName, (err, files) => err ? reject(err) : resolve(files.some(f => fileEndings.some(e => f.endsWith(e)))))); 67 | 68 | if (found) { 69 | return true; 70 | } 71 | 72 | await page.waitForTimeout(sleepTime); 73 | return checkForFileInternal(page, pathName, fileEndings, sleepTime, numberOfTries, ++tries); 74 | }; 75 | 76 | /** 77 | * Checks for a file to exist. Useful for validating if playwright downloads succeeded. 78 | * 79 | * @param page playwright page object for current session 80 | * @param pathName Folder path to search 81 | * @param fileEndings File ending to search for. Can be full name as well 82 | * @param sleepTime [500] Time to wait between checks 83 | * @param numberOfTries [10] Number of tries to do 84 | */ 85 | export const checkForFile = async (page: playwright.Page, pathName: string, fileEndings: Array, sleepTime = 500, numberOfTries = 10): Promise => { 86 | return checkForFileInternal(page, pathName, fileEndings, sleepTime, numberOfTries); 87 | }; 88 | 89 | class InflightRequests { 90 | _page: playwright.Page; 91 | _requests: Set; 92 | 93 | constructor(page: playwright.Page) { 94 | this._page = page; 95 | this._requests = new Set(); 96 | this._onStarted = this._onStarted.bind(this); 97 | this._onFinished = this._onFinished.bind(this); 98 | this._page.on("request", this._onStarted); 99 | this._page.on("requestfinished", this._onFinished); 100 | this._page.on("requestfailed", this._onFinished); 101 | } 102 | 103 | _onStarted(request: any) { this._requests.add(request); } 104 | _onFinished(request: any) { this._requests.delete(request); } 105 | 106 | inflightRequests() { return Array.from(this._requests); } 107 | 108 | dispose() { 109 | this._page.removeListener("request", this._onStarted); 110 | this._page.removeListener("requestfinished", this._onFinished); 111 | this._page.removeListener("requestfailed", this._onFinished); 112 | } 113 | } 114 | 115 | /** 116 | * If you come across network timeouts when using this library, you can use this function for finding out which requests were causing this. 117 | * 118 | * @param page The page object for the current session 119 | * @param func The function call that causes the navigation timeout 120 | * @example await trackTimedOutRequest(page, () => xrmTest.Navigation.openAppById("d365default")); 121 | */ 122 | export const trackTimedOutRequest = async (page: playwright.Page, func: () => void | Promise) => { 123 | const tracker = new InflightRequests(page); 124 | 125 | try { 126 | await Promise.resolve(func()); 127 | } 128 | catch (e) { 129 | console.log("Navigation failed: " + e.message); 130 | const inflight = tracker.inflightRequests(); 131 | console.log(inflight.map((request: any) => " " + request.url()).join("\n")); 132 | tracker.dispose(); 133 | 134 | throw(e); 135 | } 136 | }; 137 | } -------------------------------------------------------------------------------- /src/xrm/Attribute.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { RethrownError } from "../utils/RethrownError"; 3 | import { EnsureXrmGetter } from "./Global"; 4 | import { XrmUiTest } from "./XrmUITest"; 5 | 6 | /** 7 | * Define behavior during setting of field values 8 | */ 9 | interface SetValueSettings { 10 | /** 11 | * Time to wait after setting a value to give onChange handlers time to work 12 | */ 13 | settleTime?: number; 14 | /** 15 | * Set value no matter whether field is readonly or hidden 16 | */ 17 | forceValue?: boolean; 18 | } 19 | 20 | /** 21 | * Module for interacting with D365 Attributes 22 | */ 23 | export class Attribute { 24 | private _page: playwright.Page; 25 | 26 | constructor(private xrmUiTest: XrmUiTest) { 27 | this._page = xrmUiTest.page; 28 | this.xrmUiTest = xrmUiTest; 29 | } 30 | 31 | /** 32 | * Gets the required level of the specified attribute 33 | * 34 | * @param attributeName Name of the attribute 35 | * @returns Required level of the specified attribute 36 | */ 37 | getRequiredLevel = async (attributeName: string) => { 38 | try { 39 | await EnsureXrmGetter(this._page); 40 | 41 | return this._page.evaluate((attributeName: string) => { 42 | const xrm = window.oss_FindXrm(); 43 | return xrm.Page.getAttribute(attributeName).getRequiredLevel(); 44 | }, attributeName); 45 | } 46 | catch (e) { 47 | throw new RethrownError(`Error when getting required level of attribute '${attributeName}'`, e); 48 | } 49 | } 50 | 51 | /** 52 | * Gets the value of the specified attribute 53 | * 54 | * @param attributeName Name of the attribute 55 | * @returns Value of the specified attribute 56 | */ 57 | getValue = async (attributeName: string) => { 58 | try { 59 | await EnsureXrmGetter(this._page); 60 | 61 | const [attributeType, value] = await this._page.evaluate((attributeName: string) => { 62 | const xrm = window.oss_FindXrm(); 63 | const attribute = xrm.Page.getAttribute(attributeName); 64 | const attributeType = attribute.getAttributeType(); 65 | 66 | const isDate = attributeType === "datetime"; 67 | const value = attribute.getValue(); 68 | 69 | return [ attributeType, (isDate && value != undefined) ? value.toISOString() : value ]; 70 | }, attributeName); 71 | 72 | if (attributeType === "datetime" && typeof (value) === "string") { 73 | return new Date(Date.parse(value)); 74 | } 75 | else { 76 | return value; 77 | } 78 | } 79 | catch (e) { 80 | throw new RethrownError(`Error when getting value of attribute '${attributeName}'`, e); 81 | } 82 | } 83 | 84 | /** 85 | * Sets the value of the specified attribute 86 | * 87 | * @param attributeName Name of the attribute 88 | * @param value Value to set 89 | * @param settings [{settleTIme: 500, forceValue: false}] Settings defining time to wait (ms) after setting value for letting onChange events occur and whether to also write into hidden and readonly fields. 90 | * @returns Returns promise that resolves once value is set and settleTime is over 91 | */ 92 | setValue = async (attributeName: string, value: any, settings?: number | SetValueSettings) => { 93 | try { 94 | const defaults: SetValueSettings = { 95 | settleTime: 500, 96 | forceValue: false 97 | }; 98 | 99 | const safeSettings = { 100 | ...defaults, 101 | ...(typeof(settings) === "number" ? { settleTime: settings } as SetValueSettings : settings) 102 | }; 103 | 104 | const isDate = Object.prototype.toString.call(value) === "[object Date]"; 105 | await EnsureXrmGetter(this._page); 106 | 107 | await this._page.evaluate(([a, v, s]) => { 108 | const xrm = window.oss_FindXrm(); 109 | const attribute = xrm.Page.getAttribute(a); 110 | 111 | const editable = s.forceValue || attribute.controls.get().some((control: any) => { 112 | return !control.getDisabled() && control.getVisible() && (!control.getParent() || control.getParent().getVisible()) && (!control.getParent() || !control.getParent().getParent() || control.getParent().getParent().getVisible()); 113 | }); 114 | 115 | if (!editable) { 116 | throw new Error("Attribute has no unlocked and visible control, users can't set a value like that."); 117 | } 118 | 119 | attribute.setValue(attribute.getAttributeType() === "datetime" ? new Date(v) : v); 120 | attribute.fireOnChange(); 121 | }, [ attributeName, isDate ? value.toISOString() : value, safeSettings ]); 122 | 123 | await this._page.waitForTimeout(safeSettings.settleTime); 124 | await this.xrmUiTest.waitForIdleness(); 125 | } 126 | catch (e) { 127 | throw new RethrownError(`Error when setting value of attribute '${attributeName}'`, e); 128 | } 129 | } 130 | 131 | /** 132 | * Sets multiple attribute values at once 133 | * 134 | * @param values JS object with keys matching the attribute names and values containing the values to set 135 | * @param settings [{settleTIme: 500, forceValue: false}] Settings defining time to wait (ms) after setting value for letting onChange events occur and whether to also write into hidden and readonly fields. 136 | * @returns Returns promise that resolves once values are set and settleTime is over 137 | * @example XrmUiTest.Attribute.setValues({ name: "Account Name", creditlimit: 50000, customertypecode: 1, transactioncurrencyid: [{entityType: "transactioncurrency", name: "EURO", id: "someId"}] }) 138 | */ 139 | setValues = async (values: {[key: string]: any}, settings?: number | SetValueSettings) => { 140 | for (const attributeName in values) { 141 | await this.setValue(attributeName, values[attributeName], settings); 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/xrm/Button.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { XrmUiTest } from "./XrmUITest"; 3 | 4 | /** 5 | * Scheme to define how to access a button 6 | * Either by data-id (language independent) or by button label (language dependent, but easily visible in UI) 7 | */ 8 | export interface ButtonIdentifier { 9 | /** 10 | * Find button by data-id. You can find this in the HTML DOM when inspecting your form. Good if your tests need to be language independent 11 | * 12 | * @example account|NoRelationship|Form|mscrmaddons.am.form.createworkingitem.account 13 | */ 14 | byDataId?: string; 15 | 16 | /** 17 | * Find button by label. This is the plain button label that you can see in the UI. Be aware of language dependent button labels 18 | * 19 | * @example Delete 20 | */ 21 | byLabel?: string; 22 | 23 | /** 24 | * Pass a completely custom CSS selector for finding the button to click 25 | * 26 | * @example li[id*='DeletePrimaryRecord'] 27 | */ 28 | custom?: string; 29 | } 30 | 31 | /** 32 | * Module for interacting with D365 Buttons 33 | */ 34 | export class Button { 35 | private _page: playwright.Page; 36 | 37 | constructor(private xrmUiTest: XrmUiTest) { 38 | this._page = xrmUiTest.page; 39 | this.xrmUiTest = xrmUiTest; 40 | } 41 | 42 | /** 43 | * Expands the more commands ribbon menu 44 | * 45 | * @returns Promise which resolves once more commands was clicked 46 | */ 47 | expandMoreCommands = async(): Promise => { 48 | try { 49 | await this.click({ byDataId: "OverflowButton" }, false); 50 | await this.xrmUiTest.waitForIdleness(); 51 | return true; 52 | } 53 | catch { 54 | return false; 55 | } 56 | } 57 | 58 | private buildSelector = (identifier: ButtonIdentifier) => { 59 | if (identifier.byDataId) { 60 | return `button[data-id='${identifier.byDataId}']`; 61 | } 62 | 63 | if (identifier.byLabel) { 64 | return `button[aria-label='${identifier.byLabel}']`; 65 | } 66 | 67 | if (identifier.custom) { 68 | return identifier.custom; 69 | } 70 | }; 71 | 72 | /** 73 | * Clicks a ribbon button 74 | * 75 | * @param buttonIdentifier Identifier for finding button, either by label or by data-id 76 | * @param openMoreCommands [true] Whether more commands has to be clicked for finding the button. Will be used automatically if button is not found on first try 77 | * @returns Promise which resolves once button was clicked 78 | */ 79 | click = async(buttonIdentifier: ButtonIdentifier, openMoreCommands = true): Promise => { 80 | const selector = this.buildSelector(buttonIdentifier); 81 | const button = await this._page.$(selector); 82 | 83 | if (!button && openMoreCommands && await this.expandMoreCommands()) { 84 | return this.click(buttonIdentifier, false); 85 | } 86 | 87 | if (!button) { 88 | throw new Error("Failed to find button"); 89 | } 90 | 91 | await button.click(); 92 | return this.xrmUiTest.waitForIdleness(); 93 | } 94 | 95 | /** 96 | * Checks if a button is visible 97 | * 98 | * @param buttonIdentifier Identifier for finding button, either by label or by data-id 99 | * @param openMoreCommands [true] Whether more commands has to be clicked for finding the button. Will be used automatically if button is not found on first try 100 | * @returns Promise which resolves with a boolean value indicating if the button was visible 101 | */ 102 | isVisible = async(buttonIdentifier: ButtonIdentifier, openMoreCommands = true): Promise => { 103 | const selector = this.buildSelector(buttonIdentifier); 104 | const button = await this._page.$(selector); 105 | 106 | if (!button && openMoreCommands && await this.expandMoreCommands()) { 107 | return this.isVisible(buttonIdentifier, false); 108 | } 109 | 110 | return button?.isVisible() ?? false; 111 | } 112 | } -------------------------------------------------------------------------------- /src/xrm/Control.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { RethrownError } from "../utils/RethrownError"; 3 | import { EnsureXrmGetter } from "./Global"; 4 | import { XrmUiTest } from "./XrmUITest"; 5 | 6 | /** 7 | * State of a control 8 | */ 9 | export interface ControlState { 10 | /** 11 | * Whether the control is currently visible 12 | */ 13 | isVisible: boolean; 14 | 15 | /** 16 | * Whether the control is currently disabled 17 | */ 18 | isDisabled: boolean; 19 | } 20 | 21 | /** 22 | * Module for interacting with D365 Controls 23 | */ 24 | export class Control { 25 | private _page: playwright.Page; 26 | 27 | constructor(private xrmUiTest: XrmUiTest) { 28 | this._page = xrmUiTest.page; 29 | this.xrmUiTest = xrmUiTest; 30 | } 31 | 32 | /** 33 | * Gets the state of the specified control 34 | * 35 | * @param controlName Name of the control to retrieve 36 | * @returns Promise which fulfills with the current control state 37 | */ 38 | get = async (controlName: string): Promise => { 39 | try { 40 | await EnsureXrmGetter(this._page); 41 | 42 | return this._page.evaluate((controlName: string) => { 43 | const xrm = window.oss_FindXrm(); 44 | const control = xrm.Page.getControl(controlName); 45 | 46 | return { 47 | isVisible: control.getVisible() && (!control.getParent() || control.getParent().getVisible()) && (!control.getParent() || !control.getParent().getParent() || control.getParent().getParent().getVisible()), 48 | isDisabled: (control as any).getDisabled() as boolean 49 | }; 50 | }, controlName); 51 | } 52 | catch (e) { 53 | throw new RethrownError(`Error when setting value of control '${controlName}'`, e); 54 | } 55 | } 56 | 57 | /** 58 | * Gets the options of the specified option set control 59 | * 60 | * @param controlName Name of the control to retrieve 61 | * @returns Promise which fulfills with the control's options 62 | */ 63 | getOptions = async (controlName: string): Promise> => { 64 | try { 65 | await EnsureXrmGetter(this._page); 66 | 67 | return this._page.evaluate((controlName: string) => { 68 | const xrm = window.oss_FindXrm(); 69 | const control = xrm.Page.getControl(controlName); 70 | 71 | return (control as any).getOptions(); 72 | }, controlName); 73 | } 74 | catch (e) { 75 | throw new RethrownError(`Error when getting options of control '${controlName}'`, e); 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /src/xrm/Dialog.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { XrmUiTest } from "./XrmUITest"; 3 | 4 | /** 5 | * Module for interacting with D365 dialogs 6 | */ 7 | export class Dialog { 8 | private _page: playwright.Page; 9 | 10 | constructor(private xrmUiTest: XrmUiTest) { 11 | this._page = xrmUiTest.page; 12 | this.xrmUiTest = xrmUiTest; 13 | } 14 | } -------------------------------------------------------------------------------- /src/xrm/Entity.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { D365Selectors } from "../domain/D365Selectors"; 3 | import { EnsureXrmGetter } from "./Global"; 4 | import { XrmUiTest } from "./XrmUITest"; 5 | import { RethrownError } from "../utils/RethrownError"; 6 | 7 | /** 8 | * Module for interacting with D365 entity records 9 | */ 10 | export class Entity { 11 | private _page: playwright.Page; 12 | 13 | constructor(private xrmUiTest: XrmUiTest) { 14 | this._page = xrmUiTest.page; 15 | this.xrmUiTest = xrmUiTest; 16 | } 17 | 18 | /** 19 | * Sets all attributes to submit mode none. This is useful if you don't want to save and just change the page. No prompt for unsaved data will open. 20 | * 21 | * @returns Promise which resolves once all attribute submit modes are set 22 | * @deprecated Use Form.noSubmit instead 23 | */ 24 | noSubmit = async () => { 25 | await EnsureXrmGetter(this._page); 26 | 27 | return this._page.evaluate(() => { 28 | const xrm = window.oss_FindXrm(); 29 | 30 | const attributes = xrm.Page.getAttribute(); 31 | 32 | attributes.forEach(a => a.setSubmitMode("never")); 33 | }); 34 | } 35 | 36 | /** 37 | * Refreshes the current form record 38 | * 39 | * @param saveData Whether to save any unsubmitted data 40 | */ 41 | refresh = async (saveData: boolean, ignoreDuplicateCheck = false) => { 42 | await EnsureXrmGetter(this._page); 43 | 44 | const refreshPromise = this._page.evaluate(([ save ]) => { 45 | const xrm = window.oss_FindXrm(); 46 | 47 | return xrm.Page.data.refresh(save); 48 | }, [ saveData ]); 49 | 50 | const promises = [ 51 | refreshPromise, 52 | this.xrmUiTest.waitForIdleness() 53 | ]; 54 | 55 | return this.handleDuplicateCheck(promises, ignoreDuplicateCheck); 56 | } 57 | 58 | handleDuplicateCheck = async (promises: Array>, ignoreDuplicateCheck = false) => { 59 | await Promise.race([ 60 | ...promises, 61 | // Wait for duplicate dialog 62 | this._page.waitForSelector(D365Selectors.DuplicateDetection.ignore, { timeout: this.xrmUiTest.settings.timeout }) 63 | ]); 64 | 65 | const duplicateCheckButton = await this._page.$(D365Selectors.DuplicateDetection.ignore); 66 | 67 | if (duplicateCheckButton) { 68 | if (ignoreDuplicateCheck) { 69 | await Promise.all([duplicateCheckButton.click(), Promise.race(promises)]); 70 | } 71 | else { 72 | await this._page.click(D365Selectors.DuplicateDetection.abort); 73 | throw new Error("Duplicate records found. Pass true for save parameter 'ignoreDuplicateCheck' for ignore and saving"); 74 | } 75 | } 76 | }; 77 | 78 | /** 79 | * Saves the record and returns the ID (both for quick create and "normal" create) 80 | * 81 | * @param ignoreDuplicateCheck [false] Whether to automatically ignore duplicate check warnings 82 | * @returns The id of the record 83 | */ 84 | save = async (ignoreDuplicateCheck = false) => { 85 | try { 86 | await EnsureXrmGetter(this._page); 87 | 88 | const waitSelectors = [ 89 | // This is the id of the notification that gets shown once a quick create record is saved 90 | this._page.waitForSelector("div[id^=quickcreate_]", { timeout: this.xrmUiTest.settings.timeout }) 91 | ]; 92 | 93 | const saveResult = this._page.evaluate(() => { 94 | const xrm = window.oss_FindXrm(); 95 | 96 | return new Promise((resolve, reject) => { 97 | xrm.Page.data.save().then(resolve, reject); 98 | }); 99 | }); 100 | 101 | const promises = [ 102 | ...waitSelectors, 103 | saveResult, 104 | // Normal page should switch to idle again 105 | this.xrmUiTest.waitForIdleness() 106 | ]; 107 | 108 | await this.handleDuplicateCheck(promises, ignoreDuplicateCheck); 109 | 110 | const quickCreate = await this._page.$("div[id^=quickcreate_]"); 111 | 112 | if (quickCreate) { 113 | const handle = await quickCreate.getProperty("id"); 114 | const id: string = await handle.jsonValue(); 115 | 116 | return id.substr(12); 117 | } 118 | 119 | return this.getId(); 120 | } 121 | catch (e) { 122 | throw new RethrownError(`Error while saving, message: ${(e as any).title} - ${(e as any).message}`, e); 123 | } 124 | } 125 | 126 | /** 127 | * Get the id of the currently opened record 128 | * 129 | * @returns Promise which resolves with the id 130 | */ 131 | getId = async () => { 132 | await EnsureXrmGetter(this._page); 133 | 134 | return this._page.evaluate(() => { 135 | const xrm = window.oss_FindXrm(); 136 | 137 | return xrm.Page.data.entity.getId(); 138 | }); 139 | } 140 | 141 | /** 142 | * Get the logical name of the currently opened record 143 | * 144 | * @returns Promise which resolves with the name 145 | */ 146 | getEntityName = async () => { 147 | await EnsureXrmGetter(this._page); 148 | 149 | return this._page.evaluate(() => { 150 | const xrm = window.oss_FindXrm(); 151 | 152 | return xrm.Page.data.entity.getEntityName(); 153 | }); 154 | } 155 | 156 | /** 157 | * Get the entity reference of the currently opened record 158 | * 159 | * @returns Promise which resolves with the entity reference 160 | */ 161 | getEntityReference = async () => { 162 | await EnsureXrmGetter(this._page); 163 | 164 | return this._page.evaluate(() => { 165 | const xrm = window.oss_FindXrm(); 166 | 167 | return xrm.Page.data.entity.getEntityReference(); 168 | }); 169 | } 170 | 171 | /** 172 | * Delete the current record 173 | * 174 | * @returns Promise which resolves once deletion is done 175 | * @remarks Delete button on form will be used 176 | */ 177 | delete = async() => { 178 | await this.xrmUiTest.Button.click({ custom: "li[id*='DeletePrimaryRecord']" }); 179 | 180 | await Promise.all([ 181 | this._page.waitForNavigation({ waitUntil: "load", timeout: this.xrmUiTest.settings.timeout }), 182 | this._page.click(D365Selectors.PopUp.confirm, { timeout: this.xrmUiTest.settings.timeout }) 183 | ]); 184 | 185 | await this.xrmUiTest.waitForIdleness(); 186 | } 187 | 188 | /** 189 | * Deactivate the current record 190 | * 191 | * @returns Promise which resolves once deactivation is done 192 | * @remarks Deactivate button on form will be used 193 | */ 194 | deactivate = async() => { 195 | await this.xrmUiTest.Button.click({ custom: "li[id*='Mscrm.Form.Deactivate']" }); 196 | 197 | await this._page.click("button[data-id='ok_id']", { timeout: this.xrmUiTest.settings.timeout }); 198 | await this.xrmUiTest.waitForIdleness(); 199 | } 200 | 201 | /** 202 | * Activate the current record 203 | * 204 | * @returns Promise which resolves once activation is done 205 | * @remarks Activate button on form will be used 206 | */ 207 | activate = async() => { 208 | await this.xrmUiTest.Button.click({ custom: "li[id*='Mscrm.Form.Activate']" }); 209 | 210 | await this._page.click("button[data-id='ok_id']", { timeout: this.xrmUiTest.settings.timeout }); 211 | await this.xrmUiTest.waitForIdleness(); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/xrm/Form.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { EnsureXrmGetter } from "./Global"; 3 | import { XrmUiTest } from "./XrmUITest"; 4 | 5 | /** 6 | * Scheme to describe a form reference 7 | * Either by name or by id 8 | */ 9 | export interface FormIdentifier { 10 | /** 11 | * The name of the form 12 | */ 13 | byName?: string; 14 | 15 | /** 16 | * The id of the form 17 | */ 18 | byId?: string; 19 | } 20 | 21 | /** 22 | * Scheme to describe items of form selector 23 | */ 24 | export interface FormSelectorItem { 25 | /** 26 | * Id of the form 27 | */ 28 | id?: string; 29 | 30 | /** 31 | * Label of the form 32 | */ 33 | label?: string; 34 | } 35 | 36 | /** 37 | * Module for interacting with D365 Forms 38 | */ 39 | export class Form { 40 | private _page: playwright.Page; 41 | 42 | constructor(private xrmUiTest: XrmUiTest) { 43 | this._page = xrmUiTest.page; 44 | this.xrmUiTest = xrmUiTest; 45 | } 46 | 47 | /** 48 | * Sets all attributes to submit mode none. This is useful if you don't want to save and just change the page. No prompt for unsaved data will open. 49 | * 50 | * @returns Promise which resolves once all attribute submit modes are set 51 | * @deprecated Please use noSubmit 52 | */ 53 | reset = async () => { 54 | return this.noSubmit(); 55 | } 56 | 57 | /** 58 | * Sets all attributes to submit mode none. This is useful if you don't want to save and just change the page. No prompt for unsaved data will open. 59 | * 60 | * @returns Promise which resolves once all attribute submit modes are set. 61 | */ 62 | noSubmit = async () => { 63 | await EnsureXrmGetter(this._page); 64 | 65 | return this._page.evaluate(() => { 66 | const xrm = window.oss_FindXrm(); 67 | 68 | xrm.Page.getAttribute().forEach(a => a.setSubmitMode("never")); 69 | }); 70 | } 71 | 72 | /** 73 | * Gets data about the currently selected form 74 | * 75 | * @returns Object containing id and label of current form 76 | */ 77 | getCurrentFormSelectorItem = async (): Promise => { 78 | await EnsureXrmGetter(this._page); 79 | 80 | return this._page.evaluate(() => { 81 | const xrm = window.oss_FindXrm(); 82 | const form = xrm.Page.ui.formSelector.getCurrentItem(); 83 | 84 | return { 85 | id: form.getId(), 86 | label: form.getLabel() 87 | }; 88 | }); 89 | } 90 | 91 | /** 92 | * Gets a list of all forms that are currently available in the form selector 93 | * 94 | * @returns Array of objects with id and label 95 | */ 96 | getAvailableFormSelectorItems = async (): Promise> => { 97 | await EnsureXrmGetter(this._page); 98 | 99 | return this._page.evaluate(() => { 100 | const xrm = window.oss_FindXrm(); 101 | 102 | return xrm.Page.ui.formSelector.items.get().map(i => ({ id: i.getId(), label: i.getLabel()})); 103 | }); 104 | } 105 | 106 | /** 107 | * Switches to the specified form 108 | * 109 | * @param identifier Defines which form to switch to 110 | * @returns Promise which resolves once the selected form is loaded 111 | */ 112 | switch = async (identifier: FormIdentifier) => { 113 | await EnsureXrmGetter(this._page); 114 | 115 | if (identifier.byId) { 116 | return Promise.all([ 117 | this._page.evaluate((i) => { 118 | const xrm = window.oss_FindXrm(); 119 | 120 | (xrm.Page.ui.formSelector.items.get(i) as any).navigate(); 121 | }, identifier.byId), 122 | this.xrmUiTest.waitForIdleness() 123 | ]); 124 | } 125 | else if (identifier.byName) { 126 | return Promise.all([ 127 | this._page.evaluate((i) => { 128 | const xrm = window.oss_FindXrm(); 129 | 130 | (xrm.Page.ui.formSelector.items as any).getByFilter((f: any) => f._label === i).navigate(); 131 | }, identifier.byName), 132 | this.xrmUiTest.waitForIdleness() 133 | ]); 134 | } 135 | else { 136 | throw new Error("Choose to search by id or name"); 137 | } 138 | 139 | } 140 | } -------------------------------------------------------------------------------- /src/xrm/Global.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | 3 | export const EnsureXrmGetter = async(page: playwright.Page) => { 4 | await page.evaluate(() => { 5 | if (!!window.oss_FindXrm) { 6 | return; 7 | } 8 | 9 | window.oss_FindXrm = () => { 10 | if (window.Xrm && (window.Xrm as any).Internal && (window.Xrm as any).Internal && (window.Xrm as any).Internal && (window.Xrm as any).Internal.isUci && (window.Xrm as any).Internal.isUci()) { 11 | return window.Xrm; 12 | } 13 | 14 | const frames = Array.from(document.querySelectorAll("iframe")) 15 | .filter(f => f.style.visibility !== "hidden") 16 | .filter(f => { try { return f.contentWindow && f.contentWindow.Xrm; } catch { return false; } }) 17 | .map(f => f.contentWindow.Xrm) 18 | .filter(f => f.Page); 19 | 20 | return frames.length ? frames[0] : window.Xrm; 21 | }; 22 | }); 23 | }; 24 | 25 | declare global { 26 | interface Window { oss_FindXrm: () => Xrm.XrmStatic; } 27 | } -------------------------------------------------------------------------------- /src/xrm/Grid.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { RethrownError } from "../utils/RethrownError"; 3 | import { EnsureXrmGetter } from "./Global"; 4 | import { XrmUiTest } from "./XrmUITest"; 5 | import { D365Selectors } from "../domain/D365Selectors"; 6 | 7 | /** 8 | * Module for interacting with D365 Grids / Entity Lists 9 | */ 10 | export class Grid { 11 | private _page: playwright.Page; 12 | 13 | constructor(private xrmUiTest: XrmUiTest) { 14 | this._page = xrmUiTest.page; 15 | this.xrmUiTest = xrmUiTest; 16 | } 17 | 18 | /** 19 | * Opens the record in the grid at the n-th index 20 | * 21 | * @param {Number} recordNumber Index of the record to open, zero based 22 | * @returns {Promise} Promise which fulfills when record is opened 23 | */ 24 | openNthRecord = async(recordNumber: number ) => { 25 | // Our recordNumber is zero based. In Dynamics it starts at 1, with 1 being the header row. So real data starts at index 2 26 | const rowToClick = await this._page.$(D365Selectors.Grid.DataRowWithIndexCheckBox.replace("{0}", `${recordNumber + 2}`)); 27 | 28 | if (rowToClick) { 29 | await rowToClick.dblclick(); 30 | await this.xrmUiTest.waitForIdleness(); 31 | } 32 | else { 33 | throw new Error(`Failed to find grid row ${recordNumber}`); 34 | } 35 | } 36 | 37 | /** 38 | * Selects the record in the grid at the n-th index 39 | * 40 | * @param {Number} recordNumber Index of the record to select, zero based 41 | * @returns {Promise} Promise which fulfills when record is selected 42 | */ 43 | selectNthRecord = async(recordNumber: number ) => { 44 | // Our recordNumber is zero based. In Dynamics it starts at 1, with 1 being the header row. So real data starts at index 2 45 | const rowToClick = await this._page.$(D365Selectors.Grid.DataRowWithIndexCheckBox.replace("{0}", `${recordNumber + 2}`)); 46 | 47 | if (rowToClick) { 48 | await rowToClick.click(); 49 | await this.xrmUiTest.waitForIdleness(); 50 | } 51 | else { 52 | throw new Error(`Failed to find grid row ${recordNumber}`); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /src/xrm/Navigation.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { isPageElement } from "../domain/SharedLogic"; 3 | import { D365Selectors } from "../domain/D365Selectors"; 4 | import { EnsureXrmGetter } from "./Global"; 5 | import { XrmUiTest } from "./XrmUITest"; 6 | 7 | /** 8 | * Define behavior on navigation, for example handling of blocking popups (e.g. "Do you really want to leave this page?"") 9 | */ 10 | export interface NavigationSettings { 11 | /** 12 | * Define whether to confirm or to cancel dialogs that occur on navigation 13 | */ 14 | popUpAction?: "confirm" | "cancel"; 15 | } 16 | 17 | /** 18 | * Define behaviour for opening forms 19 | */ 20 | export interface FormNavigationSettings extends NavigationSettings { 21 | /** 22 | * Define the ID of the form to open 23 | */ 24 | formId?: string; 25 | } 26 | 27 | /** 28 | * Module for navigating in D365 29 | */ 30 | export class Navigation { 31 | private _page: playwright.Page; 32 | 33 | constructor(private xrmUiTest: XrmUiTest) { 34 | this._page = xrmUiTest.page; 35 | this.xrmUiTest = xrmUiTest; 36 | } 37 | 38 | private async HandlePopUpOnNavigation (navigationPromise: Promise>, settings: NavigationSettings) { 39 | const defaultSettings: NavigationSettings = { 40 | popUpAction: "cancel" 41 | }; 42 | 43 | const safeSettings = { 44 | ...defaultSettings, 45 | ...settings 46 | }; 47 | 48 | const popUpButton = safeSettings.popUpAction === "cancel" 49 | ? D365Selectors.PopUp.cancel 50 | : D365Selectors.PopUp.confirm; 51 | 52 | const result = await Promise.race([ 53 | navigationPromise, 54 | // Catch dialogs that block navigation 55 | this._page.waitForSelector(popUpButton, { timeout: this.xrmUiTest.settings.timeout }) 56 | ]); 57 | 58 | if (isPageElement(result)) { 59 | await Promise.all([ 60 | navigationPromise, 61 | result.click() 62 | ]); 63 | } 64 | } 65 | 66 | /** 67 | * Opens a create form for the specified entity 68 | * 69 | * @param entityName The entity to open the form for 70 | * @param settings How to handle dialogs that prevent navigation. Cancel discards the dialog, confirm accepts it. Default is discarding it. 71 | * @returns Promise which resolves once form is fully loaded 72 | */ 73 | openCreateForm = async (entityName: string, settings?: FormNavigationSettings) => { 74 | await EnsureXrmGetter(this._page); 75 | 76 | const navigationPromise = this._page.evaluate(([entityName, formId]) => { 77 | const xrm = window.oss_FindXrm(); 78 | 79 | return xrm.Navigation.openForm({ entityName: entityName, formId: formId ? formId : undefined }); 80 | }, [entityName, settings?.formId]); 81 | 82 | await this.HandlePopUpOnNavigation(navigationPromise, settings); 83 | await this.xrmUiTest.waitForIdleness(); 84 | } 85 | 86 | /** 87 | * Opens an update form for an existing record 88 | * 89 | * @param entityName The entity to open the form for 90 | * @param entityId The id of the record to open 91 | * @param settings How to handle dialogs that prevent navigation. Cancel discards the dialog, confirm accepts it. Default is discarding it. 92 | * @returns Promise which resolves once form is fully loaded 93 | */ 94 | openUpdateForm = async (entityName: string, entityId: string, settings?: FormNavigationSettings) => { 95 | await EnsureXrmGetter(this._page); 96 | 97 | const navigationPromise = this._page.evaluate(([ entityName, entityId, formId ]) => { 98 | const xrm = window.oss_FindXrm(); 99 | 100 | return xrm.Navigation.openForm({ entityName: entityName, entityId: entityId, formId: formId ? formId : undefined }); 101 | }, [entityName, entityId, settings?.formId]); 102 | 103 | await this.HandlePopUpOnNavigation(navigationPromise, settings); 104 | await this.xrmUiTest.waitForIdleness(); 105 | } 106 | 107 | /** 108 | * Navigate to the specified page 109 | * 110 | * @param pageInput Define where to navigate to (entity record, entity list or html web resource) 111 | * @param navigationOptions Define whether to navigate inline or in a dialog 112 | * @param settings How to handle dialogs that prevent navigation. Cancel discards the dialog, confirm accepts it. Default is discarding it. 113 | * @returns Promise which resolves once the page is fully loaded 114 | */ 115 | navigateTo = async (pageInput: Xrm.Navigation.PageInputEntityRecord | Xrm.Navigation.PageInputEntityList | Xrm.Navigation.PageInputHtmlWebResource, navigationOptions?: Xrm.Navigation.NavigationOptions, settings?: NavigationSettings) => { 116 | await EnsureXrmGetter(this._page); 117 | 118 | const navigationPromise = this._page.evaluate(([ pageInput, navigationOptions ]) => { 119 | const xrm = window.oss_FindXrm(); 120 | 121 | return xrm.Navigation.navigateTo(pageInput, navigationOptions); 122 | }, [ pageInput, navigationOptions ] as [Xrm.Navigation.PageInputEntityRecord | Xrm.Navigation.PageInputEntityList | Xrm.Navigation.PageInputHtmlWebResource, Xrm.Navigation.NavigationOptions]); 123 | 124 | await this.HandlePopUpOnNavigation(navigationPromise, settings); 125 | await this.xrmUiTest.waitForIdleness(); 126 | } 127 | 128 | /** 129 | * Opens a quick create form for the specified entity 130 | * 131 | * @param entityName The entity to open the form for 132 | * @returns Promise which resolves once form is fully loaded 133 | */ 134 | openQuickCreate = async (entityName: string) => { 135 | await EnsureXrmGetter(this._page); 136 | 137 | await this._page.evaluate((entityName: string) => { 138 | const xrm = window.oss_FindXrm(); 139 | 140 | return xrm.Navigation.openForm({ entityName: entityName, useQuickCreateForm: true }); 141 | }, entityName); 142 | 143 | await this.xrmUiTest.waitForIdleness(); 144 | } 145 | 146 | /** 147 | * Opens the specified UCI app 148 | * 149 | * @param appId The id of the app to open 150 | * @param settings How to handle dialogs that prevent navigation. Cancel discards the dialog, confirm accepts it. Default is discarding it. 151 | * @returns Promise which resolves once the app is fully loaded 152 | */ 153 | openAppById = async(appId: string, settings?: NavigationSettings) => { 154 | this.xrmUiTest.AppId = appId; 155 | 156 | const navigationUrl = this.xrmUiTest.buildUrl(this.xrmUiTest.crmUrl, appId); 157 | const navigationPromise = this._page.goto(navigationUrl, { waitUntil: "load", timeout: this.xrmUiTest.settings.timeout }); 158 | 159 | await this.HandlePopUpOnNavigation(navigationPromise, settings); 160 | await this.xrmUiTest.waitForIdleness(); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/xrm/Section.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { RethrownError } from "../utils/RethrownError"; 3 | import { EnsureXrmGetter } from "./Global"; 4 | import { XrmUiTest } from "./XrmUITest"; 5 | 6 | /** 7 | * State of a section 8 | */ 9 | export interface SectionState { 10 | /** 11 | * Whether the section is currently visible 12 | */ 13 | isVisible: boolean; 14 | } 15 | 16 | /** 17 | * Module for interacting with D365 sections 18 | */ 19 | export class Section { 20 | private _page: playwright.Page; 21 | 22 | constructor(private xrmUiTest: XrmUiTest) { 23 | this._page = xrmUiTest.page; 24 | this.xrmUiTest = xrmUiTest; 25 | } 26 | 27 | /** 28 | * Gets the state of the specified section 29 | * 30 | * @param tabName Name of the parent tab of the section to retrieve 31 | * @param sectionName Name of the section to retrieve 32 | * @returns Promise which fulfills with the current section state 33 | */ 34 | get = async (tabName: string, sectionName: string): Promise => { 35 | try { 36 | await EnsureXrmGetter(this._page); 37 | 38 | return this._page.evaluate(([tabName, sectionName]: [string, string]) => { 39 | const xrm = window.oss_FindXrm(); 40 | const tab = xrm.Page.ui.tabs.get(tabName); 41 | const section = tab.sections.get(sectionName); 42 | 43 | return { 44 | isVisible: section.getVisible() && tab.getVisible() 45 | }; 46 | }, [tabName, sectionName]); 47 | } 48 | catch (e) { 49 | throw new RethrownError(`Error when getting section '${sectionName}'`, e); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/xrm/SubGrid.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { RethrownError } from "../utils/RethrownError"; 3 | import { EnsureXrmGetter } from "./Global"; 4 | import { XrmUiTest } from "./XrmUITest"; 5 | 6 | /** 7 | * Module for interacting with D365 Subgrids 8 | */ 9 | export class SubGrid { 10 | private _page: playwright.Page; 11 | 12 | constructor(private xrmUiTest: XrmUiTest) { 13 | this._page = xrmUiTest.page; 14 | this.xrmUiTest = xrmUiTest; 15 | } 16 | 17 | /** 18 | * Gets the record count of the specified subgrid 19 | * 20 | * @param {String} subgridName The control name of the subgrid to use 21 | * @returns {Promise} Promise which fulfills with the total record count 22 | */ 23 | getRecordCount = async(subgridName: string ) => { 24 | try { 25 | await EnsureXrmGetter(this._page); 26 | 27 | return this._page.evaluate((name) => { 28 | const xrm = window.oss_FindXrm(); 29 | const control = xrm.Page.getControl(name); 30 | 31 | if (!control) { 32 | return undefined; 33 | } 34 | 35 | return control.getGrid().getTotalRecordCount(); 36 | }, subgridName); 37 | } 38 | catch (e) { 39 | throw new RethrownError(`Error when getting record count of subgrid '${subgridName}'`, e); 40 | } 41 | } 42 | 43 | /** 44 | * Opens the record in the subgrid at the n-th index 45 | * 46 | * @param {String} subgridName The control name of the subgrid to use 47 | * @param {Number} recordNumber Index of the record to open, zero based 48 | * @returns {Promise} Promise which fulfills when record is opened 49 | */ 50 | openNthRecord = async(subgridName: string, recordNumber: number ) => { 51 | try { 52 | await EnsureXrmGetter(this._page); 53 | 54 | const recordReference = await this._page.evaluate(([name, position]: [string, number]) => { 55 | const xrm = window.oss_FindXrm(); 56 | const control = xrm.Page.getControl(name); 57 | 58 | if (!control) { 59 | return undefined; 60 | } 61 | 62 | const grid = control.getGrid(); 63 | const record = grid.getRows().get(position).getData(); 64 | 65 | return record.getEntity().getEntityReference(); 66 | }, [subgridName, recordNumber]); 67 | 68 | return this.xrmUiTest.Navigation.openUpdateForm(recordReference.entityType, recordReference.id); 69 | } 70 | catch (e) { 71 | throw new RethrownError(`Error when setting opening record ${recordNumber} of subgrid '${subgridName}'`, e); 72 | } 73 | } 74 | 75 | /** 76 | * Opens the create record form by using this subgrid "Add New" button 77 | * @param {String} subgridName The control name of the subgrid to use 78 | */ 79 | createNewRecord = async(subgridName: string) => { 80 | try { 81 | await EnsureXrmGetter(this._page); 82 | 83 | const parentTab = await this._page.evaluate(([name]: [string]) => { 84 | const xrm = window.oss_FindXrm(); 85 | const control = xrm.Page.getControl(name); 86 | 87 | if (!control) { 88 | return undefined; 89 | } 90 | 91 | return control.getParent().getParent().getName(); 92 | }, [subgridName]); 93 | 94 | await this.xrmUiTest.Tab.open(parentTab); 95 | 96 | const subgridEntity = await this._page.evaluate(([name]: [string]) => { 97 | const xrm = window.oss_FindXrm(); 98 | const control = xrm.Page.getControl(name); 99 | 100 | if (!control) { 101 | return undefined; 102 | } 103 | 104 | return control.getEntityName(); 105 | }, [subgridName]); 106 | 107 | await this.xrmUiTest.waitForIdleness(); 108 | 109 | // Normal selector for button inside this subgrid's command bar 110 | const addNewButton = await this._page.$(`div[data-control-name='${subgridName}'] button[data-id*='Mscrm.SubGrid.${subgridEntity}.AddNewStandard']`); 111 | 112 | if (addNewButton) { 113 | await addNewButton.click(); 114 | } 115 | else { 116 | // Find this subgrid's overflow button for expanding additional commands 117 | const overflowButton = await this._page.$(`div[data-control-name='${subgridName}'] button[data-id*='OverflowButton']`); 118 | 119 | if (!overflowButton) { 120 | throw new Error("Failed to find the add new button on your subgrid as well as the overflow button. Please check user permissions, button hide rules and whether you use custom create buttons, as we search for the default create button"); 121 | } 122 | 123 | await overflowButton.click(); 124 | // Ribbon button inside the overflow flyout is not child of the subgrid anymore, but of the overflowflyout. Adapted selector 125 | await this._page.click(`ul[data-id='OverflowFlyout'] button[data-id*='Mscrm.SubGrid.${subgridEntity}.AddNewStandard']`); 126 | } 127 | 128 | return this.xrmUiTest.waitForIdleness(); 129 | } 130 | catch (e) { 131 | throw new RethrownError(`Error when trying to create new record in subgrid '${subgridName}'`, e); 132 | } 133 | } 134 | 135 | /** 136 | * Refreshes the specified subgrid 137 | * 138 | * @param {String} subgridName The control name of the subgrid to refresh 139 | * @returns {Promise} Promise which fulfills once refreshing is done 140 | */ 141 | refresh = async(subgridName: string) => { 142 | try { 143 | await EnsureXrmGetter(this._page); 144 | 145 | return this._page.evaluate((name) => { 146 | const xrm = window.oss_FindXrm(); 147 | const control = xrm.Page.getControl(name); 148 | 149 | if (!control) { 150 | return; 151 | } 152 | 153 | return control.refresh(); 154 | }, subgridName); 155 | } 156 | catch (e) { 157 | throw new RethrownError(`Error when refreshing subgrid '${subgridName}'`, e); 158 | } 159 | } 160 | } -------------------------------------------------------------------------------- /src/xrm/Tab.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { RethrownError } from "../utils/RethrownError"; 3 | import { EnsureXrmGetter } from "./Global"; 4 | import { XrmUiTest } from "./XrmUITest"; 5 | 6 | /** 7 | * State of a tab 8 | */ 9 | export interface TabState { 10 | /** 11 | * Whether the tab is currently visible 12 | */ 13 | isVisible: boolean; 14 | } 15 | 16 | /** 17 | * Module for interacting with D365 Tabs 18 | */ 19 | export class Tab { 20 | private _page: playwright.Page; 21 | 22 | constructor(private xrmUiTest: XrmUiTest) { 23 | this._page = xrmUiTest.page; 24 | this.xrmUiTest = xrmUiTest; 25 | } 26 | 27 | /** 28 | * Opens the specified tab on the form 29 | * 30 | * @param tabName Name of the tab to open 31 | * @returns Promise which fulfills with the current control state 32 | */ 33 | open = async (tabName: string) => { 34 | try { 35 | await EnsureXrmGetter(this._page); 36 | 37 | await this._page.evaluate((tabName: string) => { 38 | const xrm = window.oss_FindXrm(); 39 | 40 | xrm.Page.ui.tabs.get(tabName).setDisplayState("expanded"); 41 | }, tabName); 42 | 43 | await this.xrmUiTest.waitForIdleness(); 44 | } 45 | catch (e) { 46 | throw new RethrownError(`Error when opening tab '${tabName}'`, e); 47 | } 48 | } 49 | 50 | /** 51 | * Gets the state of the specified tab 52 | * 53 | * @param name Name of the tab to retrieve 54 | * @returns Promise which fulfills with the current tab state 55 | */ 56 | get = async (name: string): Promise => { 57 | try { 58 | await EnsureXrmGetter(this._page); 59 | 60 | return this._page.evaluate((tabName: string) => { 61 | const xrm = window.oss_FindXrm(); 62 | const tab = xrm.Page.ui.tabs.get(tabName); 63 | 64 | return { 65 | isVisible: tab.getVisible() 66 | }; 67 | }, name); 68 | } 69 | catch (e) { 70 | throw new RethrownError(`Error when getting tab '${name}'`, e); 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /src/xrm/WebApi.ts: -------------------------------------------------------------------------------- 1 | import * as playwright from "playwright"; 2 | import { RethrownError } from "../utils/RethrownError"; 3 | import { EnsureXrmGetter } from "./Global"; 4 | import { XrmUiTest } from "./XrmUITest"; 5 | 6 | /** 7 | * Module for interacting with D365 Web API 8 | */ 9 | export class WebApi { 10 | private _page: playwright.Page; 11 | 12 | constructor(private xrmUiTest: XrmUiTest) { 13 | this._page = xrmUiTest.page; 14 | this.xrmUiTest = xrmUiTest; 15 | } 16 | 17 | /** 18 | * Create a single record 19 | * 20 | * @param entityLogicalName Entity logical name of the record to create 21 | * @param data JSON Object with attribute names and values 22 | * @returns Promise which fulfills once the record has been created 23 | */ 24 | createRecord = async (entityLogicalName: string, data: any): Promise => { 25 | await EnsureXrmGetter(this._page); 26 | 27 | return this._page.evaluate(([entityLogicalName, data]) => { 28 | const xrm = window.oss_FindXrm(); 29 | 30 | return xrm.WebApi.createRecord(entityLogicalName, data); 31 | }, [entityLogicalName, data]); 32 | } 33 | 34 | /** 35 | * Retrieves a single record 36 | * 37 | * @param entityLogicalName Entity LogicalName of the record to retrieve 38 | * @param id ID of the record to retrieve 39 | * @param options OData system query options 40 | * @returns Promise which fulfills with the requested record data 41 | */ 42 | retrieveRecord = async (entityLogicalName: string, id: string, options?: string): Promise => { 43 | await EnsureXrmGetter(this._page); 44 | 45 | return this._page.evaluate(([entityLogicalName, id, options]) => { 46 | const xrm = window.oss_FindXrm(); 47 | 48 | return xrm.WebApi.retrieveRecord(entityLogicalName, id, options); 49 | }, [entityLogicalName, id, options]); 50 | } 51 | 52 | /** 53 | * Retrieves multiple records 54 | * 55 | * @param entityLogicalName Entity logical name of the entity to retrieve 56 | * @param options OData system query options or FetchXML query 57 | * @param maxPageSize Specify the number of records to return per page 58 | * @returns Promise which fulfills with the data once the retrieval succeeds 59 | */ 60 | retrieveMultipleRecords = async (entityLogicalName: string, options?: string, maxPageSize?: number): Promise => { 61 | await EnsureXrmGetter(this._page); 62 | 63 | return this._page.evaluate(([entityLogicalName, options, maxPageSize]) => { 64 | const xrm = window.oss_FindXrm(); 65 | 66 | return xrm.WebApi.retrieveMultipleRecords(entityLogicalName, options, maxPageSize); 67 | }, [entityLogicalName, options, maxPageSize] as [string, string?, number?]); 68 | } 69 | 70 | /** 71 | * Updates a single record 72 | * 73 | * @param entityLogicalName Entity logical name of the record to retrieve 74 | * @param id ID of the record to update 75 | * @param data JSON Object with attribute names and values 76 | * @returns Promise which fulfills once the update succeeds 77 | */ 78 | updateRecord = async (entityLogicalName: string, id: string, data: any): Promise => { 79 | await EnsureXrmGetter(this._page); 80 | 81 | return this._page.evaluate(([entityLogicalName, id, data]) => { 82 | const xrm = window.oss_FindXrm(); 83 | 84 | return xrm.WebApi.updateRecord(entityLogicalName, id, data); 85 | }, [entityLogicalName, id, data]); 86 | } 87 | 88 | /** 89 | * Delete a single record 90 | * 91 | * @param entityLogicalName Entity logical name of the record to retrieve 92 | * @param id ID of the record to delete 93 | * @returns Promise which fulfills once the deletion succeeds 94 | */ 95 | deleteRecord = async (entityLogicalName: string, id: string): Promise => { 96 | await EnsureXrmGetter(this._page); 97 | 98 | return this._page.evaluate(([entityLogicalName, id]) => { 99 | const xrm = window.oss_FindXrm(); 100 | 101 | return xrm.WebApi.deleteRecord(entityLogicalName, id); 102 | }, [entityLogicalName, id]); 103 | } 104 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "./dist/", 5 | "outDir": "./dist/", 6 | "sourceMap": true, 7 | "noImplicitAny": true, 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "baseUrl": ".", 11 | "target": "es2018", 12 | "jsx": "react", 13 | "paths": { 14 | "*": [ 15 | "./node_modules/*", 16 | "./types/*" 17 | ] 18 | } 19 | }, 20 | "include": [ 21 | "./src/**/*", 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "indent": [ 9 | true, 10 | "spaces" 11 | ], 12 | "one-line": [ 13 | true, 14 | "check-open-brace", 15 | "check-whitespace" 16 | ], 17 | "no-var-keyword": true, 18 | "quotemark": [ 19 | true, 20 | "double", 21 | "avoid-escape" 22 | ], 23 | "semicolon": [ 24 | true, 25 | "always", 26 | "ignore-bound-class-methods" 27 | ], 28 | "whitespace": [ 29 | true, 30 | "check-branch", 31 | "check-decl", 32 | "check-operator", 33 | "check-module", 34 | "check-separator", 35 | "check-type" 36 | ], 37 | "typedef-whitespace": [ 38 | true, 39 | { 40 | "call-signature": "nospace", 41 | "index-signature": "nospace", 42 | "parameter": "nospace", 43 | "property-declaration": "nospace", 44 | "variable-declaration": "nospace" 45 | }, 46 | { 47 | "call-signature": "onespace", 48 | "index-signature": "onespace", 49 | "parameter": "onespace", 50 | "property-declaration": "onespace", 51 | "variable-declaration": "onespace" 52 | } 53 | ], 54 | "no-internal-module": true, 55 | "no-trailing-whitespace": true, 56 | "no-null-keyword": true, 57 | "prefer-const": true, 58 | "jsdoc-format": true 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tutorials/01_Startup.md: -------------------------------------------------------------------------------- 1 | # Startup 2 | This tutorial is going to show you how to startup D365-UI-Test. 3 | 4 | ## Basics 5 | Below snippet can be used in an init function of your test framework (e.g. "beforeAll" in jest): 6 | 7 | ```javascript 8 | const xrmTest = new XrmUiTest(); 9 | 10 | let browser: playwright.Browser = undefined; 11 | let context: playwright.BrowserContext = undefined; 12 | let page: playwright.Page = undefined; 13 | 14 | // Start the browser 15 | // Pass headless: true for DevOps and when you don't want to see what playwright is doing. 16 | // For debugging, setting headless: false is easier as you see what's happening 17 | await xrmTest.launch("chromium", { 18 | headless: false, 19 | args: [ 20 | '--disable-setuid-sandbox', 21 | '--disable-infobars', 22 | '--start-fullscreen', 23 | '--window-position=0,0', 24 | '--window-size=1920,1080', 25 | '--user-agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"' 26 | ] 27 | }) 28 | .then(([b, c, p]) => { 29 | browser = b; 30 | context = c; 31 | page = p; 32 | }); 33 | 34 | // When saving the settings file with the credentials directly in this directory, be sure to exclude it using .gitignore (or store it one folder above your working folder) 35 | // Settings.txt might look like this: 36 | // https://yourorg.crm4.dynamics.com,youruser@yourorg.onmicrosoft.com,yourpassword 37 | const config = fs.readFileSync(path.resolve(__dirname, "settings.txt"), {encoding: "utf-8"}); 38 | 39 | // Easiest way to store credentials is to just separate url, username and password by comma in the file 40 | const [url, user, password, mfaSecret] = config.split(","); 41 | 42 | // Log into D365 43 | await xrmTest.open(url, { userName: user, password: password, mfaSecret: mfaSecret ?? undefined }); 44 | ``` 45 | 46 | ## D365 Online Organizations 47 | Authentication with the default Microsoft D365 authentication provider is implemented by default. Nothing to take care of. 48 | 49 | ## Custom Auth Pages 50 | When having a custom authentication page that you get redirected to on login, you have to provide D365-UI-Test with some additional information. 51 | 52 | In some cases these authentication pages need the user name and the password, in some cases only the password is needed. The password is of course always needed. 53 | 54 | The `xrmTest.open` function is able to use this information: 55 | 56 | ```javascript 57 | const page = await xrmTest.open(url, { userName: user, password: password, passwordFieldSelector: "#password_input", userNameFieldSelector: "#userName_input" }); 58 | ``` 59 | 60 | The values for passwordFieldSelector and userNameFieldSelector need to be valid CSS selectors that help D365-UI-Test finding the correct inputs for logging you in. 61 | You can find them out using your browser's DOM developer tools. Usually they will have an ID set as above. 62 | 63 | For sending this information the enter button will be pressed, no need to specify a login button selector. 64 | 65 | ## Multi Factor Auth (OTP) 66 | Automatically generated OTP credentials are supported when using the default Microsoft Authenticator. 67 | 68 | For doing this, register your Authenticator and when the QR code is displayed, click the button for the fallback option that states that you can't use a QR code. 69 | Copy the secret. 70 | You now just need to pass the secret as follows: 71 | 72 | ```javascript 73 | // As in the non-mfa login example, settings.txt might look like 74 | // https://yourorg.crm4.dynamics.com,youruser@yourorg.onmicrosoft.com,yourpassword,mfaSecret 75 | const config = fs.readFileSync(path.resolve(__dirname, "settings.txt"), {encoding: "utf-8"}); 76 | const [url, user, password, mfaSecret] = config.split(","); 77 | 78 | await xrmTest.open(url, { userName: user, password: password, mfaSecret: mfaSecret ?? undefined }); 79 | ``` 80 | 81 | > Important: If the OTP codes are not accepted, check that the system clock on your machine is completely synchronized. Even a slight offset to the global NTP servers might result in incorrect tokens. 82 | -------------------------------------------------------------------------------- /tutorials/02_Navigation.md: -------------------------------------------------------------------------------- 1 | # Navigation 2 | There is support for opening UCI apps, update forms and create forms. 3 | All of these calls use parametrized URLs and wait for the page to fully load and all network requests to be finished. This guarantees for a robust and fast usage experience. 4 | 5 | ## Open UCI apps 6 | UCI apps are currently opened by their appId. You can find it out by opening the app in your D365 organization and taking a look into the URL bar in your browser. 7 | 8 | There will be a parameter called appId, which will print the id of the currently opened app. 9 | You can use it like that afterwards: 10 | 11 | ```javascript 12 | await xrmTest.Navigation.openAppById("3a5ff736-45a5-4318-a05e-c8a98761e64a"); 13 | ``` 14 | 15 | ## Open create forms 16 | Opening create forms just requires the entity logical name of the entity form that you want to open: 17 | 18 | ```javascript 19 | await xrmTest.Navigation.openCreateForm("account"); 20 | ``` 21 | 22 | ## Open update forms 23 | This allows to open forms with existing records. It works just like the `openCreateForm` function, but takes another parameter for the record id: 24 | 25 | ```javascript 26 | await xrmTest.Navigation.openUpdateForm("account", "83702a07-d3eb-4774-bdab-1d768a2f94d6"); 27 | ``` 28 | 29 | ## Open quick create 30 | You can open the global quick create very much like `openCreateForm` by calling its function with the entity logical name as parameter. Afterwards you : 31 | 32 | ```javascript 33 | await xrmTest.Navigation.openQuickCreate("account"); 34 | 35 | // This will already execute inside the quick create and set the account name 36 | await xrmTest.Attribute.setValue("name", "Test name"); 37 | 38 | // This will return the id of the record that was created 39 | const id = await xrmTest.Entity.save(); 40 | ``` 41 | 42 | ## Other navigation options (EntityList, WebResource, EntityRecord) 43 | There is a `navigateTo` function which allows for flexible navigation inside the system. Client SDK is used for issuing navigation calls. 44 | 45 | ### Open Entity List 46 | ```javascript 47 | await xrmTest.Navigation.navigateTo({ 48 | pageType: "entitylist", 49 | entityName: "account" 50 | }); 51 | ``` -------------------------------------------------------------------------------- /tutorials/03_Attributes.md: -------------------------------------------------------------------------------- 1 | # Attributes 2 | When interacting with D365, one of the core functionalities is to set values on entity records. 3 | D365-UI-Test makes this as easy as possible by allowing you to pass plain values which are set using the setValue function of the SDK. 4 | 5 | This has some advantages over other approaches: 6 | - We don't have breaking changes or issues with new controls. The values can be set as in all of your form scripts. 7 | - Localization of Option Set controls does not cause issues 8 | - PCF controls don't need special handling 9 | 10 | > When setting an attribute value, we use the SDK for checking whether there is at least one visible and non-disabled control for this attribute, to ensure that a user would be able to set it as well. 11 | 12 | ## Get required level 13 | The requirement level can be retrieved like this: 14 | ```javascript 15 | // Either "none", "recommended" or "required" 16 | const requiredLevel = await xrmTest.Attribute.getRequiredLevel("name"); 17 | ``` 18 | 19 | ## Get Value 20 | Values can be retrieved like this: 21 | 22 | ```javascript 23 | const value = await xrmTest.Attribute.getValue("name"); 24 | ``` 25 | 26 | ## Set values 27 | Values can be set in single like this: 28 | 29 | ```javascript 30 | await xrmTest.Attribute.setValue("name", "Test name"); 31 | ``` 32 | 33 | Sometimes you want to set multiple fields at once. 34 | For this there is a function which takes an object with keys equal to the field logical names and values which should be set. 35 | 36 | All values will be set and D365-UI-Test will wait for a configurable settle time for onChange events to happen. 37 | 38 | Example: 39 | ```javascript 40 | await xrmTest.Attribute.setValues({ 41 | // Text or memo field 42 | "name": "Test name", 43 | // Option Set 44 | "customertypecode": 3, 45 | // Two options 46 | "msdyn_taxexempt": true, 47 | // Decimal / Number / Currency 48 | "creditlimit": 123.12, 49 | // Date 50 | "birthdate": new Date(), 51 | // Lookup 52 | "oss_countryid": [{entityType: "oss_country", id: "{FF4F3346-8CFB-E611-80FE-5065F38B06F1}", name: "AT"}] 53 | }); 54 | ``` 55 | 56 | The settle time can be passed as second value. It defaults to 2000ms, so 2 seconds. If you wish to overwrite it, pass it with your amount of milliseconds to wait. 57 | 58 | In an advanced use case you might even have a json file residing in your project with field names and values to set, so that you can just configure the values that are set without changing the script. 59 | 60 | In those cases you can parse your json file and pass the JSON object. 61 | 62 | Let's assume we have a file "accountValues.json" in our project root and our test cases in a folder "spec" inside the root dir. 63 | 64 | `accountValues.json`: 65 | ```javascript 66 | { 67 | "name": "Test name", 68 | "customertypecode": 3, 69 | "msdyn_taxexempt": true, 70 | "creditlimit": 123.12, 71 | "oss_countryid": [{entityType: "oss_country", id: "{FF4F3346-8CFB-E611-80FE-5065F38B06F1}", name: "AT"}] 72 | } 73 | ``` 74 | 75 | `spec/DemoTest.spec.ts`: 76 | ```javascript 77 | const values = fs.readFileSync(path.resolve(__dirname, "../accountValues.json"), {encoding: "utf-8"}); 78 | 79 | const json = JSON.parse(values); 80 | 81 | await xrmTest.Attribute.setValues(json); 82 | ``` -------------------------------------------------------------------------------- /tutorials/04_Controls.md: -------------------------------------------------------------------------------- 1 | # Controls 2 | Controls can currently be checked for their disabled state, their hidden state and their option set values (to check if filtering works). 3 | 4 | ## Hidden / Disabled State 5 | Hidden and disabled state can be retrieved as a combined object: 6 | 7 | ```javascript 8 | const { isVisible, isDisabled } = await xrmTest.Control.get("name"); 9 | ``` 10 | 11 | ## Options 12 | The array of currently available options for a control can be retrieved like this: 13 | ```javascript 14 | const options = await xrmTest.Control.getOptions("industrycode"); 15 | ``` -------------------------------------------------------------------------------- /tutorials/05_Buttons.md: -------------------------------------------------------------------------------- 1 | # Buttons 2 | 3 | ## Click 4 | Buttons can be clicked either by name or by data-id. 5 | The data-ids can be found in the HTML DOM, the labels are just the ones you see in the D365 UI. 6 | This should also work for clicking subgrid buttons. 7 | 8 | ## By Label 9 | Clicks the first button with the specified label: 10 | 11 | ```javascript 12 | await xrmTest.Button.click({ byLabel: "Create Document" }); 13 | ``` 14 | 15 | ## By Data-Id 16 | Clicks the first button with the specified data-id: 17 | 18 | ```javascript 19 | await xrmTest.Button.click({ byDataId: "account|NoRelationship|Form|mscrmaddons.am.form.createworkingitem.account" }); 20 | ``` -------------------------------------------------------------------------------- /tutorials/06_Subgrids.md: -------------------------------------------------------------------------------- 1 | # Subgrids 2 | There are various functions for interacting with subgrids, which are listed below. 3 | 4 | # Get record count 5 | Gets the number of records that are currently displayed. 6 | 7 | ```javascript 8 | const count = await xrmUiTest.Subgrid.getRecordCount("subgrid1"); 9 | ``` 10 | 11 | # Open n-th record 12 | Opens the update form for the record at position n. 13 | 14 | ```javascript 15 | await xrmUiTest.Subgrid.openNthRecord("subgrid1", 1); 16 | ``` 17 | 18 | # Refresh 19 | Refreshes the subgrid 20 | 21 | ```javascript 22 | await xrmUiTest.Subgrid.refresh("subgrid1"); 23 | ``` 24 | 25 | # Create new record 26 | Takes care of opening the tab where the subgrid resides and clicking its "Add New Record" default button. 27 | If the button is hidden inside the overflow menu, the overflow menu is searched as well. 28 | 29 | Note: 30 | > If the button fails to get clicked, check user permissions, button hide rules and whether you use custom create buttons, as we search for the default create button 31 | 32 | ```javascript 33 | await xrmUiTest.Subgrid.createNewRecord("subgrid1"); 34 | ``` -------------------------------------------------------------------------------- /tutorials/07_Tabs.md: -------------------------------------------------------------------------------- 1 | # Tabs 2 | Tabs don't need to be used for getting or setting values. 3 | However you might want to open them for your IFrames to load correctly (hidden ones will not load on start in D365). 4 | 5 | ## Open 6 | You can expand / select the active tab like this: 7 | 8 | ```javascript 9 | await xrmTest.Tab.open("tab_1"); 10 | ``` 11 | 12 | ## Hidden State 13 | Hidden state can be retrieved like this: 14 | 15 | ```javascript 16 | const { isVisible } = await xrmTest.Tab.get("SUMMARY_TAB"); 17 | ``` -------------------------------------------------------------------------------- /tutorials/08_Entity.md: -------------------------------------------------------------------------------- 1 | # Entity 2 | These are functions for dealing with the entity data on a form. 3 | 4 | ## No Submit 5 | When just opening a create form and entering data, you might not even want to save your data. 6 | If you just try to navigate away without saving, then a confirm message will appear, reminding you that you have unsaved data. 7 | 8 | 9 | ```javascript 10 | await xrmTest.Entity.noSubmit(); 11 | ``` 12 | 13 | If you really want to navigate to a different page without saving, call the `xrmTest.Entity.noSubmit` function for setting all attributes to submitMode `never`, so that none would be saved and thus CRM does not show the prompt. 14 | 15 | ## Save 16 | Saves the data on your current form. 17 | 18 | ```javascript 19 | await xrmTest.Entity.save(); 20 | ``` 21 | 22 | > This does not use the save button, but the SDK function for saving 23 | 24 | ## Get Id 25 | Gets the ID of the current record. 26 | 27 | ```javascript 28 | const id = await xrmTest.Entity.getId(); 29 | ``` 30 | 31 | ## Get Entity Name 32 | Gets the logical name of the current record (entity) 33 | 34 | ```javascript 35 | await xrmTest.Entity.getEntityName(); 36 | ``` 37 | 38 | ## Get Entity Reference 39 | Gets an entity reference pointing to the current record. 40 | 41 | ```javascript 42 | const { id, entityName, name } = await xrmTest.Entity.getEntityReference(); 43 | ``` 44 | 45 | > The return object has the schema { id: string, entityName: string, name: string } 46 | 47 | ## Delete 48 | Deletes the current record. 49 | 50 | ```javascript 51 | await xrmTest.Entity.delete(); 52 | ``` 53 | 54 | > This function uses the delete button of the form. 55 | 56 | ## Activate 57 | Activates the current record 58 | 59 | ```javascript 60 | await xrmTest.Entity.activate(); 61 | ``` 62 | 63 | > This function uses the activate button of the form 64 | 65 | ## Deactivate 66 | Deactivates the current record 67 | 68 | ```javascript 69 | await xrmTest.Entity.deactivate(); 70 | ``` 71 | 72 | > This function uses the deactivate button of the form 73 | -------------------------------------------------------------------------------- /tutorials/09_Dialogs.md: -------------------------------------------------------------------------------- 1 | # Dialogs 2 | This namespace is used for handlers that interact with D365 default dialogs. 3 | Previously the duplicate dialog was controlled from here, but it was integrated in the `xrmTest.Entity.save` function. 4 | 5 | 6 | For now there is no functionality in here, but as soon as new default dialogs have to be handled, this will be done in here again. 7 | 8 | 9 | For custom dialogs, you can always just use the page object that you retrieve from `xrmTest.open`. 10 | This allows to interact with the browser using playwright. 11 | -------------------------------------------------------------------------------- /tutorials/10_DevOps.md: -------------------------------------------------------------------------------- 1 | # DevOps 2 | Once you have a working set of UI tests, you should run them automatically using an automated build such as DevOps. 3 | 4 | ## Getting Started 5 | Easiest way possible is to just create a new build pipeline with the nodejs template. 6 | Afterwards you can just insert below yaml for a build which runs your tests all 2 hours, publishes reports and publishes test results. 7 | You then need to create the 4 pipeline variables crmUrl, userName, userPassword and mfaSecret (at least userPassword and if used mfaSecret should be stored as secret value). 8 | You have to set mfaSecret to an empty value even if you don't have a mfaSecret. 9 | 10 | ```yaml 11 | # Node.js 12 | # Build a general Node.js project with npm. 13 | # Add steps that analyze code, save build artifacts, deploy, and more: 14 | # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript 15 | 16 | trigger: 17 | - master 18 | 19 | schedules: 20 | - cron: "0 */2 * * *" 21 | displayName: Every two hours 22 | branches: 23 | include: 24 | - master 25 | always: true 26 | 27 | pool: 28 | vmImage: 'ubuntu-latest' 29 | 30 | steps: 31 | - task: NodeTool@0 32 | inputs: 33 | versionSpec: '18.x' 34 | displayName: 'Install Node.js' 35 | 36 | - script: | 37 | npm ci 38 | npx playwright install-deps 39 | displayName: "Install Playwright Browsers" 40 | 41 | - script: | 42 | npm run test 43 | displayName: 'Run tests' 44 | env: 45 | D365_UI_TEST_URL: $(crmUrl) 46 | D365_UI_TEST_USERNAME: $(userName) 47 | D365_UI_TEST_PASSWORD: $(userPassword) 48 | D365_UI_TEST_MFA_SECRET: $(mfaSecret) 49 | D365_UI_TEST_HEADLESS: true 50 | 51 | - task: PublishBuildArtifacts@1 52 | inputs: 53 | PathtoPublish: './reports/' 54 | ArtifactName: 'Reports' 55 | publishLocation: 'Container' 56 | condition: always() 57 | 58 | - task: PublishTestResults@2 59 | inputs: 60 | testResultsFormat: 'JUnit' 61 | testResultsFiles: '**/junit_*.xml' 62 | failTaskOnFailedTests: false 63 | condition: always() 64 | ``` 65 | 66 | ## Storing credentials 67 | **You should absolutely not check in your login data into your repository.** 68 | 69 | D365-UI-Test advices to store your url, user name and password as dev ops variables. You can easily define them in the yaml editor by clicking the "Variables" button. **Make sure to save at least the user password as secret variable in DevOps**. 70 | The above yaml already takes care of setting environment variables for the D365-UI-Test execution. 71 | You can see that it takes the pipeline variables 'crmUrl', 'userName' and 'userPassword' and assigns them to the variables CRM_URL, USER_NAME and USER_PASSWORD. 72 | These will be able to be accessed in D365-UI-Test like this: 73 | 74 | ```javascript 75 | const page = await xrmTest.open(process.env.CRM_URL, { userName: process.env.USER_NAME, password: process.env.USER_PASSWORD }); 76 | ``` 77 | 78 | ## Remarks 79 | When running in DevOps, be sure to use the headless runner as described in 01_Startup: 80 | 81 | ```javascript 82 | const browser = await xrmTest.launch({ 83 | headless: true, 84 | args: [ 85 | "--start-fullscreen" 86 | ] 87 | }); 88 | ``` -------------------------------------------------------------------------------- /tutorials/11_FAQs.md: -------------------------------------------------------------------------------- 1 | # FAQs 2 | 3 | ## Can I download files, e.g. reports, Excel exports or Documents Core Pack documents? 4 | Yes, you can. You just need to set the `acceptDownloads` property on the browserContext settings: 5 | 6 | ```javascript 7 | await xrmTest.launch("chromium", 8 | { 9 | headless: false, 10 | args: [ 11 | '--disable-setuid-sandbox', 12 | '--disable-infobars', 13 | '--start-fullscreen', 14 | '--window-position=0,0', 15 | '--window-size=1920,1080', 16 | '--user-agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"' 17 | ] 18 | }, 19 | { 20 | allowDownloads: true 21 | }) 22 | ``` 23 | 24 | This will accept all following downloads and save them with a generic file name. 25 | If you wish to save it with the name that the browser intended to, you can do so as follows: 26 | 27 | ```javascript 28 | const [ download ] = await Promise.all([ 29 | page.waitForEvent('download'), // wait for download to start 30 | page.click('a') // Or any other event that triggers your download 31 | ]); 32 | 33 | const suggestedFileName = download.suggestedFilename(); 34 | await download.saveAs(`./yourDownloadFolder/suggestedFileName`); 35 | ``` 36 | 37 | You can check whether the file was successfully downloaded using the `checkForFile` function of `TestUtils`: 38 | ```javascript 39 | await TestUtils.checkForFile(page, path.resolve("./reports"), [".pdf", ".pdf'"]); 40 | ``` 41 | 42 | ## Can I interact with Xrm functions that are not implemented by now? 43 | Yes, you can. You can use `page.evaluate` for doing this. You should be careful, as page.evaluate can not access variables from its outer context. 44 | 45 | You have to pass all variables that you want to use as second argument in `page.evaluate` like this: 46 | 47 | ```javascript 48 | const logicalName = "account"; 49 | const id = "someid"; 50 | 51 | await page.evaluate((l, i) => { 52 | window.Xrm.WebApi.deleteRecord(l, i); 53 | }, [ logicalName, id ]); 54 | ``` -------------------------------------------------------------------------------- /tutorials/12_TestUtils.md: -------------------------------------------------------------------------------- 1 | # Test Utils 2 | These are various utils that are supposed to help you achieve commonly needed tasks when doing UI tests. 3 | 4 | ## clearfiles 5 | This can be used for clearing downloads or reports files from previous runs. 6 | 7 | ## checkForFile 8 | This can be used for checking whether a specific file (most often by file ending) has been downloaded to a specific folder. 9 | 10 | ## takeScreenShotOnFailure 11 | This is useful for being able to see what happened when tests failed in DevOps. It will take a screenshot of the whole page and save it with the specified file name if a test fails. The error will be rethrown so that no information on the error is lost. 12 | 13 | ## trackTimedOutRequest 14 | This is useful when navigation timeout errors occur. 15 | When loading pages, we wait for all requests to finish (currently 60 seconds wait time by default, but you can increase it using `xrmTest.settings = { timeout: 120 * 1000 }` for example, for setting the default timeout to 120 seconds). 16 | 17 | If you get errors nonetheless, please use this function for reporting the URLs that took longer to load. 18 | We already abort some URLs which have shown timeout issues without being necessary for D365 to work. -------------------------------------------------------------------------------- /tutorials/13_Troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## NavigationTimeouts lead to test failures 4 | On most of the navigation functions we wait for the page to settle by checking that it stays idle for 2 seconds without interruption. 5 | 6 | If it gets busy during those two seconds, we reset the 2 seconds wait time. 7 | 8 | If you nonetheless get time out errors, you can track which requests timed out using the `TestUtils.trackTimedOutRequest` function: 9 | 10 | ```javascript 11 | TestUtils.trackTimedOutRequest(() => page, () => xrmTest.Navigation.openAppById("3a5ff736-45a5-4318-a05e-c8a98761e64a")); 12 | ``` 13 | 14 | If timeouts occur, D365-UI-Test will log the timed out request URLs to console. -------------------------------------------------------------------------------- /tutorials/14_Sections.md: -------------------------------------------------------------------------------- 1 | # Sections 2 | Sections are only used for being able to get their visibility state. 3 | 4 | ## Hidden State 5 | Hidden state can be retrieved like this: 6 | 7 | ```javascript 8 | const { isVisible } = await xrmTest.Section.get("SUMMARY_TAB", "ACCOUNT_INFORMATION"); 9 | ``` 10 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": [ 3 | "./src/**/*.*" 4 | ], 5 | "tsconfig": "tsconfig.json", 6 | "out": "./docs", 7 | "excludePrivate": true, 8 | "excludeProtected": true, 9 | "excludeExternals": true, 10 | "theme": "default", 11 | "readme": "README.md", 12 | "name": "D365-UI-Test", 13 | "categorizeByGroup": false 14 | } 15 | --------------------------------------------------------------------------------