From 76e7523705f9be7649382288cad97ec551ee71bb Mon Sep 17 00:00:00 2001 From: Jan Molak <1089173+jan-molak@users.noreply.github.com> Date: Tue, 10 Oct 2023 10:45:10 +0100 Subject: [PATCH] Aligned repository structure with WDIO recommendation --- serenity.properties | 1 + src/github/index.ts | 1 - src/serenity/Homepage.ts | 35 ------- src/serenity/index.ts | 1 - .../serenity/github-api}/GitHubStatus.ts | 32 +++--- test/serenity/todo-list-app/TodoList.ts | 99 +++++++++++++++++++ test/serenity/todo-list-app/TodoListItem.ts | 36 +++++++ {spec => test/specs}/example.spec.ts | 42 ++++---- wdio.conf.ts | 11 ++- 9 files changed, 184 insertions(+), 74 deletions(-) create mode 100644 serenity.properties delete mode 100644 src/github/index.ts delete mode 100644 src/serenity/Homepage.ts delete mode 100644 src/serenity/index.ts rename {src/github => test/serenity/github-api}/GitHubStatus.ts (66%) create mode 100644 test/serenity/todo-list-app/TodoList.ts create mode 100644 test/serenity/todo-list-app/TodoListItem.ts rename {spec => test/specs}/example.spec.ts (75%) diff --git a/serenity.properties b/serenity.properties new file mode 100644 index 000000000..c2a70a6d6 --- /dev/null +++ b/serenity.properties @@ -0,0 +1 @@ +serenity.project.name=serenity-js-mocha-webdriverio-template diff --git a/src/github/index.ts b/src/github/index.ts deleted file mode 100644 index 4fa1371c6..000000000 --- a/src/github/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './GitHubStatus'; diff --git a/src/serenity/Homepage.ts b/src/serenity/Homepage.ts deleted file mode 100644 index e4101499c..000000000 --- a/src/serenity/Homepage.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Ensure, includes } from '@serenity-js/assertions'; -import { Task } from '@serenity-js/core'; -import { By, Navigate, Page, PageElement, Text } from '@serenity-js/web'; - -/** - * Learn more about web testing with Serenity/JS - * https://serenity-js.org/handbook/web-testing/ - */ -export class Homepage { - // Questions allow actors to retrieve information about the system under test and its environment. - // PageElement is a question that helps actors identify web elements of interest - private static heroBannerHeading = () => - PageElement.located(By.css('h1')); - - // Questions like PageElement can be composed with other questions to transform the returned value - // Question about Text retrieves the text content of a web element - static heroBannerHeadingText = () => - Text.of(Homepage.heroBannerHeading()) - // Questions provide a QuestionAdapter interface that allows you to chain additional transformations - // of the returned value. For example, you might want to replace all new line characters: - .replaceAll('\n', ' ') - // You can set custom description to be used in the reports if needed: - .describedAs('hero banner text'); - - // Tasks are reusable units of business logic that can be composed into user workflows. - // You can organise them in any way you like, e.g. per page, per feature, per domain, etc. - static open = () => - Task.where(`#actor opens Serenity/JS homepage`, - Navigate.to('https://serenity-js.org'), - Ensure.that( - Page.current().title().describedAs('homepage title'), - includes('Serenity/JS') - ), - ) -} \ No newline at end of file diff --git a/src/serenity/index.ts b/src/serenity/index.ts deleted file mode 100644 index 8a0d762d1..000000000 --- a/src/serenity/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Homepage'; diff --git a/src/github/GitHubStatus.ts b/test/serenity/github-api/GitHubStatus.ts similarity index 66% rename from src/github/GitHubStatus.ts rename to test/serenity/github-api/GitHubStatus.ts index 45fdba02a..e53702dbc 100644 --- a/src/github/GitHubStatus.ts +++ b/test/serenity/github-api/GitHubStatus.ts @@ -1,21 +1,21 @@ -import { Ensure, equals } from '@serenity-js/assertions'; -import { Task } from '@serenity-js/core'; -import { GetRequest, LastResponse, Send } from '@serenity-js/rest'; +import { Ensure, equals } from '@serenity-js/assertions' +import { Task } from '@serenity-js/core' +import { GetRequest, LastResponse, Send } from '@serenity-js/rest' /** * Learn more about API testing with Serenity/JS * https://serenity-js.org/handbook/api-testing/ */ export class GitHubStatus { - private static readonly baseApiUrl = 'https://www.githubstatus.com/api/v2/'; - private static readonly statusJson = this.baseApiUrl + 'status.json'; + private static readonly baseApiUrl = 'https://www.githubstatus.com/api/v2/' + private static readonly statusJson = this.baseApiUrl + 'status.json' static ensureAllSystemsOperational = () => Task.where(`#actor ensures all GitHub systems are operational`, Send.a(GetRequest.to(this.statusJson)), Ensure.that(LastResponse.status(), equals(200)), Ensure.that( - LastResponse.body().status.description.describedAs('GitHub Status'), + LastResponse.body().status.description.describedAs('GitHub Status'), equals('All Systems Operational') ), ) @@ -25,16 +25,16 @@ export class GitHubStatus { * Interfaces describing a simplified response structure returned by the GitHub Status Summary API: * https://www.githubstatus.com/api/v2/summary.json */ -interface StatusJSON { +interface StatusResponse { page: { - id: string; - name: string; - url: string; - time_zone: string; - updated_at: string; - }; + id: string + name: string + url: string + time_zone: string + updated_at: string + } status: { - indicator: string; - description: string; - }; + indicator: string + description: string + } } diff --git a/test/serenity/todo-list-app/TodoList.ts b/test/serenity/todo-list-app/TodoList.ts new file mode 100644 index 000000000..ed4844bd2 --- /dev/null +++ b/test/serenity/todo-list-app/TodoList.ts @@ -0,0 +1,99 @@ +import { contain, Ensure, equals, includes, isGreaterThan } from '@serenity-js/assertions' +import { type Answerable, Check, d, type QuestionAdapter, Task, Wait } from '@serenity-js/core' +import { By, Enter, ExecuteScript, isVisible, Key, Navigate, Page, PageElement, PageElements, Press, Text } from '@serenity-js/web' + +import { TodoListItem } from './TodoListItem' + +export class TodoList { + + // Public API captures the business domain-focused tasks + // that an actor interacting with a TodoList app can perform + + static createEmptyList = () => + Task.where('#actor creates an empty todo list', + Navigate.to('https://todo-app.serenity-js.org/'), + Ensure.that( + Page.current().title().describedAs('website title'), + equals('Serenity/JS TodoApp'), + ), + Wait.until(this.newTodoInput(), isVisible()), + TodoList.emptyLocalStorageIfNeeded(), + ) + + static emptyLocalStorageIfNeeded = () => + Task.where('#actor empties local storage if needed', + Check.whether(TodoList.persistedItems().length, isGreaterThan(0)) + .andIfSo( + TodoList.emptyLocalStorage(), + Page.current().reload(), + ) + ) + + static createListContaining = (itemNames: Array>) => + Task.where(`#actor starts with a list containing ${ itemNames.length } items`, + TodoList.createEmptyList(), + ...itemNames.map(itemName => this.recordItem(itemName)) + ) + + static recordItem = (itemName: Answerable) => + Task.where(d `#actor records an item called ${ itemName }`, + Enter.theValue(itemName).into(this.newTodoInput()), + Press.the(Key.Enter).in(this.newTodoInput()), + Wait.until(Text.ofAll(this.items()), contain(itemName)), + ) + + static markAsCompleted = (itemNames: Array>) => + Task.where(d`#actor marks the following items as completed: ${ itemNames }`, + ...itemNames.map(itemName => TodoListItem.markAsCompleted(this.itemCalled(itemName))) + ) + + static markAsOutstanding = (itemNames: Array>) => + Task.where(d`#actor marks the following items as outstanding: ${ itemNames }`, + ...itemNames.map(itemName => TodoListItem.markAsOutstanding(this.itemCalled(itemName))) + ) + + static outstandingItemsCount = () => + Text.of(PageElement.located(By.tagName('strong')).of(this.outstandingItemsLabel())) + .as(Number) + .describedAs('number of items left') + + // Private API captures ways to locate interactive elements and data transformation logic. + // Private API supports the public API and is not used in the test scenario directly. + + private static itemCalled = (name: Answerable) => + this.items() + .where(Text, includes(name)) + .first() + .describedAs(d`an item called ${ name }`) + + private static outstandingItemsLabel = () => + PageElement.located(By.css('.todo-count')) + .describedAs('items left counter') + + private static newTodoInput = () => + PageElement.located(By.css('.new-todo')) + .describedAs('"What needs to be done?" input box') + + private static items = () => + PageElements.located(By.css('.todo-list li')) + .describedAs('displayed items') + + private static persistedItems = () => + Page.current() + .executeScript(` + return window.localStorage['serenity-js-todo-app'] + ? JSON.parse(window.localStorage['serenity-js-todo-app']) + : [] + `).describedAs('persisted items') as QuestionAdapter + + private static emptyLocalStorage = () => + Task.where('#actor empties local storage', + ExecuteScript.sync(`window.localStorage.removeItem('serenity-js-todo-app')`) + ) +} + +interface PersistedTodoItem { + id: number; + name: string; + completed: boolean; +} diff --git a/test/serenity/todo-list-app/TodoListItem.ts b/test/serenity/todo-list-app/TodoListItem.ts new file mode 100644 index 000000000..b4813cbed --- /dev/null +++ b/test/serenity/todo-list-app/TodoListItem.ts @@ -0,0 +1,36 @@ +import { contain, not } from '@serenity-js/assertions' +import { Check, d, QuestionAdapter, Task } from '@serenity-js/core' +import { By, Click, CssClasses, PageElement } from '@serenity-js/web' + +export class TodoListItem { + + // Public API captures the business domain-focused tasks + // that an actor interacting with a TodoListItem app can perform + + static markAsCompleted = (item: QuestionAdapter) => + Task.where(d `#actor marks ${ item } as completed`, + Check.whether(CssClasses.of(item), not(contain('completed'))) + .andIfSo(this.toggle(item)), + ) + + static markAsOutstanding = (item: QuestionAdapter) => + Task.where(d `#actor marks ${ item } as outstanding`, + Check.whether(CssClasses.of(item), contain('completed')) + .andIfSo(this.toggle(item)), + ) + + static toggle = (item: QuestionAdapter) => + Task.where(d `#actor toggles the completion status of ${ item }`, + Click.on( + TodoListItem.toggleButton().of(item), + ), + ) + + // Private API captures ways to locate interactive elements and data transformation logic. + // Private API supports the public API and is not used in the test scenario directly. + + private static toggleButton = () => + PageElement + .located(By.css('input.toggle')) + .describedAs('toggle button') +} diff --git a/spec/example.spec.ts b/test/specs/example.spec.ts similarity index 75% rename from spec/example.spec.ts rename to test/specs/example.spec.ts index ac0c04ac2..a16ddf418 100644 --- a/spec/example.spec.ts +++ b/test/specs/example.spec.ts @@ -1,10 +1,9 @@ -import { Ensure, equals } from '@serenity-js/assertions'; -import { actorCalled } from '@serenity-js/core'; -import { By, Navigate, PageElement, Text } from '@serenity-js/web'; -import { describe, it } from 'mocha'; +import { Ensure, equals } from '@serenity-js/assertions' +import { actorCalled } from '@serenity-js/core' +import { By, Navigate, PageElement, Text } from '@serenity-js/web' -import { GitHubStatus } from '../src/github'; -import { Homepage } from '../src/serenity'; +import { GitHubStatus } from '../serenity/github-api/GitHubStatus' +import { TodoList } from '../serenity/todo-list-app/TodoList' /** * Serenity/JS Screenplay Pattern test scenarios use one or more actors to represent people and external systems @@ -40,7 +39,7 @@ describe('Serenity/JS Website', () => { equals('Start automating 🚀') ), ) - }); + }) /** * This is a more advanced example of a Serenity/JS Screenplay Pattern test scenario. @@ -51,28 +50,37 @@ describe('Serenity/JS Website', () => { * * In this scenario, rather than list all the interactions in the test itself, we use: * - API Actions Class Pattern to group tasks concerning interacting with the GitHubStatus API - * - Page Objects Pattern to group tasks and questions concerning interacting with the Serenity/JS Homepage + * - Screenplay Pattern flavour of Page Objects to group tasks and questions concerning interacting with the Serenity/JS Todo List app * * Note that apart from strongly encouraging the Screenplay Pattern, * Serenity/JS doesn't require you to organise your code in any particular way. * This gives you the freedom to choose patterns and abstractions that work best for you, your team, * and reflect the domain of your system under test. */ - it(`tells people what the framework can help them do`, async () => { + it(`offers examples to help you practice test automation`, async () => { // You can use API interactions to manage test data, or to ensure services are up and running before performing any UI checks. - // Since Serenity/JS website is deployed to GitHub Pages, before interacting with the website we ensure that GitHub is up and running. + // Since Serenity/JS demo website is deployed to GitHub Pages, + // before interacting with the website we ensure that GitHub itself is up and running. await actorCalled('Apisitt').attemptsTo( GitHubStatus.ensureAllSystemsOperational(), - ); + ) // Once we know the system is up and running, Wendy can proceed with the web-based scenario. await actorCalled('Wendy').attemptsTo( - Homepage.open(), + TodoList.createListContaining([ + `Buy dog food`, + `Feed the dog`, + `Book a vet's appointment`, + ]), + TodoList.markAsCompleted([ + `Buy dog food`, + `Feed the dog`, + ]), Ensure.that( - Homepage.heroBannerHeadingText(), - equals('Enable collaborative test automation at any scale!') + TodoList.outstandingItemsCount(), + equals(1) ), - ); - }); -}); + ) + }) +}) diff --git a/wdio.conf.ts b/wdio.conf.ts index 554412d5b..7acfcd6cd 100644 --- a/wdio.conf.ts +++ b/wdio.conf.ts @@ -1,5 +1,9 @@ import {WebdriverIOConfig} from '@serenity-js/webdriverio'; +// Run tests in headless mode on CI and non-headless otherwise +// Set to true/false to override +const headless = Boolean(process.env.CI); + export const config: WebdriverIOConfig = { // ========================= @@ -26,7 +30,7 @@ export const config: WebdriverIOConfig = { ] }, - headless: true, + headless, // You can output the logs to a file to avoid cluttering the console // outputDir: 'target/logs', @@ -45,7 +49,7 @@ export const config: WebdriverIOConfig = { // then the current working directory is where your `package.json` resides, so `wdio` // will be called from there. specs: [ - './spec/**/*.spec.ts', + './test/specs/**/*.ts' ], // Patterns to exclude. exclude: [ @@ -98,10 +102,9 @@ export const config: WebdriverIOConfig = { '--allow-file-access', '--disable-infobars', '--ignore-certificate-errors', - '--headless', '--disable-gpu', '--window-size=1024x768', - ] + ].concat(headless ? ['--headless'] : []), } }], //