Skip to content

Commit

Permalink
Merge pull request #8 from efuller/update-pom
Browse files Browse the repository at this point in the history
Update POM
  • Loading branch information
efuller authored Feb 14, 2024
2 parents bca5c52 + 911ef3f commit d822689
Show file tree
Hide file tree
Showing 12 changed files with 256 additions and 158 deletions.
32 changes: 17 additions & 15 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
![Tests Passing](https://github.com/efuller/exploring-frontend-architecture/actions/workflows/pr.yaml/badge.svg)
# Exploring Frontend Architecture
This is a small frontend application that was built to explore different variations of frontend architecture.

You can check it out [here](https://exploring-frontend-architecture.onrender.com/).

<img width="878" alt="Screenshot 2024-02-13 at 6 42 51 AM" src="https://github.com/efuller/exploring-frontend-architecture/assets/4174472/e89e0158-2974-4a64-883e-c8ea27cc2677">

## Concepts Explored
- Using **Puppeteer** for **E2E Acceptance Testing**
- Using a **Page Object Model** to make E2E tests less volatile and more declarative
- **Application Level Acceptance Testing**
- **Cucumber + Gherkins**
- **Composition Root Pattern**
- **Reactivity** using the **Observer Pattern**
- **Outgoing Contract Test** to ensure our in-memory client storage works as intended
- **Presenters** wire up the **UI Framework** to the application state
- **Controllers** perform A**pplication Use Cases**
- **Repositories** manage and act upon G**lobal Application State**
- **Dependency Inversion** is used to interface with a **Client Storage Interface**. This allows us to **Code to an Interface** and create a proper **Stub for Testing**
- **Github Actions**
- Run **Application Acceptance Tests** and **Unit Tests** for PRs
- Run **E2E Tests** after deploy
## Functionality
- A user can add a journal entry
- A user can delete a journal entry
- A user can favorite a journal entry
- Favorites are saved to local storage. State is hydrated from the favorites stored in local storage upload load.
- When a favorite is deleted, the user will have to confirm deletion

## Concepts Explored
- Using **Puppeteer** for **E2E Acceptance Testing**
- Using a **Page Object Model** to make E2E tests less volatile and more declarative ([link](tests/shared))
- **Application Level Acceptance Testing** ([link](src/tests/app/journal))
- **Cucumber + Gherkins**
- **Composition Root Pattern** ([link](src/shared/compositionRoot/compositionRoot.ts))
- **Reactivity** using the **Observer Pattern** ([link](src/shared/observable/observable.ts))
- **Presenters** wire up the **UI Framework** to the application state ([link](src/modules/journal/journalPresenter.ts))
- **Controllers** perform **Application Use Cases** ([link](src/modules/journal/journalController.ts))
- **Repositories** manage and act upon **Global Application State** ([link](src/modules/journal/journalRepository.ts))
- **Dependency Inversion** is used to interface with a **Client Storage Interface**. This allows us to **Code to an Interface** and create a proper **Stub for Testing** ([link](src/modules/journal/infra))
- **Outgoing Contract Test** to ensure our in-memory client storage works as intended ([link](src/tests/infra/clientStorage.infra.ts))
- **Github Actions** ([link](.github/workflows))
- Run **Application Acceptance Tests** and **Unit Tests** for PRs
- Run **E2E Tests** after deploy
---

![General Architecture](/.eraser/f9Z5mwS6LKSmIUNbnTRi___YzvcTKoiYxfvjTVEmHkkLRz706J3___---figure---YN8ciuCREnVWFFfnKvETo---figure---CCUc806duYk9SsUSOQEUVA.png "General Architecture")
Expand Down
2 changes: 1 addition & 1 deletion src/components/journal/journalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const JournalForm = ({ onSubmit }: JournalFormProps) => {
}, [isSubmitSuccessful, reset]);

return (
<form onSubmit={handleSubmit(onSubmit)}>
<form id="add-journal" onSubmit={handleSubmit(onSubmit)}>
<div className="flex mt-4">
<input
{...register('title', {required: true})}
Expand Down
4 changes: 2 additions & 2 deletions src/components/journal/journalList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export const JournalList = ({ vm, controller }: JournalListProps) => {
<ul id="journal-list">
{
vm.getJournals().map((journal: CreateJournalDTO) => (
<li key={journal.id} className="flex mb-4 border p-2 text-left pl-6 items-center">
<p className="w-full text-grey-darkest">{journal.title}</p>
<li key={journal.id} className="flex mb-4 border p-2 text-left pl-6 items-center journal-entry">
<p className="w-full text-grey-darkest journal-title">{journal.title}</p>
<button
className={classNames(
journal.isFavorite
Expand Down
23 changes: 16 additions & 7 deletions tests/e2e/journal/journal.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,48 @@ import { loadFeature, defineFeature } from 'jest-cucumber';
import { MainPage } from "../../shared/pages/mainPage";
import { PuppeteerPageDriver } from "../../shared/driver/pupeteerPageDriver";
import { CreateJournalDTO } from "../../../src/modules/journal/journal";
import { AddJournalFormComponent } from "../../shared/pageComponents/journal/addJournalForm";
import { JournalList } from "../../shared/pageComponents/journal/journalList";
import { WebApp } from "../../shared/webApp/webApp";

const feature = loadFeature('tests/e2e/journal/journal.feature');

defineFeature(feature, (test) => {
test('Adding a journal', ({ given, when, then }) => {
let newJournalInput: CreateJournalDTO;
let webApp: WebApp;
let pageDriver: PuppeteerPageDriver;
let mainPage: MainPage;
let addJournalForm: AddJournalFormComponent;
let journalList: JournalList;

beforeAll(async () => {
pageDriver = await PuppeteerPageDriver.create({ headless: true });
mainPage = new MainPage(pageDriver);
pageDriver = await PuppeteerPageDriver.create({ headless: 'new' });
webApp = await WebApp.create(pageDriver);
mainPage = webApp.getPageObject('mainPage');
addJournalForm = mainPage.$('addJournalForm');
journalList = mainPage.$('journalList');
});

afterAll(async () => {
await pageDriver.close();
await webApp.close();
});

given('The app can be accessed', async () => {
// Do a thing
await mainPage.open();
await mainPage.navigate();
});

when(/^The user adds a new journal called (.*)$/, async (journal) => {
newJournalInput = {
title: journal
}

await mainPage.addNewJournal(newJournalInput);
await addJournalForm.addAndSubmit(newJournalInput);
});

then(/^The user should be able to verify that (.*) is added to the list$/, async (journal) => {
expect(await mainPage.journalTitleToBeInList(journal)).toBe(true);
const firstJournal = await journalList.getFirstJournal();
expect(firstJournal.title).toBe(journal);
});
});
});
35 changes: 5 additions & 30 deletions tests/shared/driver/pupeteerPageDriver.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,11 @@
import puppeteer, { Browser, Page, PuppeteerLaunchOptions } from 'puppeteer';

export class PuppeteerPageDriver {
private constructor(private instance: Browser, private page: Page) {}
private constructor(public browser: Browser, public page: Page) {}

/**
* Creates a new instance of PuppeteerPageDriver
* @param opts
*/
public static async create(opts: PuppeteerLaunchOptions) {
const instance = await puppeteer.launch(opts);
const page = await instance.newPage();
return new PuppeteerPageDriver(instance, page);
}

/**
* Closes the instance of PuppeteerPageDriver
*/
public async close() {
await this.instance.close();
}

/**
* Get the page.
*/
public getPage() {
return this.page;
}

/**
* Pause the test for 3 seconds.
*/
async pause() {
await new Promise(r => setTimeout(r, 3000))
static async create(driverProps: PuppeteerLaunchOptions) {
const browser = await puppeteer.launch(driverProps);
const page = await browser.newPage();
return new PuppeteerPageDriver(browser, page);
}
}
56 changes: 0 additions & 56 deletions tests/shared/pageComponents.ts

This file was deleted.

56 changes: 56 additions & 0 deletions tests/shared/pageComponents/basePageComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { PuppeteerPageDriver } from "../driver/pupeteerPageDriver";

export type ComponentElementsConfig = {
[key: string]: { selector: string };
};

export class BasePageComponent<T extends ComponentElementsConfig> {
constructor(
protected pageDriver: PuppeteerPageDriver,
protected componentConfig: T
) {}

async isValid() {
const errors: string[] = [];

const promises = Object.keys(this.componentConfig).map(async (key) => {
const component = this.componentConfig[key];
return await this.pageDriver.page.waitForSelector(component.selector)
.then(() => true)
.catch(() => {
errors.push(component.selector);
return false;
});
});

const result = await Promise.all(promises);

if (result.includes(false)) {
throw new Error(`These selectors are not valid for the ${this.constructor.name}: ${errors.join(', ')}`);
}
return true;
}

async $(key: keyof T) {
if (!this.componentConfig?.[key]) {
throw new Error(`Page component ${String(key)} does not exist`);
}

const result = await this.pageDriver.page.waitForSelector(this.componentConfig[key].selector);

if (!result) {
throw new Error(`There was a problem selecting the page component ${String(key)}`);
}
return result;
}

async waitAndType(key: keyof T, text: string) {
const input = await this.$(key);
await input.type(text);
}

async waitAndClick(key: keyof T) {
const input = await this.$(key);
await input.click();
}
}
21 changes: 21 additions & 0 deletions tests/shared/pageComponents/journal/addJournalForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { BasePageComponent } from "../basePageComponent";
import { PuppeteerPageDriver } from "../../driver/pupeteerPageDriver";

type AddJournalFormElements = {
titleInput: { selector: string };
submitBtn: { selector: string };
};

export class AddJournalFormComponent extends BasePageComponent<AddJournalFormElements> {
constructor(
protected pageDriver: PuppeteerPageDriver,
protected componentConfig: AddJournalFormElements,
) {
super(pageDriver, componentConfig);
}

async addAndSubmit({ title }: { title: string }) {
await this.waitAndType('titleInput', title);
await this.waitAndClick('submitBtn');
}
}
39 changes: 39 additions & 0 deletions tests/shared/pageComponents/journal/journalList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { BasePageComponent } from '../basePageComponent';
import { PuppeteerPageDriver } from "../../driver/pupeteerPageDriver";

type JournalListFormElements = {
journalList: { selector: string };
journalEntries: { selector: string };
journalTitle: { selector: string };
};

export class JournalList extends BasePageComponent<JournalListFormElements> {
constructor(
protected pageDriver: PuppeteerPageDriver,
protected componentConfig: JournalListFormElements,
) {
super(pageDriver, componentConfig);
}

async getFirstJournal() {
const journalList = await this.$('journalList');

if (!journalList) {
throw new Error('Add journal form is not visible');
}

const journalEntries = await journalList.$$(this.componentConfig.journalEntries.selector);

if (journalEntries.length < 1) {
throw new Error('Journal entries are not visible');
}

const [firstJournal] = journalEntries;

const title = await firstJournal.$eval(this.componentConfig.journalTitle.selector, (el) => el.textContent);

return {
title,
};
}
}
24 changes: 24 additions & 0 deletions tests/shared/pages/basePage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { PuppeteerPageDriver } from "../driver/pupeteerPageDriver";

export abstract class BasePage<T> {
public pageComponents: T | undefined;

protected constructor(
protected pageDriver: PuppeteerPageDriver,
protected url: string
) {
}

$<K extends keyof T>(key: K): T[K] {
if (!this.pageComponents?.[key]) {
throw new Error(`Page component ${String(key)} does not exist`);
}
return this.pageComponents[key];
}

abstract generatePageComponents(): Promise<T>;

async navigate() {
await this.pageDriver.page.goto(this.url);
}
}
Loading

0 comments on commit d822689

Please sign in to comment.