From 5e7b1b7c51bb96f597a52fa60d95196d9f321464 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 13 Apr 2026 21:57:36 +0530 Subject: [PATCH 01/11] =?UTF-8?q?feat(e2e):=20Phase=201=20=E2=80=94=20Play?= =?UTF-8?q?wright=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install @playwright/test, add test:e2e scripts, create playwright.config.ts with Chromium project, global-setup for Frappe session auth, and a smoke spec that asserts an authenticated user can reach the forms dashboard. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 7 ++++++- frontend/e2e/global-setup.ts | 28 ++++++++++++++++++++++++++++ frontend/e2e/specs/smoke.spec.ts | 8 ++++++++ frontend/package.json | 6 +++++- frontend/playwright.config.ts | 30 ++++++++++++++++++++++++++++++ frontend/yarn.lock | 26 ++++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 frontend/e2e/global-setup.ts create mode 100644 frontend/e2e/specs/smoke.spec.ts create mode 100644 frontend/playwright.config.ts diff --git a/.gitignore b/.gitignore index 6fef8ac..0e0cf91 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,9 @@ jspm_packages/ /forms_pro/www/frontend.html /forms_pro/public/node_modules frontend/components.d.ts -frontend/auto-imports.d.ts \ No newline at end of file +frontend/auto-imports.d.ts + +# Playwright +frontend/e2e/auth/storageState.json +frontend/e2e/test-results/ +frontend/e2e/playwright-report/ \ No newline at end of file diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..aa32093 --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,28 @@ +import { chromium, type FullConfig } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const AUTH_FILE = path.join(__dirname, "auth/storageState.json"); + +export default async function globalSetup(_config: FullConfig) { + fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true }); + + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + const baseURL = process.env.BASE_URL ?? "http://localhost:8001"; + + await page.goto(baseURL); + await page.request.post(`${baseURL}/api/method/login`, { + form: { + usr: "Administrator", + pwd: process.env.TEST_ADMIN_PASSWORD ?? "admin", + }, + }); + + await context.storageState({ path: AUTH_FILE }); + await browser.close(); +} diff --git a/frontend/e2e/specs/smoke.spec.ts b/frontend/e2e/specs/smoke.spec.ts new file mode 100644 index 0000000..ed3a12d --- /dev/null +++ b/frontend/e2e/specs/smoke.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from "@playwright/test"; + +test("authenticated user can reach the forms dashboard", async ({ page }) => { + await page.goto("/forms"); + // If auth works, we land on the dashboard (not the login page) + await expect(page).not.toHaveURL(/login/); + await expect(page.getByText("Dashboard")).toBeVisible(); +}); diff --git a/frontend/package.json b/frontend/package.json index 9a7c126..5fdf6cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,10 @@ "preview": "vite preview", "lint": "biome check --write .", "typecheck": "./typecheck.sh", - "copy-html-entry": "cp ../forms_pro/public/frontend/index.html ../forms_pro/www/forms.html" + "copy-html-entry": "cp ../forms_pro/public/frontend/index.html ../forms_pro/www/forms.html", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug" }, "dependencies": { "@lottiefiles/dotlottie-vue": "^0.10.4", @@ -29,6 +32,7 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@playwright/test": "^1.59.1", "@vitejs/plugin-vue": "^6.0.5", "@vitest/coverage-v8": "^4.1.4", "autoprefixer": "^10.4.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..ed1d194 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e/specs", + globalSetup: "./e2e/global-setup", + outputDir: "./e2e/test-results", + + use: { + baseURL: process.env.BASE_URL ?? "http://localhost:8001", + storageState: "./e2e/auth/storageState.json", + screenshot: "only-on-failure", + video: "retain-on-failure", + trace: "on-first-retry", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + + reporter: [ + ["list"], + ["html", { outputFolder: "./e2e/playwright-report", open: "never" }], + ], +}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d632621..9ef788e 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -394,6 +394,13 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@playwright/test@^1.59.1": + version "1.59.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6" + integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg== + dependencies: + playwright "1.59.1" + "@popperjs/core@^2.11.2", "@popperjs/core@^2.9.0": version "2.11.8" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" @@ -1784,6 +1791,11 @@ frappe-ui@^0.1.272: unplugin-icons "^22.1.0" unplugin-vue-components "^28.4.1" +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -2489,6 +2501,20 @@ pkg-types@^2.1.0, pkg-types@^2.3.0: exsolve "^1.0.7" pathe "^2.0.3" +playwright-core@1.59.1: + version "1.59.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2" + integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg== + +playwright@1.59.1: + version "1.59.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a" + integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw== + dependencies: + playwright-core "1.59.1" + optionalDependencies: + fsevents "2.3.2" + postcss-import@^15.1.0: version "15.1.0" resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" From a0226b99a85530ccdad5248bba017ca8bead4f9b Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 13 Apr 2026 22:20:58 +0530 Subject: [PATCH 02/11] =?UTF-8?q?feat(e2e):=20Phase=202=20=E2=80=94=20data?= =?UTF-8?q?-testid=20attributes,=20POMs,=20and=20test=20data=20fixture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add data-testid to 7 Vue components (form-card, btn-new-form, field-type-*, btn-save-form, btn-publish, btn-submit-form, submission-success, btn-send-invite, input-invite-email). Restructure InviteMemberDialog to use #actions slot so the send button is directly targetable. Create Page Object Models (dashboard, form-builder, submission, team) and a Playwright fixture with API helpers for form create/publish/teardown. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/fixtures/test-data.fixture.ts | 87 +++++++++++++++++++ frontend/e2e/helpers/dashboard.ts | 17 ++++ frontend/e2e/helpers/form-builder.ts | 37 ++++++++ frontend/e2e/helpers/submission.ts | 21 +++++ frontend/e2e/helpers/team.ts | 29 +++++++ frontend/src/components/FormBuilderHeader.vue | 3 + .../builder/sidebar/AddFieldsSection.vue | 1 + .../components/dashboard/FormPreviewCard.vue | 1 + .../components/submission/FormRenderer.vue | 1 + .../components/submission/SuccessSection.vue | 2 +- .../components/team/InviteMemberDialog.vue | 23 ++--- frontend/src/pages/home/Dashboard.vue | 48 +++++----- 12 files changed, 238 insertions(+), 32 deletions(-) create mode 100644 frontend/e2e/fixtures/test-data.fixture.ts create mode 100644 frontend/e2e/helpers/dashboard.ts create mode 100644 frontend/e2e/helpers/form-builder.ts create mode 100644 frontend/e2e/helpers/submission.ts create mode 100644 frontend/e2e/helpers/team.ts diff --git a/frontend/e2e/fixtures/test-data.fixture.ts b/frontend/e2e/fixtures/test-data.fixture.ts new file mode 100644 index 0000000..1e880d5 --- /dev/null +++ b/frontend/e2e/fixtures/test-data.fixture.ts @@ -0,0 +1,87 @@ +import { + test as base, + request, + type APIRequestContext, +} from "@playwright/test"; + +type TestDataFixtures = { + apiContext: APIRequestContext; + getTeamId: () => Promise; + createForm: () => Promise; + createPublishedForm: () => Promise<{ formId: string; route: string }>; +}; + +async function fetchTeamId(apiContext: APIRequestContext): Promise { + const res = await apiContext.get( + "/api/method/forms_pro.api.user.get_user_teams" + ); + const { message } = await res.json(); + if (!message?.length) throw new Error("No team found for test user"); + return message[0].name as string; +} + +export const test = base.extend({ + apiContext: async ({}, use) => { + const ctx = await request.newContext({ + baseURL: process.env.BASE_URL ?? "http://localhost:8001", + storageState: "./e2e/auth/storageState.json", + }); + await use(ctx); + await ctx.dispose(); + }, + + getTeamId: async ({ apiContext }, use) => { + await use(() => fetchTeamId(apiContext)); + }, + + createForm: async ({ apiContext }, use) => { + const created: string[] = []; + + await use(async () => { + const teamId = await fetchTeamId(apiContext); + const res = await apiContext.post( + "/api/method/forms_pro.utils.form_generator.create_form", + { data: { team_id: teamId } } + ); + const { message } = await res.json(); + const formId = message.form_document as string; + created.push(formId); + return formId; + }); + + // Teardown: delete all created forms + for (const id of created) { + await apiContext.delete(`/api/resource/Form/${id}`).catch(() => {}); + } + }, + + createPublishedForm: async ({ apiContext }, use) => { + const created: string[] = []; + + await use(async () => { + const teamId = await fetchTeamId(apiContext); + const createRes = await apiContext.post( + "/api/method/forms_pro.utils.form_generator.create_form", + { data: { team_id: teamId } } + ); + const { message } = await createRes.json(); + const formId = message.form_document as string; + created.push(formId); + + // Publish the form via Frappe REST API + const publishRes = await apiContext.put(`/api/resource/Form/${formId}`, { + data: { is_published: 1 }, + }); + const publishData = await publishRes.json(); + const route = publishData.data?.route as string; + + return { formId, route }; + }); + + for (const id of created) { + await apiContext.delete(`/api/resource/Form/${id}`).catch(() => {}); + } + }, +}); + +export { expect } from "@playwright/test"; diff --git a/frontend/e2e/helpers/dashboard.ts b/frontend/e2e/helpers/dashboard.ts new file mode 100644 index 0000000..8f21d93 --- /dev/null +++ b/frontend/e2e/helpers/dashboard.ts @@ -0,0 +1,17 @@ +import type { Page } from "@playwright/test"; + +export class DashboardPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto("/forms"); + } + + async clickNewForm() { + await this.page.getByTestId("btn-new-form").click(); + } + + formCards() { + return this.page.getByTestId("form-card"); + } +} diff --git a/frontend/e2e/helpers/form-builder.ts b/frontend/e2e/helpers/form-builder.ts new file mode 100644 index 0000000..bdf989d --- /dev/null +++ b/frontend/e2e/helpers/form-builder.ts @@ -0,0 +1,37 @@ +import type { Page } from "@playwright/test"; + +export class FormBuilderPage { + constructor(private page: Page) {} + + async goto(formId: string) { + await this.page.goto(`/forms/edit-form/${formId}`); + } + + async addField(fieldType: string) { + // The sidebar defaults to "Add Fields" section + await this.page.getByTestId(`field-type-${fieldType}`).hover(); + await this.page + .getByTestId(`field-type-${fieldType}`) + .getByRole("button") + .click(); + } + + async save() { + await this.page.getByTestId("btn-save-form").click(); + await this.page.waitForResponse( + (r) => r.url().includes("/api/") && r.status() === 200 + ); + } + + async publish() { + await this.page.getByTestId("btn-publish").click(); + } + + publishButton() { + return this.page.getByTestId("btn-publish"); + } + + saveButton() { + return this.page.getByTestId("btn-save-form"); + } +} diff --git a/frontend/e2e/helpers/submission.ts b/frontend/e2e/helpers/submission.ts new file mode 100644 index 0000000..4daf16a --- /dev/null +++ b/frontend/e2e/helpers/submission.ts @@ -0,0 +1,21 @@ +import type { Page } from "@playwright/test"; + +export class SubmissionPage { + constructor(private page: Page) {} + + async goto(route: string) { + await this.page.goto(`/forms/p/${route}`); + } + + async fillField(label: string, value: string) { + await this.page.getByLabel(label).fill(value); + } + + async submit() { + await this.page.getByTestId("btn-submit-form").click(); + } + + successSection() { + return this.page.getByTestId("submission-success"); + } +} diff --git a/frontend/e2e/helpers/team.ts b/frontend/e2e/helpers/team.ts new file mode 100644 index 0000000..c203cd9 --- /dev/null +++ b/frontend/e2e/helpers/team.ts @@ -0,0 +1,29 @@ +import type { Page } from "@playwright/test"; + +export class TeamPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto("/forms/team"); + } + + async openInviteDialog() { + await this.page.getByRole("button", { name: /invite member/i }).click(); + } + + async inviteByEmail(email: string) { + const dialog = this.page.getByRole("dialog"); + // frappe-ui FormControl renders a wrapper div; target the inner input + await dialog.getByTestId("input-invite-email").locator("input").fill(email); + // Press Enter to add the email to the list + await dialog + .getByTestId("input-invite-email") + .locator("input") + .press("Enter"); + await dialog.getByTestId("btn-send-invite").click(); + } + + memberRows() { + return this.page.getByRole("row"); + } +} diff --git a/frontend/src/components/FormBuilderHeader.vue b/frontend/src/components/FormBuilderHeader.vue index 512d0a9..3c8ae3d 100644 --- a/frontend/src/components/FormBuilderHeader.vue +++ b/frontend/src/components/FormBuilderHeader.vue @@ -99,6 +99,7 @@ const openFormSubmissionPage = () => {