From cdd9f12d876d005821cea7f3d420393fd09f222d Mon Sep 17 00:00:00 2001 From: mpsalunggg Date: Tue, 16 Sep 2025 19:55:13 +0700 Subject: [PATCH 1/9] chore: adjust calendar disabledfor form events --- src/features/events/components/EventForm.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/features/events/components/EventForm.tsx b/src/features/events/components/EventForm.tsx index 01b2531..40dc748 100644 --- a/src/features/events/components/EventForm.tsx +++ b/src/features/events/components/EventForm.tsx @@ -318,7 +318,11 @@ const EventForm = ({ onSubmit, isLoading = false, initialData, mode = "create" } mode="single" selected={field.value ? new Date(field.value) : undefined} onSelect={(date) => field.onChange(date ? date.toISOString() : "")} - disabled={(date) => date < new Date()} + disabled={(date) => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + return date < yesterday; + }} /> @@ -351,7 +355,11 @@ const EventForm = ({ onSubmit, isLoading = false, initialData, mode = "create" } mode="single" selected={field.value ? new Date(field.value) : undefined} onSelect={(date) => field.onChange(date ? date.toISOString() : "")} - disabled={(date) => date < new Date()} + disabled={(date) => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + return date < yesterday; + }} /> @@ -383,7 +391,11 @@ const EventForm = ({ onSubmit, isLoading = false, initialData, mode = "create" } mode="single" selected={field.value ? new Date(field.value) : undefined} onSelect={(date) => field.onChange(date ? date.toISOString() : "")} - disabled={(date) => date < new Date()} + disabled={(date) => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + return date < yesterday; + }} /> From fa2f81b9c8e9e60dfa04514c604c86de4707f38f Mon Sep 17 00:00:00 2001 From: mpsalunggg Date: Tue, 16 Sep 2025 20:49:49 +0700 Subject: [PATCH 2/9] chore: change image to image_event --- src/domains/Events.ts | 2 +- src/features/events/components/EventCardV2.tsx | 4 ++-- src/features/events/components/MyEventCard.tsx | 4 ++-- src/features/events/pages/PublicEventDetailPage.tsx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/domains/Events.ts b/src/domains/Events.ts index 1679272..4b34743 100644 --- a/src/domains/Events.ts +++ b/src/domains/Events.ts @@ -6,7 +6,7 @@ export const eventSchema = z.object({ description: z.string(), slug: z.string().optional(), author: z.string(), - image: z.string(), + image_event: z.string(), date: z.string().optional(), type: z.string(), location: z.string(), diff --git a/src/features/events/components/EventCardV2.tsx b/src/features/events/components/EventCardV2.tsx index a0ae1be..4de43c0 100644 --- a/src/features/events/components/EventCardV2.tsx +++ b/src/features/events/components/EventCardV2.tsx @@ -8,13 +8,13 @@ import { EventType } from "@/domains/Events"; import { useFormatDate } from "@/lib/format"; const EventCardV2: FC<{ data: EventType }> = ({ data }) => { - const { title, date, image, status, duration, location } = data; + const { title, date, image_event, status, duration, location } = data; return (
{title} {
{`Event = ({ eventId }) => {
{!isLoading ? ( Banner ) : ( From bdebc2a74f079fbb0b150feb0c18c830cda1f0d0 Mon Sep 17 00:00:00 2001 From: mpsalunggg Date: Tue, 16 Sep 2025 21:07:46 +0700 Subject: [PATCH 3/9] feat: setup e2e testing with playwright --- .github/workflows/playwright.yml | 27 ++ .gitignore | 7 + e2e/example.spec.ts | 18 ++ package.json | 1 + playwright.config.ts | 79 +++++ pnpm-lock.yaml | 57 +++- tests-examples/demo-todo-app.spec.ts | 416 +++++++++++++++++++++++++++ 7 files changed, 598 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 e2e/example.spec.ts create mode 100644 playwright.config.ts create mode 100644 tests-examples/demo-todo-app.spec.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..24effd7 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [main, master, development] + pull_request: + branches: [main, master, development] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm install -g pnpm && pnpm install + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + - name: Run Playwright tests + run: pnpm exec playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index a84107a..107d09a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,10 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/example.spec.ts b/e2e/example.spec.ts new file mode 100644 index 0000000..839cef5 --- /dev/null +++ b/e2e/example.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from "@playwright/test"; + +test("has title", async ({ page }) => { + await page.goto("https://playwright.dev/"); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test("get started link", async ({ page }) => { + await page.goto("https://playwright.dev/"); + + // Click the get started link. + await page.getByRole("link", { name: "Get started" }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible(); +}); diff --git a/package.json b/package.json index 2500bc1..1ef5e83 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "zod": "^3.24.4" }, "devDependencies": { + "@playwright/test": "^1.55.0", "@tailwindcss/postcss": "^4.1.4", "@tailwindcss/typography": "^0.5.16", "@testing-library/dom": "^10.4.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f48cdf9 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,79 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * 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, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* 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:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 535f422..456b806 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,10 +139,10 @@ importers: version: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: specifier: 15.3.2 - version: 15.3.2(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.3.2(@babel/core@7.28.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-intl: specifier: ^4.1.0 - version: 4.3.4(next@15.3.2(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.2) + version: 4.3.4(next@15.3.2(@babel/core@7.28.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.2) next-mdx-remote: specifier: ^5.0.0 version: 5.0.0(@types/react@19.1.5)(acorn@8.15.0)(react@19.1.0) @@ -189,6 +189,9 @@ importers: specifier: ^3.24.4 version: 3.25.76 devDependencies: + "@playwright/test": + specifier: ^1.55.0 + version: 1.55.0 "@tailwindcss/postcss": specifier: ^4.1.4 version: 4.1.11 @@ -994,6 +997,12 @@ packages: { integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== } engines: { node: ">=14" } + "@playwright/test@1.55.0": + resolution: + { integrity: sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ== } + engines: { node: ">=18" } + hasBin: true + "@radix-ui/number@1.1.1": resolution: { integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g== } @@ -3371,6 +3380,12 @@ packages: resolution: { integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== } + fsevents@2.3.2: + resolution: + { integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + fsevents@2.3.3: resolution: { integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== } @@ -4691,6 +4706,18 @@ packages: engines: { node: ">=0.10" } hasBin: true + playwright-core@1.55.0: + resolution: + { integrity: sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg== } + engines: { node: ">=18" } + hasBin: true + + playwright@1.55.0: + resolution: + { integrity: sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA== } + engines: { node: ">=18" } + hasBin: true + possible-typed-array-names@1.1.0: resolution: { integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== } @@ -6427,6 +6454,10 @@ snapshots: "@pkgjs/parseargs@0.11.0": optional: true + "@playwright/test@1.55.0": + dependencies: + playwright: 1.55.0 + "@radix-ui/number@1.1.1": {} "@radix-ui/primitive@1.1.2": {} @@ -8305,7 +8336,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.39.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.39.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -8327,7 +8358,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.39.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.39.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -8592,6 +8623,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9774,11 +9808,11 @@ snapshots: negotiator@1.0.0: {} - next-intl@4.3.4(next@15.3.2(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.2): + next-intl@4.3.4(next@15.3.2(@babel/core@7.28.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.2): dependencies: "@formatjs/intl-localematcher": 0.5.10 negotiator: 1.0.0 - next: 15.3.2(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.2(@babel/core@7.28.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 use-intl: 4.3.4(react@19.1.0) optionalDependencies: @@ -9803,7 +9837,7 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - next@15.3.2(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@15.3.2(@babel/core@7.28.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: "@next/env": 15.3.2 "@swc/counter": 0.1.3 @@ -9823,6 +9857,7 @@ snapshots: "@next/swc-linux-x64-musl": 15.3.2 "@next/swc-win32-arm64-msvc": 15.3.2 "@next/swc-win32-x64-msvc": 15.3.2 + "@playwright/test": 1.55.0 sharp: 0.34.3 transitivePeerDependencies: - "@babel/core" @@ -9972,6 +10007,14 @@ snapshots: pidtree@0.6.0: {} + playwright-core@1.55.0: {} + + playwright@1.55.0: + dependencies: + playwright-core: 1.55.0 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-selector-parser@6.0.10: diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 0000000..d482146 --- /dev/null +++ b/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,416 @@ +import { test, expect, type Page } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("https://demo.playwright.dev/todomvc"); +}); + +const TODO_ITEMS = ["buy some cheese", "feed the cat", "book a doctors appointment"] as const; + +test.describe("New Todo", () => { + test("should allow me to add todo items", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press("Enter"); + + // Make sure the list only has one todo item. + await expect(page.getByTestId("todo-title")).toHaveText([TODO_ITEMS[0]]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press("Enter"); + + // Make sure the list now has two todo items. + await expect(page.getByTestId("todo-title")).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test("should clear text input field when an item is added", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press("Enter"); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test("should append new items to the bottom of the list", async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId("todo-count"); + + // Check test using different methods. + await expect(page.getByText("3 items left")).toBeVisible(); + await expect(todoCount).toHaveText("3 items left"); + await expect(todoCount).toContainText("3"); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId("todo-title")).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe("Mark all as completed", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test("should allow me to mark all items as completed", async ({ page }) => { + // Complete all todos. + await page.getByLabel("Mark all as complete").check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId("todo-item")).toHaveClass(["completed", "completed", "completed"]); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test("should allow me to clear the complete state of all items", async ({ page }) => { + const toggleAll = page.getByLabel("Mark all as complete"); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId("todo-item")).toHaveClass(["", "", ""]); + }); + + test("complete all checkbox should update state when items are completed / cleared", async ({ page }) => { + const toggleAll = page.getByLabel("Mark all as complete"); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId("todo-item").nth(0); + await firstTodo.getByRole("checkbox").uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole("checkbox").check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe("Item", () => { + test("should allow me to mark items as complete", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } + + // Check first item. + const firstTodo = page.getByTestId("todo-item").nth(0); + await firstTodo.getByRole("checkbox").check(); + await expect(firstTodo).toHaveClass("completed"); + + // Check second item. + const secondTodo = page.getByTestId("todo-item").nth(1); + await expect(secondTodo).not.toHaveClass("completed"); + await secondTodo.getByRole("checkbox").check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass("completed"); + await expect(secondTodo).toHaveClass("completed"); + }); + + test("should allow me to un-mark items as complete", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } + + const firstTodo = page.getByTestId("todo-item").nth(0); + const secondTodo = page.getByTestId("todo-item").nth(1); + const firstTodoCheckbox = firstTodo.getByRole("checkbox"); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass("completed"); + await expect(secondTodo).not.toHaveClass("completed"); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass("completed"); + await expect(secondTodo).not.toHaveClass("completed"); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test("should allow me to edit an item", async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId("todo-item"); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole("textbox", { name: "Edit" })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole("textbox", { name: "Edit" }).fill("buy some sausages"); + await secondTodo.getByRole("textbox", { name: "Edit" }).press("Enter"); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([TODO_ITEMS[0], "buy some sausages", TODO_ITEMS[2]]); + await checkTodosInLocalStorage(page, "buy some sausages"); + }); +}); + +test.describe("Editing", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test("should hide other controls when editing", async ({ page }) => { + const todoItem = page.getByTestId("todo-item").nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole("checkbox")).not.toBeVisible(); + await expect( + todoItem.locator("label", { + hasText: TODO_ITEMS[1], + }) + ).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test("should save edits on blur", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).fill("buy some sausages"); + await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).dispatchEvent("blur"); + + await expect(todoItems).toHaveText([TODO_ITEMS[0], "buy some sausages", TODO_ITEMS[2]]); + await checkTodosInLocalStorage(page, "buy some sausages"); + }); + + test("should trim entered text", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).fill(" buy some sausages "); + await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).press("Enter"); + + await expect(todoItems).toHaveText([TODO_ITEMS[0], "buy some sausages", TODO_ITEMS[2]]); + await checkTodosInLocalStorage(page, "buy some sausages"); + }); + + test("should remove the item if an empty text string was entered", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).fill(""); + await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).press("Enter"); + + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test("should cancel edits on escape", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).fill("buy some sausages"); + await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).press("Escape"); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe("Counter", () => { + test("should display the current number of todo items", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // create a todo count locator + const todoCount = page.getByTestId("todo-count"); + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press("Enter"); + + await expect(todoCount).toContainText("1"); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press("Enter"); + await expect(todoCount).toContainText("2"); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe("Clear completed button", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test("should display the correct text", async ({ page }) => { + await page.locator(".todo-list li .toggle").first().check(); + await expect(page.getByRole("button", { name: "Clear completed" })).toBeVisible(); + }); + + test("should remove completed items when clicked", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).getByRole("checkbox").check(); + await page.getByRole("button", { name: "Clear completed" }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test("should be hidden when there are no items that are completed", async ({ page }) => { + await page.locator(".todo-list li .toggle").first().check(); + await page.getByRole("button", { name: "Clear completed" }).click(); + await expect(page.getByRole("button", { name: "Clear completed" })).toBeHidden(); + }); +}); + +test.describe("Persistence", () => { + test("should persist its data", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } + + const todoItems = page.getByTestId("todo-item"); + const firstTodoCheck = todoItems.nth(0).getByRole("checkbox"); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(["completed", ""]); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(["completed", ""]); + }); +}); + +test.describe("Routing", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test("should allow me to display active items", async ({ page }) => { + const todoItem = page.getByTestId("todo-item"); + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole("link", { name: "Active" }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test("should respect the back button", async ({ page }) => { + const todoItem = page.getByTestId("todo-item"); + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step("Showing all items", async () => { + await page.getByRole("link", { name: "All" }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step("Showing active items", async () => { + await page.getByRole("link", { name: "Active" }).click(); + }); + + await test.step("Showing completed items", async () => { + await page.getByRole("link", { name: "Completed" }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test("should allow me to display completed items", async ({ page }) => { + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole("link", { name: "Completed" }).click(); + await expect(page.getByTestId("todo-item")).toHaveCount(1); + }); + + test("should allow me to display all items", async ({ page }) => { + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole("link", { name: "Active" }).click(); + await page.getByRole("link", { name: "Completed" }).click(); + await page.getByRole("link", { name: "All" }).click(); + await expect(page.getByTestId("todo-item")).toHaveCount(3); + }); + + test("should highlight the currently applied filter", async ({ page }) => { + await expect(page.getByRole("link", { name: "All" })).toHaveClass("selected"); + + //create locators for active and completed links + const activeLink = page.getByRole("link", { name: "Active" }); + const completedLink = page.getByRole("link", { name: "Completed" }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass("selected"); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass("selected"); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction((e) => { + return JSON.parse(localStorage["react-todos"]).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction((e) => { + return JSON.parse(localStorage["react-todos"]).filter((todo: any) => todo.completed).length === e; + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction((t) => { + return JSON.parse(localStorage["react-todos"]) + .map((todo: any) => todo.title) + .includes(t); + }, title); +} From 694448513ab13d4bacac498834d0aa18aad082d2 Mon Sep 17 00:00:00 2001 From: mpsalunggg Date: Wed, 17 Sep 2025 21:49:46 +0700 Subject: [PATCH 4/9] chore(test): refactor folder playwright --- package.json | 3 ++- {e2e => tests/e2e}/example.spec.ts | 0 playwright.config.ts => tests/setup/playwright.config.ts | 2 +- tests/{ => setup}/vitest-setup.ts | 0 vitest.config.ts | 2 +- 5 files changed, 4 insertions(+), 3 deletions(-) rename {e2e => tests/e2e}/example.spec.ts (100%) rename playwright.config.ts => tests/setup/playwright.config.ts (99%) rename tests/{ => setup}/vitest-setup.ts (100%) diff --git a/package.json b/package.json index 1ef5e83..5877aaa 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "check-format": "prettier --check .", "prepare": "husky", "test": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test --config=tests/setup/playwright.config.ts" }, "husky": { "hooks": { diff --git a/e2e/example.spec.ts b/tests/e2e/example.spec.ts similarity index 100% rename from e2e/example.spec.ts rename to tests/e2e/example.spec.ts diff --git a/playwright.config.ts b/tests/setup/playwright.config.ts similarity index 99% rename from playwright.config.ts rename to tests/setup/playwright.config.ts index f48cdf9..363869d 100644 --- a/playwright.config.ts +++ b/tests/setup/playwright.config.ts @@ -12,7 +12,7 @@ import { defineConfig, devices } from "@playwright/test"; * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: "./e2e", + 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. */ diff --git a/tests/vitest-setup.ts b/tests/setup/vitest-setup.ts similarity index 100% rename from tests/vitest-setup.ts rename to tests/setup/vitest-setup.ts diff --git a/vitest.config.ts b/vitest.config.ts index df875b0..cbad941 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ test: { environment: "jsdom", globals: true, - setupFiles: "./tests/vitest-setup.ts", + setupFiles: "./tests/setup/vitest-setup.ts", exclude: [...configDefaults.exclude, ...testExclusions], coverage: { exclude: [...(configDefaults.coverage.exclude ?? []), ...testExclusions], From 7f866ac22e461524958cd222b1b2804c5ef3f144 Mon Sep 17 00:00:00 2001 From: mpsalunggg Date: Wed, 17 Sep 2025 22:57:31 +0700 Subject: [PATCH 5/9] feat(e2e): create e2e testing signin page form element --- package.json | 5 ++++- src/locales/id.json | 2 +- tests/e2e/auth/sign-in.spec.ts | 26 ++++++++++++++++++++++++++ tests/e2e/example.spec.ts | 2 +- tests/setup/playwright.config.ts | 12 ++++++------ 5 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/auth/sign-in.spec.ts diff --git a/package.json b/package.json index 5877aaa..3566aca 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "prepare": "husky", "test": "vitest", "test:coverage": "vitest run --coverage", - "test:e2e": "playwright test --config=tests/setup/playwright.config.ts" + "test:e2e": "playwright test --config=tests/setup/playwright.config.ts", + "test:e2e:ui": "playwright test --config=tests/setup/playwright.config.ts --ui", + "test:e2e:headed": "playwright test --config=tests/setup/playwright.config.ts --headed", + "test:e2e:debug": "playwright test --config=tests/setup/playwright.config.ts --debug" }, "husky": { "hooks": { diff --git a/src/locales/id.json b/src/locales/id.json index f96444f..af1342e 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -66,7 +66,7 @@ "enter-email": "Masukan Email", "enter-password": "Masukan Password", "no-account": "Apakah Belum Punya Akun?", - "sign-in": "Sign In", + "sign-in": "Masuk", "sign-up": "Sign Up", "forgot-password": "Lupa Password?" }, diff --git a/tests/e2e/auth/sign-in.spec.ts b/tests/e2e/auth/sign-in.spec.ts new file mode 100644 index 0000000..031aab5 --- /dev/null +++ b/tests/e2e/auth/sign-in.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Sign In Page", () => { + test.describe("English (Default)", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/sign-in"); + }); + + test("should display sign in form elements", async ({ page }) => { + await expect(page.getByRole("heading", { name: /welcome back 👋/i })).toBeVisible(); + await expect(page.getByText(/sign in to connect, learn, and grow together./i)).toBeVisible(); + + await expect(page.locator('input[name="email"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toBeVisible(); + await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible(); + + const forgotPasswordLink = page.getByRole("link", { name: /forgot password\?/i }); + await expect(forgotPasswordLink).toBeVisible(); + await expect(forgotPasswordLink).toHaveAttribute("href", "/en/forgot-password/"); + + const signUpLink = page.getByRole("link", { name: /sign up/i }); + await expect(signUpLink).toBeVisible(); + await expect(signUpLink).toHaveAttribute("href", "/en/sign-up/"); + }); + }); +}); diff --git a/tests/e2e/example.spec.ts b/tests/e2e/example.spec.ts index 839cef5..0ff9e75 100644 --- a/tests/e2e/example.spec.ts +++ b/tests/e2e/example.spec.ts @@ -4,7 +4,7 @@ test("has title", async ({ page }) => { await page.goto("https://playwright.dev/"); // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Playwright/); + await expect(page).toHaveTitle(/Playwrighasdasdt/); }); test("get started link", async ({ page }) => { diff --git a/tests/setup/playwright.config.ts b/tests/setup/playwright.config.ts index 363869d..660a5dd 100644 --- a/tests/setup/playwright.config.ts +++ b/tests/setup/playwright.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ /* 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:3000', + baseURL: "http://localhost:3000", /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", @@ -71,9 +71,9 @@ export default defineConfig({ ], /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://localhost:3000', - // reuseExistingServer: !process.env.CI, - // }, + webServer: { + command: "pnpm run dev", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + }, }); From 30fd0e24d5543c714879d2b85f561e7d5ab7565c Mon Sep 17 00:00:00 2001 From: mpsalunggg Date: Thu, 18 Sep 2025 08:25:42 +0700 Subject: [PATCH 6/9] chore(e2e): adjust workflow run playwright --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 24effd7..92242e6 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -18,7 +18,7 @@ jobs: - name: Install Playwright Browsers run: pnpm exec playwright install --with-deps - name: Run Playwright tests - run: pnpm exec playwright test + run: pnpm run test:e2e - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: From 10a78ce6cc297d998435d82da5ec8c281be82138 Mon Sep 17 00:00:00 2001 From: mpsalunggg Date: Thu, 18 Sep 2025 08:32:21 +0700 Subject: [PATCH 7/9] chore(e2e): fix typo example test --- .github/workflows/playwright.yml | 14 ++++++++++++++ tests/e2e/example.spec.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 92242e6..fe74b0a 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -25,3 +25,17 @@ jobs: name: playwright-report path: playwright-report/ retention-days: 30 + + # Required check untuk branch protection + e2e-required: + runs-on: ubuntu-latest + needs: test + if: always() + steps: + - name: Check E2E Test Results + run: | + if [ "${{ needs.test.result }}" != "success" ]; then + echo "E2E tests failed!" + exit 1 + fi + echo "E2E tests passed!" diff --git a/tests/e2e/example.spec.ts b/tests/e2e/example.spec.ts index 0ff9e75..839cef5 100644 --- a/tests/e2e/example.spec.ts +++ b/tests/e2e/example.spec.ts @@ -4,7 +4,7 @@ test("has title", async ({ page }) => { await page.goto("https://playwright.dev/"); // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Playwrighasdasdt/); + await expect(page).toHaveTitle(/Playwright/); }); test("get started link", async ({ page }) => { From 484bcc08d8822f1fcea30d21ae4a0d0ca32e67d1 Mon Sep 17 00:00:00 2001 From: mpsalunggg Date: Thu, 18 Sep 2025 08:38:17 +0700 Subject: [PATCH 8/9] chore(e2e): testing failed --- .github/workflows/playwright.yml | 2 +- tests/e2e/example.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index fe74b0a..3dbcf05 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -26,7 +26,6 @@ jobs: path: playwright-report/ retention-days: 30 - # Required check untuk branch protection e2e-required: runs-on: ubuntu-latest needs: test @@ -34,6 +33,7 @@ jobs: steps: - name: Check E2E Test Results run: | + echo "Test job result: ${{ needs.test.result }}" if [ "${{ needs.test.result }}" != "success" ]; then echo "E2E tests failed!" exit 1 diff --git a/tests/e2e/example.spec.ts b/tests/e2e/example.spec.ts index 839cef5..0796a58 100644 --- a/tests/e2e/example.spec.ts +++ b/tests/e2e/example.spec.ts @@ -4,7 +4,7 @@ test("has title", async ({ page }) => { await page.goto("https://playwright.dev/"); // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Playwright/); + await expect(page).toHaveTitle(/Playwrigsshtssss/); }); test("get started link", async ({ page }) => { From 03b10416944030a341b11b534cfaa56835f6659f Mon Sep 17 00:00:00 2001 From: mpsalunggg Date: Thu, 18 Sep 2025 15:04:12 +0700 Subject: [PATCH 9/9] chore(e2e): testing success --- .github/workflows/playwright.yml | 14 -------------- tests/e2e/example.spec.ts | 2 +- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 3dbcf05..92242e6 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -25,17 +25,3 @@ jobs: name: playwright-report path: playwright-report/ retention-days: 30 - - e2e-required: - runs-on: ubuntu-latest - needs: test - if: always() - steps: - - name: Check E2E Test Results - run: | - echo "Test job result: ${{ needs.test.result }}" - if [ "${{ needs.test.result }}" != "success" ]; then - echo "E2E tests failed!" - exit 1 - fi - echo "E2E tests passed!" diff --git a/tests/e2e/example.spec.ts b/tests/e2e/example.spec.ts index 0796a58..839cef5 100644 --- a/tests/e2e/example.spec.ts +++ b/tests/e2e/example.spec.ts @@ -4,7 +4,7 @@ test("has title", async ({ page }) => { await page.goto("https://playwright.dev/"); // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Playwrigsshtssss/); + await expect(page).toHaveTitle(/Playwright/); }); test("get started link", async ({ page }) => {