diff --git a/.eslintrc b/.eslintrc index 4134d7b18..51f224efd 100644 --- a/.eslintrc +++ b/.eslintrc @@ -39,6 +39,7 @@ "ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features "sourceType": "module" // Allows for the use of imports }, + "ignorePatterns": ["/e2e"], "rules": { "prettier/prettier": [ 2, @@ -113,6 +114,28 @@ "parserOptions": { "project": ["./tsconfig.json"] } + }, + { + "files": ["e2e/**/*.ts", "e2e/**/*.tsx"], + "extends": [ + "eslint:recommended", + "plugin:playwright/playwright-test" + ], + "rules": { + "prettier/prettier": [ + 2, + { + "semi": true, + "singleQuote": true, + "jsxSingleQuote": true, + "parser": "flow" + } + ], + "import-order": 2, + "semi": [2, "always"], + "no-extra-semi": 2, + "semi-spacing": [2, { "before": false, "after": true }] + } } ] } diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..cd15371e2 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,79 @@ +name: Playwright Tests +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + +env: + NODE: 16 + +jobs: + prep: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + with: + access_token: ${{ github.token }} + + - name: Checkout + uses: actions/checkout@v3 + + - name: Use Node.js ${{ env.NODE }} + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE }} + + - name: Cache node_modules + uses: actions/cache@v3 + id: cache-node-modules + with: + path: node_modules + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} + + - name: Install + run: yarn + + playwright: + timeout-minutes: 60 + needs: prep + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Use Node.js ${{ env.NODE }} + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE }} + + - name: Cache node_modules + uses: actions/cache@v3 + id: cache-node-modules + with: + path: node_modules + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} + + - name: Install + run: yarn install + + - name: Install Playwright Browsers + run: yarn playwright install --with-deps + + - name: Playwright tests + env: + MAPBOX_TOKEN: ${{secrets.MAPBOX_TOKEN}} + run: yarn test:e2e + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + diff --git a/.gitignore b/.gitignore index 12fd79316..a0fcef3c8 100644 --- a/.gitignore +++ b/.gitignore @@ -72,4 +72,9 @@ nbproject temp tmp .tmp -dist \ No newline at end of file +dist +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/e2e/playwrightTestData.json \ No newline at end of file diff --git a/app/scripts/components/analysis/define/index.tsx b/app/scripts/components/analysis/define/index.tsx index 6cc845f54..b65449915 100644 --- a/app/scripts/components/analysis/define/index.tsx +++ b/app/scripts/components/analysis/define/index.tsx @@ -463,7 +463,7 @@ export default function Analysis() { {!infoboxMessage ? ( <> -
+ {selectableDatasetLayers.map((datasetLayer) => ( {!!requestStatus.length && availableDomain && brushRange && ( - + {requestStatus.map((l) => (
  • { + if (err) { + // eslint-disable-next-line no-console + console.error(err); + throw err; + } else { + // eslint-disable-next-line no-console + console.info('new test data file generated'); + } + } +); diff --git a/e2e/pages/aboutPage.ts b/e2e/pages/aboutPage.ts new file mode 100644 index 000000000..a9e84eac3 --- /dev/null +++ b/e2e/pages/aboutPage.ts @@ -0,0 +1,11 @@ +import { Locator, Page } from '@playwright/test'; + +export default class AboutPage { + readonly page: Page; + readonly mainContent: Locator; + + constructor(page: Page) { + this.page = page; + this.mainContent = this.page.getByRole('main'); + } +} \ No newline at end of file diff --git a/e2e/pages/analysisPage.ts b/e2e/pages/analysisPage.ts new file mode 100644 index 000000000..37d5d03fb --- /dev/null +++ b/e2e/pages/analysisPage.ts @@ -0,0 +1,59 @@ +import { Locator, Page, test } from '@playwright/test'; + +export default class AnalysisPage { + readonly page: Page; + readonly mainContent: Locator; + readonly header: Locator; + readonly mapboxCanvas: Locator; + readonly generateAnalysisButton: Locator; + readonly datasetOptions: Locator; + readonly datasetCheckbox: Locator; + readonly moreOptionsButton: Locator; + readonly northAmericaOption: Locator; + + + constructor(page: Page) { + this.page = page; + this.mainContent = this.page.getByRole('main'); + this.header = this.mainContent.getByRole('heading', {level: 1, name: /analysis/i }); + this.mapboxCanvas = this.page.getByLabel('Map', { exact: true }); + this.generateAnalysisButton = this.page.getByRole('link', { name: /Generate analysis/i }); + this.datasetOptions = this.page.getByTestId('datasetOptions'); + this.datasetCheckbox = this.datasetOptions.getByRole('checkbox'); + this.moreOptionsButton = this.page.getByRole('button', {name: /more options/i }); + this.northAmericaOption = this.page.getByRole('button', {name: /north america/i }); + } + + async drawPolygon (polygonCorners: number[][]) { + await test.step('draw polygon on mapbox canvas box', async () => { + if(polygonCorners.length < 3) { + throw new Error('polygon in drawPolygon must have >=3 corners') + } + // mutating corners array to have all but the final corner + const finalCorner = polygonCorners.pop()|| []; + + // single click each remaining corner + for (const corner of polygonCorners) { + await this.mapboxCanvas.click({ + position: { + x: corner[0], + y: corner[1] + } + }); + } + // double click on final corner + await this.mapboxCanvas.dblclick({ + position: { + x: finalCorner[0], + y: finalCorner[1] + } + }); + }) + } + + async clickDatasetOption (index: number) { + test.step(`clicking dataset number ${index}`, async () => { + this.datasetCheckbox.nth(index).locator('..').click(); + }) + } +} \ No newline at end of file diff --git a/e2e/pages/analysisResultsPage.ts b/e2e/pages/analysisResultsPage.ts new file mode 100644 index 000000000..b018735d7 --- /dev/null +++ b/e2e/pages/analysisResultsPage.ts @@ -0,0 +1,13 @@ +import { Locator, Page } from '@playwright/test'; + +export default class AnalysisResultsPage { + readonly page: Page; + readonly analysisCards: Locator; + + + constructor(page: Page) { + this.page = page; + this.analysisCards = this.page.getByTestId('analysisCards'); + } + +} \ No newline at end of file diff --git a/e2e/pages/basePage.ts b/e2e/pages/basePage.ts new file mode 100644 index 000000000..e869df0ed --- /dev/null +++ b/e2e/pages/basePage.ts @@ -0,0 +1,57 @@ +import { test as base } from '@playwright/test'; +import AboutPage from './aboutPage'; +import AnalysisPage from './analysisPage'; +import AnalysisResultsPage from './analysisResultsPage'; +import ExplorePage from './explorePage'; +import FooterComponent from './footerComponent'; +import HeaderComponent from './headerComponent'; +import HomePage from './homePage'; +import CatalogPage from './catalogPage'; +import DatasetPage from './datasetPage'; +import StoryPage from './storyPage'; + +export const test = base.extend<{ + aboutPage: AboutPage; + analysisPage: AnalysisPage; + analysisResultsPage: AnalysisResultsPage; + footerComponent: FooterComponent; + explorePage: ExplorePage; + headerComponent: HeaderComponent; + homePage: HomePage; + catalogPage: CatalogPage; + datasetPage: DatasetPage; + storyPage: StoryPage +}> ({ + aboutPage: async ({page}, use) => { + await use(new AboutPage(page)); + }, + analysisPage: async ({page}, use) => { + await use(new AnalysisPage(page)); + }, + analysisResultsPage: async ({page}, use) => { + await use(new AnalysisResultsPage(page)); + }, + catalogPage: async ({page}, use) => { + await use(new CatalogPage(page)); + }, + datasetPage: async ({page}, use) => { + await use(new DatasetPage(page)); + }, + explorePage: async ({page}, use) => { + await use(new ExplorePage(page)); + }, + homePage: async ({page}, use) => { + await use(new HomePage(page)); + }, + storyPage: async ({page}, use) => { + await use(new StoryPage(page)); + }, + headerComponent: async ({page}, use) => { + await use(new HeaderComponent(page)); + }, + footerComponent: async ({page}, use) => { + await use(new FooterComponent(page)); + }, +}); + +export const expect = test.expect; \ No newline at end of file diff --git a/e2e/pages/catalogPage.ts b/e2e/pages/catalogPage.ts new file mode 100644 index 000000000..58abdc029 --- /dev/null +++ b/e2e/pages/catalogPage.ts @@ -0,0 +1,14 @@ +import { Locator, Page } from '@playwright/test'; + +export default class CatalogPage { + readonly page: Page; + readonly mainContent: Locator; + readonly header: Locator; + + + constructor(page: Page) { + this.page = page; + this.mainContent = this.page.getByRole('main'); + this.header = this.mainContent.getByRole('heading', {level: 1, name: /data catalog/i}); + } +} \ No newline at end of file diff --git a/e2e/pages/datasetPage.ts b/e2e/pages/datasetPage.ts new file mode 100644 index 000000000..75386bca9 --- /dev/null +++ b/e2e/pages/datasetPage.ts @@ -0,0 +1,18 @@ +import { Locator, Page } from '@playwright/test'; + +export default class DatasetPage { + readonly page: Page; + readonly mainContent: Locator; + readonly header: Locator; + readonly exploreDataButton: Locator; + readonly analyzeDataButton: Locator; + + + constructor(page: Page) { + this.page = page; + this.mainContent = this.page.getByRole('main'); + this.header = this.mainContent.getByRole('heading', { level: 1 }) + this.exploreDataButton = this.page.getByRole('link', {name: /explore data/i} ); + this.analyzeDataButton = this.page.getByRole('button', {name: /analyze data/i} ); + } +} \ No newline at end of file diff --git a/e2e/pages/explorePage.ts b/e2e/pages/explorePage.ts new file mode 100644 index 000000000..303c6d295 --- /dev/null +++ b/e2e/pages/explorePage.ts @@ -0,0 +1,11 @@ +import { Locator, Page } from '@playwright/test'; + +export default class ExplorePage { + readonly page: Page; + readonly layersHeading: Locator; + + constructor(page: Page) { + this.page = page; + this.layersHeading = this.page.getByRole('heading', { name: 'Layers' }); + } +} \ No newline at end of file diff --git a/e2e/pages/footerComponent.ts b/e2e/pages/footerComponent.ts new file mode 100644 index 000000000..90d5bdf8d --- /dev/null +++ b/e2e/pages/footerComponent.ts @@ -0,0 +1,13 @@ +import { Locator, Page } from '@playwright/test'; + +export default class FooterComponent { + readonly page: Page; + readonly footer: Locator; + readonly partners: Locator; + + constructor(page: Page) { + this.page = page; + this.footer = this.page.locator('footer'); + this.partners = this.footer.locator('div'); + } +} \ No newline at end of file diff --git a/e2e/pages/headerComponent.ts b/e2e/pages/headerComponent.ts new file mode 100644 index 000000000..96808702a --- /dev/null +++ b/e2e/pages/headerComponent.ts @@ -0,0 +1,23 @@ +import { Locator, Page } from '@playwright/test'; + +export default class HeaderComponent { + readonly page: Page; + readonly header: Locator; + readonly welcomeLink: Locator; + readonly dataCatalogLink: Locator; + readonly analysisLink: Locator; + readonly dataInsightsLink: Locator; + readonly aboutLink: Locator; + readonly feedbackLink: Locator; + + constructor(page: Page) { + this.page = page; + this.header = this.page.getByRole('navigation'); + this.welcomeLink = this.header.getByRole('link', {name: /welcome/i}); + this.dataCatalogLink = this.header.getByRole('link', {name: / data catalog/i}); + this.analysisLink = this.header.getByRole('link', {name: /analysis/i}); + this.dataInsightsLink = this.header.getByRole('link', {name: /data insights/i}); + this.aboutLink = this.header.getByRole('link', {name: /about/i}); + this.feedbackLink = this.header.getByRole('link', {name: /feedback/i}); + } +} \ No newline at end of file diff --git a/e2e/pages/homePage.ts b/e2e/pages/homePage.ts new file mode 100644 index 000000000..5fa13895e --- /dev/null +++ b/e2e/pages/homePage.ts @@ -0,0 +1,14 @@ +import { Locator, Page } from '@playwright/test'; + +export default class HomePage { + readonly page: Page; + readonly mainContent: Locator; + readonly headingContainer: Locator; + + + constructor(page: Page) { + this.page = page; + this.mainContent = this.page.getByRole('main'); + this.headingContainer = this.mainContent.locator('div').filter({ hasText: 'U.S. Greenhouse Gas CenterUniting Data and Technology to Empower Tomorrow\'s' }).nth(2) + } +} \ No newline at end of file diff --git a/e2e/pages/storyPage.ts b/e2e/pages/storyPage.ts new file mode 100644 index 000000000..358b2caa2 --- /dev/null +++ b/e2e/pages/storyPage.ts @@ -0,0 +1,14 @@ +import { Locator, Page } from '@playwright/test'; + +export default class StoryPage { + readonly page: Page; + readonly mainContent: Locator; + readonly header: Locator; + + + constructor(page: Page) { + this.page = page; + this.mainContent = this.page.getByRole('main'); + this.header = this.mainContent.getByRole('heading', {level: 1}) + } +} \ No newline at end of file diff --git a/e2e/tests/analysis.spec.ts b/e2e/tests/analysis.spec.ts new file mode 100644 index 000000000..7a6213c3e --- /dev/null +++ b/e2e/tests/analysis.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '../pages/basePage'; + +test('generate analysis with polygon', async ({ + page, + analysisPage, + analysisResultsPage, + }) => { + let pageErrorCalled = false; + // Log all uncaught errors to the terminal to be visible in trace + page.on('pageerror', exception => { + console.log(`Uncaught exception: "${JSON.stringify(exception)}"`); + pageErrorCalled = true; + }); + + const mapboxResponsePromise = page.waitForResponse(/api\.mapbox.com\/v4\/mapbox\.mapbox-streets-v8/i); + await page.goto('/analysis'); + await expect(analysisPage.header, `analysis page should load`).toBeVisible(); + const mapboxResponse = await mapboxResponsePromise; + expect(mapboxResponse.ok(), 'mapbox request should be successful').toBeTruthy(); + await expect(analysisPage.mapboxCanvas, 'mapbox canvas should be visible').toBeVisible(); + + const box = await analysisPage.mapboxCanvas.boundingBox(); + + // using Non-null Assertion because we know the mapbox is visible, therefore box is not null + const firstCorner = [box!.width * 0.2, box!.height * 0.2]; + const secondCorner = [box!.width * 0.8, box!.height * 0.2]; + const thirdCorner = [box!.width * 0.8, box!.height * 0.8]; + const fourthCorner = [box!.width * 0.2, box!.height * 0.8]; + + + await analysisPage.mapboxCanvas.click(); + + await analysisPage.drawPolygon([firstCorner, secondCorner, thirdCorner, fourthCorner]) + + await analysisPage.clickDatasetOption(1); + + const searchResponsePromise = page.waitForResponse(/\/search/i); + await analysisPage.generateAnalysisButton.click({force: true }); + + + const searchResponse = await searchResponsePromise; + expect(searchResponse.ok(), 'request to GET /search should be successful').toBeTruthy(); + + await expect(analysisResultsPage.analysisCards.first(), 'at least one analysis results is visible' ).toBeVisible(); + + // scroll page to bottom + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + expect(pageErrorCalled, 'no javascript exceptions thrown on page').toBe(false); + +}); + + +test('generate analysis with region select', async ({ + page, + analysisPage, + analysisResultsPage, + }) => { + let pageErrorCalled = false; + // Log all uncaught errors to the terminal to be visible in trace + page.on('pageerror', exception => { + console.log(`Uncaught exception: "${JSON.stringify(exception)}"`); + pageErrorCalled = true; + }); + + const mapboxResponsePromise = page.waitForResponse(/api\.mapbox.com\/v4\/mapbox\.mapbox-streets-v8/i); + await page.goto('/analysis'); + await expect(analysisPage.header, `analysis page should load`).toBeVisible(); + const mapboxResponse = await mapboxResponsePromise; + expect(mapboxResponse.ok(), 'mapbox request should be successful').toBeTruthy(); + await expect(analysisPage.mapboxCanvas, 'mapbox canvas should be visible').toBeVisible(); + + await analysisPage.moreOptionsButton.click(); + await analysisPage.northAmericaOption.click(); + + await analysisPage.clickDatasetOption(1); + + const searchResponsePromise = page.waitForResponse(/\/search/i); + await analysisPage.generateAnalysisButton.click({force: true }); + + + const searchResponse = await searchResponsePromise; + expect(searchResponse.ok(), 'request to GET /search should be successful').toBeTruthy(); + + await expect(analysisResultsPage.analysisCards.first(), 'at least one analysis results is visible' ).toBeVisible(); + + expect(pageErrorCalled, 'no javascript exceptions thrown on page').toBe(false); + +}); \ No newline at end of file diff --git a/e2e/tests/catalog.spec.ts b/e2e/tests/catalog.spec.ts new file mode 100644 index 000000000..bb7db8474 --- /dev/null +++ b/e2e/tests/catalog.spec.ts @@ -0,0 +1,27 @@ +import fs from 'fs'; +import { test, expect } from '../pages/basePage'; + +const catalogs = JSON.parse(fs.readFileSync('e2e/playwrightTestData.json', 'utf8')).catalogs; + +test('load catalogs on /data-catalog route', async ({ + page, + catalogPage, + }) => { + let pageErrorCalled = false; + // Log all uncaught errors to the terminal to be visible in trace + page.on('pageerror', exception => { + console.log(`Uncaught exception: "${JSON.stringify(exception)}"`); + pageErrorCalled = true; + }); + + await page.goto('/data-catalog'); + await expect(catalogPage.header, 'catalog page should load').toBeVisible(); + + for (const item of catalogs) { + const catalogCard = catalogPage.mainContent.getByRole('article').getByRole('heading', { level: 3, name: item, exact: true}).last(); + await catalogCard.scrollIntoViewIfNeeded(); + await expect(catalogCard, `${item} catalog card should load`).toBeVisible(); + } + + expect(pageErrorCalled, 'no javascript exceptions thrown on page').toBe(false); +}); diff --git a/e2e/tests/catalogRouting.spec.ts b/e2e/tests/catalogRouting.spec.ts new file mode 100644 index 000000000..2a1063bb4 --- /dev/null +++ b/e2e/tests/catalogRouting.spec.ts @@ -0,0 +1,36 @@ +import fs from 'fs'; +import { test, expect } from '../pages/basePage'; + +const catalogs = JSON.parse(fs.readFileSync('e2e/playwrightTestData.json', 'utf8')).catalogs; + +test.describe('catalog card routing', () => { + for (const item of catalogs) { + test(`${item} routes to dataset details page`, async({ + page, + catalogPage, + datasetPage, + }) => { + let pageErrorCalled = false; + // Log all uncaught errors to the terminal to be visible in trace + page.on('pageerror', exception => { + console.log(`Uncaught exception: "${JSON.stringify(exception)}"`); + pageErrorCalled = true; + }); + + await page.goto('/data-catalog'); + await expect(catalogPage.header, `catalog page should load`).toHaveText(/data catalog/i); + + const catalogCard = catalogPage.mainContent.getByRole('article').getByRole('heading', { level: 3, name: item, exact: true}).first(); + await catalogCard.scrollIntoViewIfNeeded(); + await catalogCard.click({force: true}); + + await expect(datasetPage.header.filter({ hasText: item}), `${item} page should load`).toBeVisible(); + + // scroll page to bottom + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + expect(pageErrorCalled, 'no javascript exceptions thrown on page').toBe(false); + }); + } + +}); \ No newline at end of file diff --git a/e2e/tests/exploreDatasets.spec.ts b/e2e/tests/exploreDatasets.spec.ts new file mode 100644 index 000000000..f544f07c2 --- /dev/null +++ b/e2e/tests/exploreDatasets.spec.ts @@ -0,0 +1,37 @@ +import fs from 'fs'; +import { test, expect } from '../pages/basePage'; + +const datasetIds = JSON.parse(fs.readFileSync('e2e/playwrightTestData.json', 'utf8')).datasetIds; + +test.describe('explore dataset', () => { + for (const dataset of datasetIds) { + test(`${dataset} explore page functions`, async({ + page, + explorePage, + }) => { + let pageErrorCalled = false; + // Log all uncaught errors to the terminal to be visible in trace + page.on('pageerror', exception => { + console.log(`Uncaught exception: "${JSON.stringify(exception)}"`); + pageErrorCalled = true; + }); + + //mosaic isn't hit on all datasets + const collectionsResponsePromise = page.waitForResponse(response => + response.url().includes('collections') && response.status() === 200 + ); + + await page.goto(`data-catalog/${dataset}/explore`); + await expect(explorePage.layersHeading).toBeVisible(); + + const mosaicResponse = await collectionsResponsePromise; + expect(mosaicResponse.ok(), 'mapbox request should be successful').toBeTruthy(); + + // scroll page to bottom + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + expect(pageErrorCalled, 'no javascript exceptions thrown on page').toBe(false); + }); + } + +}); \ No newline at end of file diff --git a/e2e/tests/stories.spec.ts b/e2e/tests/stories.spec.ts new file mode 100644 index 000000000..527cda2b0 --- /dev/null +++ b/e2e/tests/stories.spec.ts @@ -0,0 +1,29 @@ +import fs from 'fs'; +import { test, expect } from '../pages/basePage'; + +const stories = JSON.parse(fs.readFileSync('e2e/playwrightTestData.json', 'utf8')).stories; + +test('load stories on /stories route', async ({ + page, + storyPage, + }) => { + + let pageErrorCalled = false; + // Log all uncaught errors to the terminal + page.on('pageerror', exception => { + console.log(`Uncaught exception: "${JSON.stringify(exception)}"`); + pageErrorCalled = true; + }); + + await page.goto('/stories'); + await expect(storyPage.header, `data stories page should load`).toHaveText(/stories/i); + + for (const item of stories) { + const storiesCard = storyPage.mainContent.getByRole('article').getByRole('heading', { level: 3, name: item, exact: true}).first(); + await storiesCard.scrollIntoViewIfNeeded(); + await expect(storiesCard, `${item} story card should load`).toBeVisible(); + } + + expect(pageErrorCalled, 'no javascript exceptions thrown on page').toBe(false); + +}); \ No newline at end of file diff --git a/e2e/tests/storiesRouting.spec.ts b/e2e/tests/storiesRouting.spec.ts new file mode 100644 index 000000000..b72e52d7c --- /dev/null +++ b/e2e/tests/storiesRouting.spec.ts @@ -0,0 +1,34 @@ +import fs from 'fs'; +import { test, expect } from '../pages/basePage'; + +const stories = JSON.parse(fs.readFileSync('e2e/playwrightTestData.json', 'utf8')).stories; + +test.describe('stories card routing', () => { + for (const item of stories) { + test(`${item} routes to dataset details page`, async({ + page, + storyPage, + datasetPage, + }) => { + let pageErrorCalled = false; + // Log all uncaught errors to the terminal to be visible in trace + page.on('pageerror', exception => { + console.log(`Uncaught exception: "${JSON.stringify(exception)}"`); + pageErrorCalled = true; + }); + + await page.goto('/stories'); + await expect(storyPage.header, `stories page should load`).toHaveText(/stories/i); + + const storyCard = storyPage.mainContent.getByRole('article').getByRole('heading', { level: 3, name: item, exact: true}).last(); + await storyCard.scrollIntoViewIfNeeded(); + await storyCard.click({force: true}); + await expect(datasetPage.header.filter({ hasText: item}), `${item} page should load`).toBeVisible(); + + // scroll page to bottom + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + expect(pageErrorCalled, 'no javascript exceptions thrown on page').toBe(false); + }); + } + +}); \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index df14e6ba1..9b5b091ee 100644 --- a/jest.config.js +++ b/jest.config.js @@ -166,9 +166,7 @@ module.exports = { // ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "/node_modules/" - // ], + testPathIgnorePatterns: ['/node_modules/', '/e2e/'], // The regexp pattern or array of patterns that Jest uses to detect test files // testRegex: [], diff --git a/package.json b/package.json index 1a254532e..615d75ddc 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "lint:scripts": "eslint app/scripts/", "lint:css": "stylelint 'app/styles/**/**' 'app/scripts/**/*.(js|ts|tsx|jsx)'", "ts-check": "yarn tsc --noEmit --skipLibCheck", - "test": "jest" + "test": "jest", + "pretest:e2e": "node e2e/generateMockTestData.js", + "test:e2e": "yarn playwright test" }, "engines": { "node": "16.x" @@ -43,10 +45,12 @@ "@parcel/resolver-glob": "^2.0.1", "@parcel/transformer-mdx": "^2.0.1", "@parcel/transformer-webmanifest": "2.7.0", + "@playwright/test": "^1.41.2", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.2", "@types/d3": "^7.4.0", "@types/mapbox-gl": "^2.7.5", + "@types/node": "^20.11.17", "@typescript-eslint/eslint-plugin": "^5.12.0", "@typescript-eslint/parser": "^5.12.0", "babel-jest": "^28.1.3", @@ -61,12 +65,13 @@ "eslint-plugin-import": "^2.26.0", "eslint-plugin-inclusive-language": "^2.1.1", "eslint-plugin-jest": "^26.1.1", + "eslint-plugin-playwright": "^1.0.1", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-react": "^7.26.1", "eslint-plugin-react-hooks": "^4.2.0", "events": "^3.3.0", "fancy-log": "^1.3.3", - "fast-glob": "^3.2.7", + "fast-glob": "^3.3.2", "fs-extra": "^10.0.0", "gray-matter": "^4.0.3", "gulp": "^4.0.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..08dd56875 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,58 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + // Global single test timeout + timeout: 300000, + // For expect calls + expect: { + timeout: 180000, + }, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : 3, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:9000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + // uncomment to also run tests in Firefox and webkit + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'yarn serve', + url: 'http://localhost:9000', + reuseExistingServer: !process.env.CI, + }, +}); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 0ff177dd1..75f135a00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3138,6 +3138,13 @@ chrome-trace-event "^1.0.2" nullthrows "^1.1.1" +"@playwright/test@^1.41.2": + version "1.41.2" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.41.2.tgz#bd9db40177f8fd442e16e14e0389d23751cdfc54" + integrity sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg== + dependencies: + playwright "1.41.2" + "@popperjs/core@^2.9.0": version "2.11.5" resolved "http://verdaccio.ds.io:4873/@popperjs%2fcore/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64" @@ -4028,6 +4035,13 @@ resolved "http://verdaccio.ds.io:4873/@types%2fnode/-/node-18.0.4.tgz#48aedbf35efb3af1248e4cd4d792c730290cd5d6" integrity sha512-M0+G6V0Y4YV8cqzHssZpaNCqvYwlCiulmm0PwpNLF55r/+cT8Ol42CHRU1SEaYFH2rTwiiE1aYg/2g2rrtGdPA== +"@types/node@^20.11.17": + version "20.11.17" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.17.tgz#cdd642d0e62ef3a861f88ddbc2b61e32578a9292" + integrity sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw== + dependencies: + undici-types "~5.26.4" + "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "http://verdaccio.ds.io:4873/@types%2fnormalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" @@ -6560,6 +6574,13 @@ eslint-plugin-jest@^26.1.1: dependencies: "@typescript-eslint/utils" "^5.10.0" +eslint-plugin-playwright@^1.0.1: + version "1.0.1" + resolved "http://verdaccio.ds.io:4873/eslint-plugin-playwright/-/eslint-plugin-playwright-1.0.1.tgz#0c010ec380cc89a53228e728dc3e502b4afffed0" + integrity sha512-fq3oIZSE7/aHry2gaZRXGYwSkKDkoV+PKyQfyKmtMg4Gt4vQ41dZc4qBPd6VrdXLAflbL1i5KEl3bF9x1UFbVw== + dependencies: + globals "^13.23.0" + eslint-plugin-prettier@^4.0.0: version "4.2.1" resolved "http://verdaccio.ds.io:4873/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b" @@ -6902,7 +6923,7 @@ fast-equals@^2.0.0: resolved "http://verdaccio.ds.io:4873/fast-equals/-/fast-equals-2.0.4.tgz#3add9410585e2d7364c2deeb6a707beadb24b927" integrity sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w== -fast-glob@^3.2.5, fast-glob@^3.2.7, fast-glob@^3.2.9: +fast-glob@^3.2.5, fast-glob@^3.2.9: version "3.2.11" resolved "http://verdaccio.ds.io:4873/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew== @@ -6913,6 +6934,17 @@ fast-glob@^3.2.5, fast-glob@^3.2.7, fast-glob@^3.2.9: merge2 "^1.3.0" micromatch "^4.0.4" +fast-glob@^3.3.2: + version "3.3.2" + resolved "http://verdaccio.ds.io:4873/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "http://verdaccio.ds.io:4873/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" @@ -7142,6 +7174,11 @@ fs.realpath@^1.0.0: resolved "http://verdaccio.ds.io:4873/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@2.3.2, fsevents@^2.3.2: + version "2.3.2" + resolved "http://verdaccio.ds.io:4873/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@^1.2.7: version "1.2.13" resolved "http://verdaccio.ds.io:4873/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" @@ -7150,11 +7187,6 @@ fsevents@^1.2.7: bindings "^1.5.0" nan "^2.12.1" -fsevents@^2.3.2: - version "2.3.2" - resolved "http://verdaccio.ds.io:4873/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - function-bind@^1.1.1: version "1.1.1" resolved "http://verdaccio.ds.io:4873/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -7408,6 +7440,13 @@ globals@^13.15.0, globals@^13.2.0: dependencies: type-fest "^0.20.2" +globals@^13.23.0: + version "13.24.0" + resolved "http://verdaccio.ds.io:4873/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== + dependencies: + type-fest "^0.20.2" + globby@^11.0.1, globby@^11.0.3, globby@^11.1.0: version "11.1.0" resolved "http://verdaccio.ds.io:4873/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" @@ -10996,6 +11035,20 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +playwright-core@1.41.2: + version "1.41.2" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.41.2.tgz#db22372c708926c697acc261f0ef8406606802d9" + integrity sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA== + +playwright@1.41.2: + version "1.41.2" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.41.2.tgz#4e760b1c79f33d9129a8c65cc27953be6dd35042" + integrity sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A== + dependencies: + playwright-core "1.41.2" + optionalDependencies: + fsevents "2.3.2" + polished@^3.4.2: version "3.7.2" resolved "http://verdaccio.ds.io:4873/polished/-/polished-3.7.2.tgz#ec5ddc17a7d322a574d5e10ddd2a6f01d3e767d1" @@ -13314,6 +13367,11 @@ undertaker@^1.2.1: object.reduce "^1.0.0" undertaker-registry "^1.0.0" +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + unherit@^1.0.4: version "1.1.3" resolved "http://verdaccio.ds.io:4873/unherit/-/unherit-1.1.3.tgz#6c9b503f2b41b262330c80e91c8614abdaa69c22"