diff --git a/web/.env.example b/web/.env.example index 2ef5020335..06124122dc 100644 --- a/web/.env.example +++ b/web/.env.example @@ -6,4 +6,9 @@ REEARTH_WEB_AUTH0_AUDIENCE= REEARTH_WEB_AUTH0_CLIENT_ID= # Optional -REEARTH_WEB_CESIUM_ION_TOKEN_URL= \ No newline at end of file +REEARTH_WEB_CESIUM_ION_TOKEN_URL= + +# E2E for playwright +REEARTH_WEB_E2E_BASEURL= +REEARTH_WEB_E2E_ACCOUNT= +REEARTH_WEB_E2E_ACCOUNT_PASSWORD= diff --git a/web/.gitignore b/web/.gitignore index 9662ac8a30..8874f84189 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -14,3 +14,5 @@ __screenshots__ /cesium_ion_token.txt .idea/* .yarn/* +e2e/utils/.auth +e2e/testSnapshot diff --git a/web/e2e/README.md b/web/e2e/README.md new file mode 100644 index 0000000000..1ca39a4026 --- /dev/null +++ b/web/e2e/README.md @@ -0,0 +1,90 @@ +# Playwright E2E Testing Project + +## Project Overview + +This project is an end-to-end (E2E) testing suite built using [Playwright](https://playwright.dev/). It follows the Page Object Model (POM) design pattern to organize tests for various components of the `visualizer` module, including `dashboard`, `projectSetting`, and `editor`. + +## Project Structure + +web +├── e2e +│ ├── pom +│ │ ├── visualizer +│ │ │ ├── dashboard +│ │ │ │ ├── index.ts +│ │ │ │ └── ProjectsPage.ts +│ │ │ ├── projectSetting +│ │ │ │ ├── generalPage.ts +│ │ │ │ └── index.ts +│ │ │ ├── editor +│ │ │ │ ├── index.ts +│ │ │ │ └── MapPage.ts +│ │ └── index.ts +│ ├── utils +│ │ ├── .auth +│ │ ├── config.ts //old setting +│ │ ├── index.ts //old setting +│ │ ├── login.ts //old setting +│ │ ├── setup.ts //old setting +│ │ └── auth.setup.ts +│ ├── dashboard.spec.ts //old testing +│ └── test.spec.ts //testing file +└── README.md + +## Prerequisites + +- [Node.js](https://nodejs.org/) >= 14.x +- [npm](https://www.npmjs.com/) or [Yarn](https://yarnpkg.com/) + +## Setup + +### Install Dependencies + +To install the required packages, run the following commands: + +```bash +cd web +yarn install +npx playwright install //if needed +``` + +To set required .env for frontend. Recording following variable. + +```bash +# Local backend with Auth0 OSS tenant +REEARTH_WEB_E2E_BASEURL=http://localhost:3000 +REEARTH_WEB_E2E_ACCOUNT // your visualizer accounnt +REEARTH_WEB_E2E_ACCOUNT_PASSWORD // your visualizer password +``` + +## Running Tests + +First run api and frontend, And to run all tests: + +```bash +yarn e2e +``` + +## Configuration + +- utils/auth.setup.ts + +Contains the authentication setup process for the tests. You can define login flows here to ensure tests are executed under authenticated conditions. + +## Snapshots + +Visual regression testing snapshots will be storied in `e2e/__screenshots__` directory. Test will fail on first run if you don't have the snapshots locally. + +## Page Object Model (POM) + +The project follows the Page Object Model (POM) pattern, where each page is represented as an object. This improves code maintainability and reusability. + +Key Directories: + +- dashboard: Manages interactions with the dashboard page via ProjectsPage.ts. +- projectSetting: Handles interactions with the project settings page (generalPage.ts). +- editor: Contains methods for the editor page, including MapPage.ts. + +## Memo + +We need to improve the implementation later. diff --git a/web/e2e/auth.setup.ts b/web/e2e/auth.setup.ts new file mode 100644 index 0000000000..a93cc897e7 --- /dev/null +++ b/web/e2e/auth.setup.ts @@ -0,0 +1,40 @@ +// globalSetup.js +import path, { dirname } from "path"; +import { fileURLToPath } from "url"; + +import { chromium, expect } from "@reearth/e2e/utils"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const authFile = path.join(__dirname, "./utils/.auth/user.json"); + +export default async () => { + if ( + !process.env.REEARTH_WEB_E2E_ACCOUNT || + !process.env.REEARTH_WEB_E2E_ACCOUNT_PASSWORD + ) { + throw new Error( + "please setup .env for REEARTH_WEB_E2E_ACCOUNT and REEARTH_WEB_E2E_ACCOUNT_PASSWORD" + ); + } + + const browser = await chromium.launch(); + const page = await browser.newPage(); + await page.goto( + process.env.REEARTH_WEB_E2E_BASEURL || "http://localhost:3000/" + ); + await page + .getByPlaceholder("username/email") + .fill(process.env.REEARTH_WEB_E2E_ACCOUNT); + await page + .getByPlaceholder("your password") + .fill(process.env.REEARTH_WEB_E2E_ACCOUNT_PASSWORD); + await page.getByText("LOG IN").click(); + await page.waitForTimeout(10 * 1000); + await page.goto( + process.env.REEARTH_WEB_E2E_BASEURL || "http://localhost:3000/" + ); + await expect(page.getByRole("button", { name: "New Project" })).toBeVisible(); + await page.context().storageState({ path: authFile }); + await browser.close(); +}; diff --git a/web/e2e/dashboard.spec.ts b/web/e2e/dashboard.spec.ts index 32648eca4a..d9facebff7 100644 --- a/web/e2e/dashboard.spec.ts +++ b/web/e2e/dashboard.spec.ts @@ -1,8 +1,9 @@ -import { expect, test } from "@reearth/e2e/utils"; +// not in use +// import { expect, test } from "@reearth/e2e/utils"; -test("dasboard can be logged in", async ({ page, reearth }) => { - await reearth.initUser(); - await reearth.goto(`/dashboard/${reearth.teamId}`); +// test("dasboard can be logged in", async ({ page, reearth }) => { +// await reearth.initUser(); +// await reearth.goto(`/dashboard/${reearth.teamId}`); - await expect(page.getByText(`${reearth.userName}'s workspace`)).toBeVisible(); -}); +// await expect(page.getByText(`${reearth.userName}'s workspace`)).toBeVisible(); +// }); diff --git a/web/e2e/editorMapAddLayer.spec.ts b/web/e2e/editorMapAddLayer.spec.ts new file mode 100644 index 0000000000..3ad8372d4f --- /dev/null +++ b/web/e2e/editorMapAddLayer.spec.ts @@ -0,0 +1,64 @@ +import path, { dirname } from "path"; +import { fileURLToPath } from "url"; + +import { expect, test } from "@reearth/e2e/utils"; +import { v4 as uuidv4 } from "uuid"; + +import pom from "./pom"; + +//use session, also could set in playwright config +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const authFile = path.join(__dirname, "./utils/.auth/user.json"); +test.use({ storageState: authFile }); + +//test +test("create project and add layer", async ({ page }) => { + const url = process.env.REEARTH_WEB_E2E_BASEURL || "http://localhost:3000/"; + + await page.goto(url); + await page.waitForSelector("button:text('New Project')"); + + // create new project and get in + const dashboardPage = new pom.visualizer.dashboard.ProjectsPage(page); + const projectName = "playwright_test_" + uuidv4(); + await dashboardPage.createProject(projectName); + await page.waitForSelector(`div:text('${projectName}')`); + await dashboardPage.dbClickInProjectByName(projectName); + + // clocs sky box, add new layer + const mapPage = new pom.visualizer.editor.MapPage(page); + const layerTitle = "layer_" + uuidv4(); + await mapPage.closeSkyBox(); + await page.waitForTimeout(8 * 1000); + + // take screenshot + const canvas = page.locator("canvas"); + expect.soft(await canvas.screenshot()).toMatchSnapshot("before-action.png", { + maxDiffPixels: 100 + }); + + await mapPage.createNewLayerBySketch(layerTitle); + + //add circle + await page.getByText(layerTitle).click(); + await mapPage.circleButton.click(); + const { x, y } = await mapPage.getLocatorOfCanvs(); + await mapPage.createCircleToEarthByLocator(x, y); + + //wait result for stateble + await page.waitForTimeout(10 * 1000); + + expect + .soft(await canvas.screenshot()) + .not.toMatchSnapshot("before-action.png"); + expect.soft(await canvas.screenshot()).toMatchSnapshot("after-action.png"); + + //delete created project + await dashboardPage.goto(); + await dashboardPage.intoProjectSettingByName(projectName); + const generalSettingPage = new pom.visualizer.projectSetting.generalPage( + page + ); + await generalSettingPage.deleteProject(); +}); diff --git a/web/e2e/pom/index.ts b/web/e2e/pom/index.ts new file mode 100644 index 0000000000..ae6b8c132c --- /dev/null +++ b/web/e2e/pom/index.ts @@ -0,0 +1,5 @@ +import * as visualizer from "./visualizer"; + +export default { + visualizer +}; diff --git a/web/e2e/pom/visualizer/dashboard/ProjectsPage.ts b/web/e2e/pom/visualizer/dashboard/ProjectsPage.ts new file mode 100644 index 0000000000..dacd7b4fa2 --- /dev/null +++ b/web/e2e/pom/visualizer/dashboard/ProjectsPage.ts @@ -0,0 +1,39 @@ +import type { Page, Locator } from "@reearth/e2e/utils"; + +export default class ProjectsPage { + readonly page: Page; + readonly newProjectButton: Locator; + readonly projectNameInput: Locator; + readonly applyButton: Locator; + + constructor(page: Page) { + this.page = page; + this.newProjectButton = this.page.getByText("New Project"); + this.projectNameInput = this.page.getByPlaceholder("Text"); + this.applyButton = this.page.getByText("Apply"); + } + + async goto() { + await this.page.goto(`/`); + } + + async createProject(name: string) { + await this.newProjectButton.click(); + await this.projectNameInput.fill(name); + await this.applyButton.click(); + } + + async dbClickInProjectByName(name: string) { + await this.page + .locator(`div:has(> div > div > div:text('${name}') ) > :first-child`) + .dblclick(); + } + + async intoProjectSettingByName(name: string) { + await this.page + .locator(`div:has(> div > div:text('${name}')) button`) + .click(); + await this.page.getByText("Project Setting").click(); + await this.page.waitForSelector("p:text('Project Name')"); + } +} diff --git a/web/e2e/pom/visualizer/dashboard/index.ts b/web/e2e/pom/visualizer/dashboard/index.ts new file mode 100644 index 0000000000..a01a73a141 --- /dev/null +++ b/web/e2e/pom/visualizer/dashboard/index.ts @@ -0,0 +1,3 @@ +import ProjectsPage from "./ProjectsPage"; + +export { ProjectsPage }; diff --git a/web/e2e/pom/visualizer/editor/MapPage.ts b/web/e2e/pom/visualizer/editor/MapPage.ts new file mode 100644 index 0000000000..30f319f663 --- /dev/null +++ b/web/e2e/pom/visualizer/editor/MapPage.ts @@ -0,0 +1,58 @@ +import type { Page, Locator } from "@reearth/e2e/utils"; + +export default class MapPage { + readonly page: Page; + readonly newLayerButton: Locator; + readonly addSketchLayerButton: Locator; + readonly layerNameInput: Locator; + readonly createButton: Locator; + readonly circleButton: Locator; + readonly skyBoxSelectButton: Locator; + + constructor(page: Page) { + this.page = page; + this.newLayerButton = this.page.getByText("New Layer"); + this.addSketchLayerButton = this.page.getByText("Add Sketch Layer"); + this.layerNameInput = this.page.getByPlaceholder("Text"); + this.createButton = this.page.getByRole("button").getByText("Create"); + this.circleButton = this.page + .locator("div[direction='column'] > div[direction='row'] button") + .nth(2); + this.skyBoxSelectButton = this.page.locator( + "div:has(> div > p:text('Sky Box')) div:has(> p:text('Description needed.')) > div" + ); + } + + async goto() { + await this.page.goto(`/`); + } + + async createNewLayerBySketch(name: string) { + await this.page.waitForSelector("button:text('New Layer')"); + await this.newLayerButton.click(); + await this.addSketchLayerButton.click(); + await this.layerNameInput.fill(name); + await this.createButton.click(); + } + + async getLocatorOfCanvs() { + const box = await this.page.locator("canvas").boundingBox(); + if (!box) throw new Error("no canvas found"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + return { x, y }; + } + + async createCircleToEarthByLocator(x: number, y: number) { + await this.page.mouse.move(x, y); + await this.page.mouse.click(x, y, { button: "left" }); + await this.page.waitForTimeout(5 * 1000); + await this.page.mouse.move(x + 20, y + 20); + await this.page.mouse.click(x + 20, y + 20, { button: "left", delay: 500 }); + } + + async closeSkyBox() { + await this.page.getByText("Sky").click(); + await this.skyBoxSelectButton.click(); + } +} diff --git a/web/e2e/pom/visualizer/editor/index.ts b/web/e2e/pom/visualizer/editor/index.ts new file mode 100644 index 0000000000..b49a664f1c --- /dev/null +++ b/web/e2e/pom/visualizer/editor/index.ts @@ -0,0 +1,3 @@ +import MapPage from "./MapPage"; + +export { MapPage }; diff --git a/web/e2e/pom/visualizer/index.ts b/web/e2e/pom/visualizer/index.ts new file mode 100644 index 0000000000..64eb7e9f6b --- /dev/null +++ b/web/e2e/pom/visualizer/index.ts @@ -0,0 +1,5 @@ +import * as dashboard from "./dashboard"; +import * as editor from "./editor"; +import * as projectSetting from "./projectSetting"; + +export { dashboard, editor, projectSetting }; diff --git a/web/e2e/pom/visualizer/projectSetting/generalPage.ts b/web/e2e/pom/visualizer/projectSetting/generalPage.ts new file mode 100644 index 0000000000..03c6810387 --- /dev/null +++ b/web/e2e/pom/visualizer/projectSetting/generalPage.ts @@ -0,0 +1,28 @@ +import type { Page, Locator } from "@reearth/e2e/utils"; + +export default class generalPage { + readonly page: Page; + readonly projectNameElement: Locator; + + constructor(page: Page) { + this.page = page; + + this.projectNameElement = this.page + .locator( + "div:has( > p:text('Please type your project name to continue.')) > p" + ) + .first(); + } + + async deleteProject() { + await this.page.getByText("Delete project").click(); + const projectname = await this.projectNameElement.textContent(); + const projectNameInput = this.page.locator( + "div:has( > p:text('Please type your project name to continue.')) > div > input" + ); + await projectNameInput.fill(projectname as string); + await this.page + .getByText("I am sure I want to delete this project.") + .click(); + } +} diff --git a/web/e2e/pom/visualizer/projectSetting/index.ts b/web/e2e/pom/visualizer/projectSetting/index.ts new file mode 100644 index 0000000000..75575ef7c6 --- /dev/null +++ b/web/e2e/pom/visualizer/projectSetting/index.ts @@ -0,0 +1,3 @@ +import generalPage from "./generalPage"; + +export { generalPage }; diff --git a/web/e2e/utils/config.ts b/web/e2e/utils/config.ts index 1ad5d2cb43..9dcd3f2923 100644 --- a/web/e2e/utils/config.ts +++ b/web/e2e/utils/config.ts @@ -1,3 +1,4 @@ +//not in use export const config = { api: process.env["REEARTH_WEB_API"], userId: process.env["REEARTH_WEB_E2E_USER_ID"], diff --git a/web/e2e/utils/index.ts b/web/e2e/utils/index.ts index 56548dc917..346317ff1b 100644 --- a/web/e2e/utils/index.ts +++ b/web/e2e/utils/index.ts @@ -9,7 +9,7 @@ import { import { config, getAccessToken, type Config } from "./config"; // eslint-disable-next-line no-restricted-imports -export { expect } from "@playwright/test"; +export { expect, chromium, type Page, type Locator } from "@playwright/test"; export type Reearth = { initUser: () => Promise<{ diff --git a/web/e2e/utils/login.ts b/web/e2e/utils/login.ts index af596490e2..348c4ad275 100644 --- a/web/e2e/utils/login.ts +++ b/web/e2e/utils/login.ts @@ -1,3 +1,4 @@ +//not in use import axios from "axios"; import { config } from "./config"; diff --git a/web/e2e/utils/setup.ts b/web/e2e/utils/setup.ts index 47e7c198dd..404bc81ec8 100644 --- a/web/e2e/utils/setup.ts +++ b/web/e2e/utils/setup.ts @@ -1,3 +1,4 @@ +//not in use import { getAccessToken, setAccessToken } from "./config"; import { login } from "./login"; diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 34928bd65c..f012a42e8c 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,17 +1,36 @@ -import { type PlaywrightTestConfig } from "@playwright/test"; +import { devices, type PlaywrightTestConfig } from "@playwright/test"; import dotenv from "dotenv"; dotenv.config(); const config: PlaywrightTestConfig = { + timeout: 0, use: { baseURL: process.env.REEARTH_WEB_E2E_BASEURL || "http://localhost:3000/", screenshot: "only-on-failure", - video: "retain-on-failure" + video: "retain-on-failure", + headless: false }, testDir: "e2e", - globalSetup: "./e2e/utils/setup.ts", - reporter: process.env.CI ? "github" : "list" + snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}", + // globalSetup: "./e2e/utils/setup.ts", //old way + globalSetup: "./e2e/auth.setup.ts", + reporter: process.env.CI ? "github" : "list", + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + headless: false, + screenshot: "on", + video: "on", + launchOptions: { + // args: ["--headless","--no-sandbox","--use-angle=gl"] + args: ["--no-sandbox"] + } + } + } + ] }; export default config;