diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 0000000..a819bb3 --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,169 @@ +name: UI Tests + +on: + push: + branches: + - develop + pull_request: + workflow_dispatch: + +concurrency: + group: ui-tests-${{ github.event.number || github.ref }} + cancel-in-progress: true + +jobs: + ui-tests: + runs-on: ubuntu-latest + timeout-minutes: 60 + name: Playwright E2E + + services: + redis-cache: + image: redis:alpine + ports: + - 13000:6379 + redis-queue: + image: redis:alpine + ports: + - 11000:6379 + mariadb: + image: mariadb:10.6 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + check-latest: true + + - name: Add to Hosts + run: echo "127.0.0.1 forms.test" | sudo tee -a /etc/hosts + + - name: Cache pip + uses: actions/cache@v5 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: 'echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT' + + - uses: actions/cache@v5 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install MariaDB Client + run: | + sudo apt update + sudo apt-get install mariadb-client + + - name: Setup Frappe bench + run: | + pip install frappe-bench + bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + + - name: Install app + working-directory: /home/runner/frappe-bench + run: | + bench get-app frappe_factory_bot https://github.com/harshtandiya/frappe_factory_bot.git --branch main + bench get-app forms_pro $GITHUB_WORKSPACE + bench setup requirements --dev + bench new-site --db-root-password root --admin-password admin forms.test + bench --site forms.test install-app forms_pro + bench build + env: + CI: "Yes" + + - name: Configure Site for UI Tests + working-directory: /home/runner/frappe-bench + run: | + bench --site forms.test set-config allow_tests true + bench --site forms.test set-config server_script_enabled true + bench --site forms.test set-config host_name "http://forms.test:8000" + bench --site forms.test set-config ignore_csrf 1 + bench --site forms.test set-config mute_emails 1 + + - name: Setup E2E test data + working-directory: /home/runner/frappe-bench + run: bench --site forms.test execute forms_pro.install.before_tests + + - name: Start Frappe Server + working-directory: /home/runner/frappe-bench + run: | + sed -i 's/^watch:/# watch:/g' Procfile + sed -i 's/^schedule:/# schedule:/g' Procfile + bench start &> /tmp/bench_start.log & + echo "Waiting for Frappe server to start..." + timeout 90 bash -c 'until curl -s http://forms.test:8000 > /dev/null; do sleep 2; done' || (echo "=== bench start log ===" && cat /tmp/bench_start.log && exit 1) + echo "Frappe server is ready!" + + - name: Install Playwright browsers + working-directory: ${{ github.workspace }}/frontend + run: | + yarn install + npx playwright install --with-deps chromium + + - name: Run Playwright tests + working-directory: ${{ github.workspace }}/frontend + run: yarn test:e2e + env: + BASE_URL: http://forms.test:8000 + TEST_USER_EMAIL: test_forms_pro_user@example.com + TEST_USER_PASSWORD: testforms123 + CI: "true" + + - name: Upload HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: frontend/e2e/playwright-report/ + retention-days: 14 + + - name: Upload failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-results + path: frontend/e2e/test-results/ + retention-days: 7 + + - name: Show bench logs on failure + if: failure() + run: | + echo "=== bench start log ===" + cat /tmp/bench_start.log || true + echo "" + echo "=== Frappe logs ===" + cat /home/runner/frappe-bench/logs/*.log || true 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/forms_pro/install.py b/forms_pro/install.py index f408d42..99707c1 100644 --- a/forms_pro/install.py +++ b/forms_pro/install.py @@ -22,6 +22,17 @@ def create_user_forms_module(): def before_tests(): give_admin_forms_pro_role() create_test_user() + _ensure_admin_has_default_team() + + +def _ensure_admin_has_default_team(): + from forms_pro.overrides.roles import create_default_team_for_user + from forms_pro.utils.teams import get_user_teams + + if get_user_teams("Administrator"): + return + admin = frappe.get_doc("User", "Administrator") + create_default_team_for_user(admin) def give_admin_forms_pro_role(): @@ -31,6 +42,8 @@ def give_admin_forms_pro_role(): def create_test_user(): + from frappe.utils.password import update_password + if frappe.db.exists("User", FORMS_PRO_TEST_USER): return @@ -39,5 +52,10 @@ def create_test_user(): user.first_name = "Test" user.last_name = "Forms Pro User" user.insert(ignore_permissions=True) + + # Frappe auto-assigns System User on insert; replace with only Forms Pro User + user.roles = [] user.append("roles", {"role": FORMS_PRO_ROLE}) user.save(ignore_permissions=True) + + update_password(FORMS_PRO_TEST_USER, "testforms123") diff --git a/forms_pro/utils/form_generator.py b/forms_pro/utils/form_generator.py index 8bae1ba..14fa51d 100644 --- a/forms_pro/utils/form_generator.py +++ b/forms_pro/utils/form_generator.py @@ -98,7 +98,7 @@ def generate(self) -> None: self._initialize_doctype() self._add_status_field() self._initialize_form_document() - frappe.clear_cache() + frappe.clear_document_cache("DocType", self.doctype.name) def _initialize_doctype(self) -> None: if self.doctype: diff --git a/frontend/e2e/fixtures/test-data.fixture.ts b/frontend/e2e/fixtures/test-data.fixture.ts new file mode 100644 index 0000000..4de5b5b --- /dev/null +++ b/frontend/e2e/fixtures/test-data.fixture.ts @@ -0,0 +1,98 @@ +import { + test as base, + request, + type APIRequestContext, +} from "@playwright/test"; + +type TestDataFixtures = { + apiContext: APIRequestContext; + getTeamId: () => Promise; + createForm: () => Promise; + createPublishedForm: () => Promise<{ formId: string; route: string }>; + submitForm: (formId: string) => Promise; +}; + +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(() => {}); + } + }, + + // Creates a guest submission against an already-published form + submitForm: async ({ apiContext }, use) => { + await use(async (formId: string) => { + await apiContext.post( + "/api/method/forms_pro.api.submission.submit_form_response", + { data: { form_id: formId, form_data: [] } } + ); + }); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..1fc09fd --- /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: process.env.TEST_USER_EMAIL ?? "test_forms_pro_user@example.com", + pwd: process.env.TEST_USER_PASSWORD ?? "testforms123", + }, + }); + + await context.storageState({ path: AUTH_FILE }); + await browser.close(); +} diff --git a/frontend/e2e/helpers/dashboard.ts b/frontend/e2e/helpers/dashboard.ts new file mode 100644 index 0000000..f971c90 --- /dev/null +++ b/frontend/e2e/helpers/dashboard.ts @@ -0,0 +1,14 @@ +import type { Page } from "@playwright/test"; + +export class DashboardPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto("/forms"); + } + + // Form title headings inside FormPreviewCard + formTitles() { + return this.page.getByRole("heading", { level: 3 }); + } +} diff --git a/frontend/e2e/helpers/form-builder.ts b/frontend/e2e/helpers/form-builder.ts new file mode 100644 index 0000000..7d18e08 --- /dev/null +++ b/frontend/e2e/helpers/form-builder.ts @@ -0,0 +1,53 @@ +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}`); + // Wait for the sidebar to confirm the builder is mounted + await this.page.waitForSelector( + '[data-form-builder-component="form-builder-sidebar"]' + ); + } + + // Scope selectors to the sidebar (which has a stable pre-existing attribute) + private sidebar() { + return this.page.locator( + '[data-form-builder-component="form-builder-sidebar"]' + ); + } + + async addField(fieldType: string) { + // Each card shows the field type name as visible text + const card = this.sidebar() + .getByText(fieldType, { exact: true }) + .locator(".."); + await card.hover(); + await card.getByRole("button").click(); + } + + // The canvas shows "Click on fields to add them…" when empty + canvasEmptyState() { + return this.page.getByText(/click on fields to add them/i); + } + + async publish() { + // Click Publish and wait for the label to flip to "Unpublish" + await this.page.getByRole("button", { name: /^publish$/i }).click(); + await this.page + .getByRole("button", { name: /^unpublish$/i }) + .waitFor({ timeout: 10000 }); + } + + async unpublish() { + await this.page.getByRole("button", { name: /^unpublish$/i }).click(); + await this.page + .getByRole("button", { name: /^publish$/i }) + .waitFor({ timeout: 10000 }); + } + + publishButton() { + return this.page.getByRole("button", { name: /^publish$/i }); + } +} diff --git a/frontend/e2e/helpers/submission.ts b/frontend/e2e/helpers/submission.ts new file mode 100644 index 0000000..a0f81c8 --- /dev/null +++ b/frontend/e2e/helpers/submission.ts @@ -0,0 +1,22 @@ +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.getByRole("button", { name: "Submit" }).click(); + } + + // After successful submission the SuccessSection renders; detect via its description text + successMessage() { + return this.page.getByText(/thank you for submitting/i); + } +} diff --git a/frontend/e2e/helpers/team.ts b/frontend/e2e/helpers/team.ts new file mode 100644 index 0000000..474d1f8 --- /dev/null +++ b/frontend/e2e/helpers/team.ts @@ -0,0 +1,32 @@ +import type { Page } from "@playwright/test"; + +export class TeamPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto("/forms/team"); + // Wait for the API calls (team members, etc.) to complete before proceeding + await this.page.waitForLoadState("networkidle"); + } + + async openInviteDialog() { + await this.page.getByRole("button", { name: /invite member/i }).click(); + // Wait until the dialog's email input is interactive, not just the dialog shell + await this.page + .getByRole("dialog") + .getByLabel("Invite by Email") + .waitFor({ state: "visible" }); + } + + async inviteByEmail(email: string) { + const dialog = this.page.getByRole("dialog"); + await dialog.getByLabel("Invite by Email").fill(email); + // Click "Add" to move the email into the pending invites list + await dialog.getByRole("button", { name: "Add" }).click(); + await dialog.getByRole("button", { name: "Send Invitations" }).click(); + } + + memberRows() { + return this.page.getByRole("row"); + } +} diff --git a/frontend/e2e/specs/form-creation.spec.ts b/frontend/e2e/specs/form-creation.spec.ts new file mode 100644 index 0000000..d9dbcc6 --- /dev/null +++ b/frontend/e2e/specs/form-creation.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from "../fixtures/test-data.fixture"; +import { DashboardPage } from "../helpers/dashboard"; +import { FormBuilderPage } from "../helpers/form-builder"; + +test.describe("Form Creation", () => { + test("form title appears on dashboard after creation via API", async ({ + page, + createForm, + }) => { + await createForm(); + const dashboard = new DashboardPage(page); + await dashboard.goto(); + await expect(dashboard.formTitles().first()).toBeVisible(); + }); + + test("form builder loads and shows Add Fields sidebar", async ({ + page, + createForm, + }) => { + const formId = await createForm(); + const builder = new FormBuilderPage(page); + await builder.goto(formId); + await expect(page.getByText("Add Fields")).toBeVisible(); + await expect(page.getByText("Data", { exact: true }).first()).toBeVisible(); + }); + + test("adding a field removes the canvas empty state", async ({ + page, + createForm, + }) => { + const formId = await createForm(); + const builder = new FormBuilderPage(page); + await builder.goto(formId); + + await expect(builder.canvasEmptyState()).toBeVisible(); + await builder.addField("Data"); + await expect(builder.canvasEmptyState()).not.toBeVisible(); + }); + + test("can publish a draft form", async ({ page, createForm }) => { + const formId = await createForm(); + const builder = new FormBuilderPage(page); + await builder.goto(formId); + + // Fresh form is a draft → Publish button visible + await expect(builder.publishButton()).toBeVisible(); + await builder.publish(); + + // After publishing, button flips to Unpublish + await expect( + page.getByRole("button", { name: /^unpublish$/i }) + ).toBeVisible(); + }); + + test("can unpublish a published form", async ({ + page, + createPublishedForm, + }) => { + const { formId } = await createPublishedForm(); + const builder = new FormBuilderPage(page); + await builder.goto(formId); + + // Published form → Unpublish button visible + await expect( + page.getByRole("button", { name: /^unpublish$/i }) + ).toBeVisible(); + await builder.unpublish(); + + await expect(builder.publishButton()).toBeVisible(); + }); +}); diff --git a/frontend/e2e/specs/form-submission.spec.ts b/frontend/e2e/specs/form-submission.spec.ts new file mode 100644 index 0000000..d014559 --- /dev/null +++ b/frontend/e2e/specs/form-submission.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from "../fixtures/test-data.fixture"; +import { SubmissionPage } from "../helpers/submission"; + +test.describe("Form Submission", () => { + test("published form loads on the public submission page", async ({ + page, + createPublishedForm, + }) => { + const { route } = await createPublishedForm(); + const submissionPage = new SubmissionPage(page); + await submissionPage.goto(route); + + // The public form renders a Submit button (form is in filling state) + await expect(page.getByRole("button", { name: "Submit" })).toBeVisible(); + }); + + test("guest user can submit a published form", async ({ + browser, + createPublishedForm, + }) => { + const { route } = await createPublishedForm(); + + // Fresh context = no session cookies → simulates a real guest user + const guestCtx = await browser.newContext(); + const guestPage = await guestCtx.newPage(); + const submissionPage = new SubmissionPage(guestPage); + + await submissionPage.goto(route); + await expect(guestPage.getByRole("button", { name: "Submit" })).toBeVisible( + { timeout: 10000 } + ); + + await submissionPage.submit(); + + // Success section appears with the default thank-you copy + await expect(submissionPage.successMessage()).toBeVisible({ + timeout: 10000, + }); + + await guestCtx.close(); + }); +}); diff --git a/frontend/e2e/specs/smoke.spec.ts b/frontend/e2e/specs/smoke.spec.ts new file mode 100644 index 0000000..c5bfc13 --- /dev/null +++ b/frontend/e2e/specs/smoke.spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from "@playwright/test"; + +test("authenticated user can reach the forms dashboard", async ({ page }) => { + const response = await page.goto("/forms"); + // Frappe dev server can 500 on the very first request under parallel test startup + if (response?.status() === 500) { + await page.reload({ waitUntil: "networkidle" }); + } + await expect(page).not.toHaveURL(/login/); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible({ + timeout: 15000, + }); +}); diff --git a/frontend/e2e/specs/submission-view.spec.ts b/frontend/e2e/specs/submission-view.spec.ts new file mode 100644 index 0000000..018adb4 --- /dev/null +++ b/frontend/e2e/specs/submission-view.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from "../fixtures/test-data.fixture"; + +test.describe("Submission Viewing", () => { + test("submissions page shows empty state when no submissions exist", async ({ + page, + createPublishedForm, + }) => { + const { formId } = await createPublishedForm(); + await page.goto(`/forms/manage/${formId}/submissions`); + await expect(page.getByText("No Submissions Yet")).toBeVisible(); + }); + + test("admin sees a submission row after a form is submitted", async ({ + page, + createPublishedForm, + submitForm, + }) => { + const { formId } = await createPublishedForm(); + await submitForm(formId); + + await page.goto(`/forms/manage/${formId}/submissions`); + + // Empty state should be gone; the "Submitted" badge appears in a row + await expect(page.getByText("No Submissions Yet")).not.toBeVisible(); + await expect(page.getByText("Submitted").first()).toBeVisible({ + timeout: 10000, + }); + }); +}); diff --git a/frontend/e2e/specs/team-invite.spec.ts b/frontend/e2e/specs/team-invite.spec.ts new file mode 100644 index 0000000..40abd7e --- /dev/null +++ b/frontend/e2e/specs/team-invite.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "../fixtures/test-data.fixture"; +import { TeamPage } from "../helpers/team"; + +test.describe("Team Management", () => { + test("team page loads and shows Invite Member button", async ({ page }) => { + const teamPage = new TeamPage(page); + await teamPage.goto(); + await expect( + page.getByRole("button", { name: /invite member/i }) + ).toBeVisible(); + }); + + test("invite dialog opens with email input and send button", async ({ + page, + }) => { + const teamPage = new TeamPage(page); + await teamPage.goto(); + await teamPage.openInviteDialog(); + + const dialog = page.getByRole("dialog"); + await expect(dialog.getByLabel("Invite by Email")).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Send Invitations" }) + ).toBeVisible(); + }); + + test("team owner can invite a member by email", async ({ page }) => { + const teamPage = new TeamPage(page); + await teamPage.goto(); + await teamPage.openInviteDialog(); + await teamPage.inviteByEmail("testinvite@example.com"); + + // Success toast appears + await expect(page.getByText(/invitations sent successfully/i)).toBeVisible({ + timeout: 10000, + }); + }); +}); diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json new file mode 100644 index 0000000..61b5dac --- /dev/null +++ b/frontend/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node", "@playwright/test"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["./**/*.ts", "../playwright.config.ts"] +} diff --git a/frontend/package.json b/frontend/package.json index 9a7c126..00fa9dc 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,8 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@playwright/test": "^1.59.1", + "@types/node": "^25.6.0", "@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..dc6e5e8 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,31 @@ +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: 1, + workers: process.env.CI ? 2 : undefined, + + reporter: [ + ["list"], + ["html", { outputFolder: "./e2e/playwright-report", open: "never" }], + ["github"], + ], +}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d632621..44c501f 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" @@ -855,6 +862,13 @@ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== +"@types/node@^25.6.0": + version "25.6.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca" + integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ== + dependencies: + undici-types "~7.19.0" + "@types/trusted-types@^2.0.7": version "2.0.7" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" @@ -1784,6 +1798,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 +2508,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" @@ -3245,6 +3278,11 @@ ufo@^1.6.1: resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b" integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA== +undici-types@~7.19.0: + version "7.19.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.19.2.tgz#1b67fc26d0f157a0cba3a58a5b5c1e2276b8ba2a" + integrity sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg== + undici@^7.24.5: version "7.24.7" resolved "https://registry.yarnpkg.com/undici/-/undici-7.24.7.tgz#af9535341bbe80625ca403a02418477a5c6a8760"