diff --git a/.gitignore b/.gitignore index 3200dcf..a87c9cd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ yarn-error.log .env.development .env.production +# Playwright +playwright-report/ +test-results/ + .vercel .vscode/ diff --git a/LICENSE b/LICENSE index 816ce04..13a5837 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md index 0144cc8..ef693d8 100644 --- a/README.md +++ b/README.md @@ -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/) \ No newline at end of file +- [Gatsby documentation](https://www.gatsbyjs.com/docs/) diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts new file mode 100644 index 0000000..03f98d5 --- /dev/null +++ b/e2e/fixtures/index.ts @@ -0,0 +1 @@ +export { test, expect, TestFixtures } from './test-fixtures'; diff --git a/e2e/fixtures/test-fixtures.ts b/e2e/fixtures/test-fixtures.ts new file mode 100644 index 0000000..e8be72b --- /dev/null +++ b/e2e/fixtures/test-fixtures.ts @@ -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({ + 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'; diff --git a/e2e/pages/AboutPage.ts b/e2e/pages/AboutPage.ts new file mode 100644 index 0000000..dcc608c --- /dev/null +++ b/e2e/pages/AboutPage.ts @@ -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 { + await super.goto('/about-us'); + } + + async isHeroBannerVisible(): Promise { + return await this.page.locator(this.aboutSelectors.heroBanner).isVisible(); + } + + async getHeroTitle(): Promise { + return await this.page.locator(this.aboutSelectors.heroTitle).textContent(); + } + + async isTeamSectionVisible(): Promise { + return await this.page.locator(this.aboutSelectors.teamSection).isVisible(); + } + + async getTeamMembersCount(): Promise { + return await this.page.locator(this.aboutSelectors.teamMembers).count(); + } +} diff --git a/e2e/pages/BasePage.ts b/e2e/pages/BasePage.ts new file mode 100644 index 0000000..9ac5be2 --- /dev/null +++ b/e2e/pages/BasePage.ts @@ -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 { + await this.page.goto(path); + await this.waitForPageLoad(); + } + + async waitForPageLoad(): Promise { + await this.page.waitForLoadState('domcontentloaded'); + await this.page.locator(this.selectors.header).waitFor({ state: 'visible', timeout: 10000 }); + } + + async getPageTitle(): Promise { + return await this.page.title(); + } + + async isHeaderVisible(): Promise { + return await this.page.locator(this.selectors.header).isVisible(); + } + + async isFooterVisible(): Promise { + return await this.page.locator(this.selectors.footer).isVisible(); + } + + async getHeaderNavItems(): Promise { + 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 { + 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 { + return await this.page.locator(`${this.selectors.footerNav} a`).allTextContents(); + } + + async getFooterSocialLinksCount(): Promise { + return await this.page.locator(this.selectors.footerSocialLinks).count(); + } + + async getCopyrightText(): Promise { + return await this.page.locator(this.selectors.copyright).textContent(); + } + + async clickLogo(): Promise { + 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 { + return this.page.url(); + } + + getLocator(selector: string): Locator { + return this.page.locator(selector); + } +} diff --git a/e2e/pages/BlogPage.ts b/e2e/pages/BlogPage.ts new file mode 100644 index 0000000..452d471 --- /dev/null +++ b/e2e/pages/BlogPage.ts @@ -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 { + await super.goto('/blog'); + } + + async isBannerVisible(): Promise { + return await this.page.locator(this.blogSelectors.blogBanner).isVisible(); + } + + async getBannerTitle(): Promise { + return await this.page.locator(this.blogSelectors.bannerTitle).textContent(); + } + + async isBlogContainerVisible(): Promise { + return await this.page.locator(this.blogSelectors.blogContainer).isVisible(); + } + + async getBlogPostsCount(): Promise { + return await this.page.locator(this.blogSelectors.blogList).count(); + } + + async getBlogPostTitles(): Promise { + return await this.page.locator(this.blogSelectors.blogListTitle).allTextContents(); + } + + async clickBlogPost(index: number = 0): Promise { + await this.page.locator(this.blogSelectors.blogList).nth(index).locator('a').first().click(); + await this.waitForPageLoad(); + } + + async isArchiveSectionVisible(): Promise { + return await this.page.locator(this.blogSelectors.archiveSection).isVisible(); + } + + async getArchiveTitle(): Promise { + return await this.page.locator(this.blogSelectors.archiveTitle).textContent(); + } +} diff --git a/e2e/pages/BlogPostPage.ts b/e2e/pages/BlogPostPage.ts new file mode 100644 index 0000000..36327df --- /dev/null +++ b/e2e/pages/BlogPostPage.ts @@ -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 { + await super.goto(slug); + } + + async getPostTitle(): Promise { + return await this.page.locator(this.postSelectors.postTitle).textContent(); + } + + async getAuthorName(): Promise { + return await this.page.locator(this.postSelectors.postAuthor).textContent(); + } + + async isBlogDetailVisible(): Promise { + return await this.page.locator(this.postSelectors.blogDetail).isVisible(); + } + + async getRelatedPostsCount(): Promise { + return await this.page.locator(this.postSelectors.relatedPosts).count(); + } +} diff --git a/e2e/pages/ContactPage.ts b/e2e/pages/ContactPage.ts new file mode 100644 index 0000000..d4971a6 --- /dev/null +++ b/e2e/pages/ContactPage.ts @@ -0,0 +1,42 @@ +import { Page } from '@playwright/test'; +import { BasePage } from './BasePage'; + +interface ContactSelectors { + heroBanner: string; + heroTitle: string; + pageContent: string; +} + +export class ContactPage extends BasePage { + readonly contactSelectors: ContactSelectors; + + constructor(page: Page) { + super(page); + this.contactSelectors = { + heroBanner: '.hero-banner', + heroTitle: '.hero-banner .hero-title, h1', + pageContent: '.contact-page-section, .contact-maps-section, .about', + }; + } + + async goto(): Promise { + await super.goto('/contact-us'); + } + + async isHeroBannerVisible(): Promise { + return await this.page.locator(this.contactSelectors.heroBanner).isVisible(); + } + + async getPageHeading(): Promise { + const h1 = this.page.locator('h1').first(); + if (await h1.isVisible()) { + return await h1.textContent(); + } + return null; + } + + async hasMainContent(): Promise { + const content = this.page.locator(this.contactSelectors.pageContent).first(); + return await content.isVisible(); + } +} diff --git a/e2e/pages/HomePage.ts b/e2e/pages/HomePage.ts new file mode 100644 index 0000000..ca57143 --- /dev/null +++ b/e2e/pages/HomePage.ts @@ -0,0 +1,51 @@ +import { Page } from '@playwright/test'; +import { BasePage } from './BasePage'; + +interface HomeSelectors { + heroBanner: string; + heroTitle: string; + featuredBlogs: string; + blogTitle: string; +} + +export class HomePage extends BasePage { + readonly homeSelectors: HomeSelectors; + + constructor(page: Page) { + super(page); + this.homeSelectors = { + heroBanner: '.hero-banner', + heroTitle: '.hero-banner .hero-title', + featuredBlogs: '.home-featured-blogs .featured-blog', + blogTitle: '.featured-blog h3', + }; + } + + async goto(): Promise { + await super.goto('/'); + } + + async isHeroBannerVisible(): Promise { + return await this.page.locator(this.homeSelectors.heroBanner).isVisible(); + } + + async getHeroTitle(): Promise { + return await this.page.locator(this.homeSelectors.heroTitle).textContent(); + } + + async getFeaturedBlogsCount(): Promise { + return await this.page.locator(this.homeSelectors.featuredBlogs).count(); + } + + async clickFeaturedBlog(index: number = 0): Promise { + const currentUrl = this.page.url(); + const link = this.page.locator(this.homeSelectors.featuredBlogs).nth(index).locator('a').first(); + await link.click(); + await this.page.waitForFunction( + (oldUrl) => window.location.href !== oldUrl, + currentUrl, + { timeout: 10000 } + ); + await this.waitForPageLoad(); + } +} diff --git a/e2e/pages/index.ts b/e2e/pages/index.ts new file mode 100644 index 0000000..35c1911 --- /dev/null +++ b/e2e/pages/index.ts @@ -0,0 +1,6 @@ +export { BasePage } from './BasePage'; +export { HomePage } from './HomePage'; +export { AboutPage } from './AboutPage'; +export { BlogPage } from './BlogPage'; +export { BlogPostPage } from './BlogPostPage'; +export { ContactPage } from './ContactPage'; diff --git a/e2e/tests/about.spec.ts b/e2e/tests/about.spec.ts new file mode 100644 index 0000000..8598f1f --- /dev/null +++ b/e2e/tests/about.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '../fixtures'; + +test.describe('About Page', () => { + test('should load with header and footer', async ({ aboutPage }) => { + await aboutPage.goto(); + expect(await aboutPage.isHeaderVisible()).toBe(true); + expect(await aboutPage.isFooterVisible()).toBe(true); + }); + + test('should have correct URL', async ({ aboutPage }) => { + await aboutPage.goto(); + expect(await aboutPage.getCurrentUrl()).toContain('/about-us'); + }); + + test('should have hero banner', async ({ aboutPage }) => { + await aboutPage.goto(); + expect(await aboutPage.isHeroBannerVisible()).toBe(true); + }); + + test('should navigate to Blog page', async ({ aboutPage }) => { + await aboutPage.goto(); + await aboutPage.clickNavItem('Blog'); + expect(await aboutPage.getCurrentUrl()).toContain('/blog'); + }); + + test('should navigate home via logo', async ({ aboutPage }) => { + await aboutPage.goto(); + await aboutPage.clickLogo(); + expect(await aboutPage.getCurrentUrl()).toMatch(/\/$|:8000\/?$/); + }); +}); diff --git a/e2e/tests/accessibility.spec.ts b/e2e/tests/accessibility.spec.ts new file mode 100644 index 0000000..14a1c02 --- /dev/null +++ b/e2e/tests/accessibility.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Accessibility', () => { + test('should have alt text on logos', async ({ page }) => { + await page.goto('/'); + await page.locator('header.header').waitFor({ state: 'visible' }); + const headerLogo = page.locator('header.header .logo'); + await expect(headerLogo).toBeVisible(); + const alt = await headerLogo.getAttribute('alt'); + expect(alt).toBeTruthy(); + }); + + test('should have semantic header and footer', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('header.header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + }); + + test('should have h1 heading on pages', async ({ page }) => { + await page.goto('/'); + await page.locator('header.header').waitFor({ state: 'visible' }); + await expect(page.locator('h1').first()).toBeVisible(); + + await page.goto('/about-us'); + await page.locator('header.header').waitFor({ state: 'visible' }); + await expect(page.locator('h1').first()).toBeVisible(); + }); +}); diff --git a/e2e/tests/blog-post.spec.ts b/e2e/tests/blog-post.spec.ts new file mode 100644 index 0000000..eeb3e8c --- /dev/null +++ b/e2e/tests/blog-post.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '../fixtures'; + +test.describe('Blog Post Page', () => { + test('should navigate to blog post from listing', async ({ blogPage }) => { + await blogPage.goto(); + const postsCount = await blogPage.getBlogPostsCount(); + if (postsCount > 0) { + await blogPage.clickBlogPost(0); + expect(await blogPage.getCurrentUrl()).toContain('/blog'); + } + }); + + test('should display blog post content', async ({ blogPage, page }) => { + await blogPage.goto(); + const postsCount = await blogPage.getBlogPostsCount(); + if (postsCount > 0) { + await blogPage.clickBlogPost(0); + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + } + }); + + test('should display blog post title', async ({ blogPage, page }) => { + await blogPage.goto(); + const postsCount = await blogPage.getBlogPostsCount(); + if (postsCount > 0) { + await blogPage.clickBlogPost(0); + const title = page.locator('.blog-detail h2'); + await expect(title).toBeVisible(); + } + }); +}); diff --git a/e2e/tests/blog.spec.ts b/e2e/tests/blog.spec.ts new file mode 100644 index 0000000..fd7b8d0 --- /dev/null +++ b/e2e/tests/blog.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '../fixtures'; + +test.describe('Blog Page', () => { + test('should load with header and footer', async ({ blogPage }) => { + await blogPage.goto(); + expect(await blogPage.isHeaderVisible()).toBe(true); + expect(await blogPage.isFooterVisible()).toBe(true); + }); + + test('should have correct URL', async ({ blogPage }) => { + await blogPage.goto(); + expect(await blogPage.getCurrentUrl()).toContain('/blog'); + }); + + test('should display blog container', async ({ blogPage }) => { + await blogPage.goto(); + expect(await blogPage.isBlogContainerVisible()).toBe(true); + }); + + test('should navigate to About page', async ({ blogPage }) => { + await blogPage.goto(); + await blogPage.clickNavItem('About'); + expect(await blogPage.getCurrentUrl()).toContain('/about'); + }); + + test('should navigate home via logo', async ({ blogPage }) => { + await blogPage.goto(); + await blogPage.clickLogo(); + expect(await blogPage.getCurrentUrl()).toMatch(/\/$|:8000\/?$/); + }); +}); diff --git a/e2e/tests/contact.spec.ts b/e2e/tests/contact.spec.ts new file mode 100644 index 0000000..95e08af --- /dev/null +++ b/e2e/tests/contact.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '../fixtures'; + +test.describe('Contact Page', () => { + test('should load with header and footer', async ({ contactPage }) => { + await contactPage.goto(); + expect(await contactPage.isHeaderVisible()).toBe(true); + expect(await contactPage.isFooterVisible()).toBe(true); + }); + + test('should have correct URL', async ({ contactPage }) => { + await contactPage.goto(); + expect(await contactPage.getCurrentUrl()).toContain('/contact-us'); + }); + + test('should have main content', async ({ contactPage }) => { + await contactPage.goto(); + expect(await contactPage.hasMainContent()).toBe(true); + }); + + test('should navigate to About page', async ({ contactPage }) => { + await contactPage.goto(); + await contactPage.clickNavItem('About'); + expect(await contactPage.getCurrentUrl()).toContain('/about'); + }); + + test('should navigate home via logo', async ({ contactPage }) => { + await contactPage.goto(); + await contactPage.clickLogo(); + expect(await contactPage.getCurrentUrl()).toMatch(/\/$|:8000\/?$/); + }); +}); diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts new file mode 100644 index 0000000..a72e29b --- /dev/null +++ b/e2e/tests/home.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '../fixtures'; + +test.describe('Home Page', () => { + test('should load with header and footer', async ({ homePage }) => { + await homePage.goto(); + expect(await homePage.isHeaderVisible()).toBe(true); + expect(await homePage.isFooterVisible()).toBe(true); + }); + + test('should have hero banner', async ({ homePage }) => { + await homePage.goto(); + expect(await homePage.isHeroBannerVisible()).toBe(true); + }); + + test('should navigate to About page', async ({ homePage }) => { + await homePage.goto(); + await homePage.clickNavItem('About'); + expect(await homePage.getCurrentUrl()).toContain('/about'); + }); + + test('should navigate to Blog page', async ({ homePage }) => { + await homePage.goto(); + await homePage.clickNavItem('Blog'); + expect(await homePage.getCurrentUrl()).toContain('/blog'); + }); + + test('should navigate to Contact page', async ({ homePage }) => { + await homePage.goto(); + await homePage.clickNavItem('Contact'); + expect(await homePage.getCurrentUrl()).toContain('/contact'); + }); +}); diff --git a/e2e/tests/navigation.spec.ts b/e2e/tests/navigation.spec.ts new file mode 100644 index 0000000..1a34339 --- /dev/null +++ b/e2e/tests/navigation.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '../fixtures'; + +test.describe('Site Navigation', () => { + test('should have consistent nav across pages', async ({ homePage, aboutPage, blogPage }) => { + await homePage.goto(); + const homeNav = await homePage.getHeaderNavItems(); + + await aboutPage.goto(); + const aboutNav = await aboutPage.getHeaderNavItems(); + + await blogPage.goto(); + const blogNav = await blogPage.getHeaderNavItems(); + + expect(homeNav.length).toBe(aboutNav.length); + expect(aboutNav.length).toBe(blogNav.length); + }); + + test('should complete user journey home to blog to home', async ({ homePage, blogPage }) => { + await homePage.goto(); + expect(await homePage.isHeroBannerVisible()).toBe(true); + + await homePage.clickNavItem('Blog'); + expect(await blogPage.isBlogContainerVisible()).toBe(true); + + await blogPage.clickLogo(); + expect(await homePage.getCurrentUrl()).toMatch(/\/$|:8000\/?$/); + }); +}); diff --git a/e2e/utils/index.ts b/e2e/utils/index.ts new file mode 100644 index 0000000..f116a86 --- /dev/null +++ b/e2e/utils/index.ts @@ -0,0 +1 @@ +export { getAllLinks, LinkData, TIMEOUTS } from './test-helpers'; diff --git a/e2e/utils/test-helpers.ts b/e2e/utils/test-helpers.ts new file mode 100644 index 0000000..987a0bb --- /dev/null +++ b/e2e/utils/test-helpers.ts @@ -0,0 +1,21 @@ +import { Page } from '@playwright/test'; + +export interface LinkData { + text: string; + href: string; +} + +export async function getAllLinks(page: Page): Promise { + return await page.$$eval('a', (links) => + links.map((link) => ({ + text: link.textContent?.trim() || '', + href: link.getAttribute('href') || '', + })) + ); +} + +export const TIMEOUTS = { + SHORT: 3000, + MEDIUM: 5000, + LONG: 10000, +} as const; diff --git a/package-lock.json b/package-lock.json index 3fee068..7063f14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "typescript": "^4.6.4" }, "devDependencies": { + "@playwright/test": "^1.40.0", "@types/node": "^17.0.35", "@types/react": "^18.2.23", "@types/react-dom": "^18.2.4", @@ -3991,6 +3992,22 @@ "@parcel/core": "^2.8.3" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", @@ -14355,6 +14372,53 @@ "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==" }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/please-upgrade-node": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", @@ -15092,9 +15156,9 @@ "integrity": "sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA==" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/package.json b/package.json index 5d39622..4788f3a 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "gatsby-plugin-env-variables": "^2.3.0", "gatsby-plugin-image": "^3.12.0", "gatsby-plugin-manifest": "^5.12.0", - "gatsby-plugin-robots-txt": "^1.8.0", "gatsby-plugin-sharp": "^5.12.0", "gatsby-plugin-sitemap": "^6.12.0", @@ -45,7 +44,13 @@ "format": "prettier --write \"**/*.{js,css,html,jsx,ts,tsx,json,md}\"", "start": "npm run develop", "serve": "gatsby serve", - "clean": "gatsby clean" + "clean": "gatsby clean", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report playwright-report", + "test:e2e:prod": "BASE_URL=http://localhost:9000 playwright test" }, "repository": { "type": "git", @@ -55,12 +60,14 @@ "url": "https://github.com/contentstack/contentstack-gatsby-starter-app" }, "devDependencies": { + "@playwright/test": "^1.40.0", "@types/node": "^17.0.35", "@types/react": "^18.2.23", "@types/react-dom": "^18.2.4", "@types/react-helmet": "^6.1.7" }, "overrides": { + "axe-core": "^4.10.0", "axios": "^1.12.0", "@babel/helpers": "^7.26.10", "@babel/runtime": "^7.26.10", @@ -86,4 +93,4 @@ "tmp": "^0.2.4", "ws": "^8.17.1" } -} +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f499cc8 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from '@playwright/test'; +import * as path from 'path'; +import * as fs from 'fs'; + +const envFile = process.env.NODE_ENV === 'production' + ? '.env.production' + : '.env.development'; +const envPath = path.resolve(__dirname, envFile); + +if (fs.existsSync(envPath)) { + require('dotenv').config({ path: envPath }); +} + +const defaultUrl = process.env.NODE_ENV === 'production' + ? 'http://localhost:9000' + : 'http://localhost:8000'; + +export default defineConfig({ + testDir: './e2e/tests', + timeout: 30 * 1000, + expect: { timeout: 5000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 2, + workers: 1, + reporter: [ + ['list'], + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ], + use: { + baseURL: process.env.BASE_URL || defaultUrl, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + viewport: { width: 1280, height: 720 }, + ignoreHTTPSErrors: true, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + outputDir: 'test-results', +}); diff --git a/tsconfig.json b/tsconfig.json index 1d693b2..a4cb94e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "noEmit": true, "jsx": "react" }, - "include": ["src"] + "include": ["src", "e2e", "playwright.config.ts"] }