Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ yarn-error.log
.env.development
.env.production

# Playwright
playwright-report/
test-results/

.vercel

.vscode/
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2025 Contentstack
Copyright (c) 2026 Contentstack

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,42 @@ We have created an in-depth tutorial on how you can create a Gatsby starter webs

[Build a Starter Website with Gatsby and Contentstack](https://www.contentstack.com/docs/developers/sample-apps/build-a-starter-website-with-gatsby-and-contentstack/)

## E2E Tests

End-to-end tests using [Playwright](https://playwright.dev/).

### Setup

```bash
npm install
npx playwright install chromium
```

### Run Tests

```bash
# Development (port 8000)
npm run develop # Terminal 1
npm run test:e2e # Terminal 2

# Production build (port 9000)
npm run build && npm run serve # Terminal 1
npm run test:e2e:prod # Terminal 2
```

### Commands

| Command | Description |
|---------|-------------|
| `npm run test:e2e` | Run tests against dev server (port 8000) |
| `npm run test:e2e:prod` | Run tests against production build (port 9000) |
| `npm run test:e2e:headed` | Run with visible browser |
| `npm run test:e2e:debug` | Debug mode |
| `npm run test:e2e:ui` | Interactive UI |
| `npm run test:e2e:report` | View HTML report |

**More Resources**

- [Contentstack documentation](https://www.contentstack.com/docs/)
- [Region support documentation](https://www.contentstack.com/docs/developers/selecting-region-in-contentstack-starter-apps)
- [Gatsby documentation](https://www.gatsbyjs.com/docs/)
- [Gatsby documentation](https://www.gatsbyjs.com/docs/)
1 change: 1 addition & 0 deletions e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { test, expect, TestFixtures } from './test-fixtures';
30 changes: 30 additions & 0 deletions e2e/fixtures/test-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test as base } from '@playwright/test';
import { HomePage, AboutPage, BlogPage, BlogPostPage, ContactPage } from '../pages';

export interface TestFixtures {
homePage: HomePage;
aboutPage: AboutPage;
blogPage: BlogPage;
blogPostPage: BlogPostPage;
contactPage: ContactPage;
}

export const test = base.extend<TestFixtures>({
homePage: async ({ page }, use) => {
await use(new HomePage(page));
},
aboutPage: async ({ page }, use) => {
await use(new AboutPage(page));
},
blogPage: async ({ page }, use) => {
await use(new BlogPage(page));
},
blogPostPage: async ({ page }, use) => {
await use(new BlogPostPage(page));
},
contactPage: async ({ page }, use) => {
await use(new ContactPage(page));
},
});

export { expect } from '@playwright/test';
43 changes: 43 additions & 0 deletions e2e/pages/AboutPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';

interface AboutSelectors {
heroBanner: string;
heroTitle: string;
teamSection: string;
teamMembers: string;
}

export class AboutPage extends BasePage {
readonly aboutSelectors: AboutSelectors;

constructor(page: Page) {
super(page);
this.aboutSelectors = {
heroBanner: '.hero-banner',
heroTitle: '.hero-banner .hero-title',
teamSection: '.about-team-section',
teamMembers: '.team-content .team-details',
};
}

async goto(): Promise<void> {
await super.goto('/about-us');
}

async isHeroBannerVisible(): Promise<boolean> {
return await this.page.locator(this.aboutSelectors.heroBanner).isVisible();
}

async getHeroTitle(): Promise<string | null> {
return await this.page.locator(this.aboutSelectors.heroTitle).textContent();
}

async isTeamSectionVisible(): Promise<boolean> {
return await this.page.locator(this.aboutSelectors.teamSection).isVisible();
}

async getTeamMembersCount(): Promise<number> {
return await this.page.locator(this.aboutSelectors.teamMembers).count();
}
}
109 changes: 109 additions & 0 deletions e2e/pages/BasePage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Page, Locator } from '@playwright/test';

export interface BaseSelectors {
header: string;
headerLogo: string;
headerNavItems: string;
footer: string;
footerNav: string;
footerSocialLinks: string;
copyright: string;
}

export class BasePage {
readonly page: Page;
readonly selectors: BaseSelectors;

constructor(page: Page) {
this.page = page;
this.selectors = {
header: 'header.header',
headerLogo: 'header.header .logo',
headerNavItems: 'header.header .nav-li',
footer: 'footer',
footerNav: 'footer .nav-ul',
footerSocialLinks: 'footer .social-nav a',
copyright: '.copyright',
};
}

async goto(path: string = '/'): Promise<void> {
await this.page.goto(path);
await this.waitForPageLoad();
}

async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState('domcontentloaded');
await this.page.locator(this.selectors.header).waitFor({ state: 'visible', timeout: 10000 });
}

async getPageTitle(): Promise<string> {
return await this.page.title();
}

async isHeaderVisible(): Promise<boolean> {
return await this.page.locator(this.selectors.header).isVisible();
}

async isFooterVisible(): Promise<boolean> {
return await this.page.locator(this.selectors.footer).isVisible();
}

async getHeaderNavItems(): Promise<string[]> {
await this.page.locator(this.selectors.headerNavItems).first().waitFor({ state: 'visible' });
return await this.page.locator(`${this.selectors.headerNavItems} a`).allTextContents();
}

async clickNavItem(text: string): Promise<void> {
const currentUrl = this.page.url();
const link = this.page.locator(`${this.selectors.headerNavItems} a`).filter({ hasText: text });
await link.click();
await this.page.waitForFunction(
(oldUrl) => window.location.href !== oldUrl,
currentUrl,
{ timeout: 10000 }
);
await this.waitForPageLoad();
}

async getFooterNavItems(): Promise<string[]> {
return await this.page.locator(`${this.selectors.footerNav} a`).allTextContents();
}

async getFooterSocialLinksCount(): Promise<number> {
return await this.page.locator(this.selectors.footerSocialLinks).count();
}

async getCopyrightText(): Promise<string | null> {
return await this.page.locator(this.selectors.copyright).textContent();
}

async clickLogo(): Promise<void> {
const currentUrl = this.page.url();
await this.page.waitForTimeout(200);
await this.page.evaluate(() => {
const logo = document.querySelector('header.header .logo') as HTMLElement;
if (logo) {
const link = logo.closest('a') as HTMLAnchorElement;
if (link) link.click();
else logo.click();
}
});
if (!currentUrl.match(/\/$|:9000\/?$|:8000\/?$/)) {
await this.page.waitForFunction(
(oldUrl) => window.location.href !== oldUrl,
currentUrl,
{ timeout: 10000 }
);
}
await this.waitForPageLoad();
}

async getCurrentUrl(): Promise<string> {
return this.page.url();
}

getLocator(selector: string): Locator {
return this.page.locator(selector);
}
}
66 changes: 66 additions & 0 deletions e2e/pages/BlogPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';

interface BlogSelectors {
blogBanner: string;
bannerTitle: string;
blogContainer: string;
blogList: string;
blogListTitle: string;
archiveSection: string;
archiveTitle: string;
}

export class BlogPage extends BasePage {
readonly blogSelectors: BlogSelectors;

constructor(page: Page) {
super(page);
this.blogSelectors = {
blogBanner: '.blog-page-banner',
bannerTitle: '.blog-page-banner .hero-title',
blogContainer: '.blog-container',
blogList: '.blog-list',
blogListTitle: '.blog-list h3',
archiveSection: '.blog-column-right',
archiveTitle: '.blog-column-right h2',
};
}

async goto(): Promise<void> {
await super.goto('/blog');
}

async isBannerVisible(): Promise<boolean> {
return await this.page.locator(this.blogSelectors.blogBanner).isVisible();
}

async getBannerTitle(): Promise<string | null> {
return await this.page.locator(this.blogSelectors.bannerTitle).textContent();
}

async isBlogContainerVisible(): Promise<boolean> {
return await this.page.locator(this.blogSelectors.blogContainer).isVisible();
}

async getBlogPostsCount(): Promise<number> {
return await this.page.locator(this.blogSelectors.blogList).count();
}

async getBlogPostTitles(): Promise<string[]> {
return await this.page.locator(this.blogSelectors.blogListTitle).allTextContents();
}

async clickBlogPost(index: number = 0): Promise<void> {
await this.page.locator(this.blogSelectors.blogList).nth(index).locator('a').first().click();
await this.waitForPageLoad();
}

async isArchiveSectionVisible(): Promise<boolean> {
return await this.page.locator(this.blogSelectors.archiveSection).isVisible();
}

async getArchiveTitle(): Promise<string | null> {
return await this.page.locator(this.blogSelectors.archiveTitle).textContent();
}
}
45 changes: 45 additions & 0 deletions e2e/pages/BlogPostPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';

interface PostSelectors {
blogContainer: string;
blogDetail: string;
postTitle: string;
postAuthor: string;
relatedPosts: string;
}

export class BlogPostPage extends BasePage {
readonly postSelectors: PostSelectors;

constructor(page: Page) {
super(page);
this.postSelectors = {
blogContainer: '.blog-container',
blogDetail: '.blog-detail',
postTitle: '.blog-detail h2',
postAuthor: '.blog-detail span strong',
relatedPosts: '.related-post a',
};
}

async goto(slug: string = '/blog/sample-post/'): Promise<void> {
await super.goto(slug);
}

async getPostTitle(): Promise<string | null> {
return await this.page.locator(this.postSelectors.postTitle).textContent();
}

async getAuthorName(): Promise<string | null> {
return await this.page.locator(this.postSelectors.postAuthor).textContent();
}

async isBlogDetailVisible(): Promise<boolean> {
return await this.page.locator(this.postSelectors.blogDetail).isVisible();
}

async getRelatedPostsCount(): Promise<number> {
return await this.page.locator(this.postSelectors.relatedPosts).count();
}
}
Loading
Loading