├── .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 [](https://snyk.io/test/github/digitalflow/d365-ui-test) [](https://github.com/XRM-OSS/D365-UI-Test/actions/workflows/main.yml) [](https://badge.fury.io/js/d365-ui-test)  [](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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
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 |
203 |
204 |
Legend
205 |
206 |
207 | Constructor
208 | Method
209 | Accessor
210 |
211 |
214 |
215 | Inherited property
216 |
217 |
218 | Static property
219 |
220 |
221 |
222 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
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 |
155 |
156 |
Legend
157 |
158 |
159 | Constructor
160 | Method
161 | Accessor
162 |
163 |
166 |
167 | Inherited property
168 |
169 |
170 | Static property
171 |
172 |
173 |
174 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
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 |
104 | await xrmTest.Attribute.setValue("name" , "Test name" );
105 |
106 |
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 |
193 |
194 |
Legend
195 |
196 |
197 | Constructor
198 | Method
199 | Accessor
200 |
201 |
204 |
205 | Inherited property
206 |
207 |
208 | Static property
209 |
210 |
211 |
212 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
48 |
49 |
50 |
51 |
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 |
160 |
161 |
Legend
162 |
163 |
164 | Constructor
165 | Method
166 | Accessor
167 |
168 |
171 |
172 | Inherited property
173 |
174 |
175 | Static property
176 |
177 |
178 |
179 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
48 |
49 |
50 |
51 |
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 |
165 |
166 |
Legend
167 |
168 |
169 | Constructor
170 | Method
171 | Accessor
172 |
173 |
176 |
177 | Inherited property
178 |
179 |
180 | Static property
181 |
182 |
183 |
184 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
48 |
49 |
50 |
51 |
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 |
177 |
178 |
Legend
179 |
180 |
181 | Constructor
182 | Method
183 | Accessor
184 |
185 |
188 |
189 | Inherited property
190 |
191 |
192 | Static property
193 |
194 |
195 |
196 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
48 |
49 |
50 |
51 |
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 |
161 |
162 |
Legend
163 |
164 |
165 | Constructor
166 | Method
167 | Accessor
168 |
169 |
172 |
173 | Inherited property
174 |
175 |
176 | Static property
177 |
178 |
179 |
180 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
48 |
49 |
50 |
51 |
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 |
213 |
214 |
Legend
215 |
216 |
217 | Constructor
218 | Method
219 | Accessor
220 |
221 |
224 |
225 | Inherited property
226 |
227 |
228 | Static property
229 |
230 |
231 |
232 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
48 |
49 |
50 |
51 |
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 |
152 |
153 |
Legend
154 |
155 |
156 | Constructor
157 | Method
158 | Accessor
159 |
160 |
163 |
164 | Inherited property
165 |
166 |
167 | Static property
168 |
169 |
170 |
171 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
48 |
49 |
50 |
51 |
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' ),
99 | page.click('a' )
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 |
192 |
193 |
Legend
194 |
195 |
196 | Constructor
197 | Method
198 | Accessor
199 |
200 |
203 |
204 | Inherited property
205 |
206 |
207 | Static property
208 |
209 |
210 |
211 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
48 |
49 |
50 |
51 |
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 |
167 |
168 |
Legend
169 |
170 |
171 | Constructor
172 | Method
173 | Accessor
174 |
175 |
178 |
179 | Inherited property
180 |
181 |
182 | Static property
183 |
184 |
185 |
186 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
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 |
156 |
157 |
Legend
158 |
159 |
160 | Constructor
161 | Method
162 | Accessor
163 |
164 |
167 |
168 | Inherited property
169 |
170 |
171 | Static property
172 |
173 |
174 |
175 |
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 |
18 |
19 | Search
20 |
21 |
22 |
23 | Preparing search index...
24 | The search index is not available
25 |
26 |
D365-UI-Test
27 |
28 |
48 |
49 |
50 |
51 |
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 |
154 |
155 |
Legend
156 |
157 |
158 | Constructor
159 | Method
160 | Accessor
161 |
162 |
165 |
166 | Inherited property
167 |
168 |
169 | Static property
170 |
171 |
172 |
173 |
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 |
--------------------------------------------------------------------------------