From baefdeec8970f720344c334b163ca1118071b2d7 Mon Sep 17 00:00:00 2001 From: Shahed <125728402+dev-shahed@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:58:45 +0600 Subject: [PATCH 1/6] Add end-to-end tests for post form security and view control features - Implement `postFormSecurityTest.spec.ts` to validate security measures for form submissions, including nonce checks, payload validation, and escaping of script inputs. - Create `postFormViewControlTestPro.spec.ts` to test view control settings, ensuring content visibility based on user roles and subscription packs. - Enhance `subscriptionTest.spec.ts` to track subscription pack counts, ensuring tests start from the correct baseline. - Introduce `fieldTypes.ts` to catalog available post-form field types for better test management. - Extend `wpEnvCli.ts` with functions to count subscription packs and manage user subscriptions for streamlined test setup. --- .github/workflows/e2e-wpuf.yml | 91 +- docs/post-form.md | 506 +++++++ tests/e2e/.gitignore | 5 + tests/e2e/.wp-env.json | 7 +- tests/e2e/.wp-env.override | 2 +- tests/e2e/features-map/features-map.yml | 307 +++++ tests/e2e/pages/base.ts | 35 +- tests/e2e/pages/fieldOptionSettings.ts | 6 +- tests/e2e/pages/formEditor.ts | 1161 +++++++++++++++++ tests/e2e/pages/postForm.ts | 10 +- tests/e2e/pages/postFormGaps.ts | 334 +++++ tests/e2e/pages/regForm.ts | 33 +- tests/e2e/pages/selectors.ts | 202 ++- tests/e2e/pages/settingsSetup.ts | 16 +- tests/e2e/tests/allFieldTypesTest.spec.ts | 402 ++++++ tests/e2e/tests/alphaSetupTest.spec.ts | 8 +- tests/e2e/tests/formEditorTest.spec.ts | 518 ++++++++ .../tests/postFormDisplayAdvancedTest.spec.ts | 332 +++++ .../tests/postFormExpirationTestPro.spec.ts | 320 +++++ .../tests/postFormFieldBehaviourTest.spec.ts | 352 +++++ .../e2e/tests/postFormGuestSetupTest.spec.ts | 263 ++++ tests/e2e/tests/postFormLifecycleTest.spec.ts | 338 +++++ .../e2e/tests/postFormPricingTestPro.spec.ts | 175 +++ tests/e2e/tests/postFormSecurityTest.spec.ts | 258 ++++ .../tests/postFormViewControlTestPro.spec.ts | 187 +++ tests/e2e/tests/subscriptionTest.spec.ts | 11 + tests/e2e/utils/fieldTypes.ts | 86 ++ tests/e2e/utils/wpEnvCli.ts | 204 +++ 28 files changed, 6132 insertions(+), 37 deletions(-) create mode 100644 docs/post-form.md create mode 100644 tests/e2e/pages/formEditor.ts create mode 100644 tests/e2e/pages/postFormGaps.ts create mode 100644 tests/e2e/tests/allFieldTypesTest.spec.ts create mode 100644 tests/e2e/tests/formEditorTest.spec.ts create mode 100644 tests/e2e/tests/postFormDisplayAdvancedTest.spec.ts create mode 100644 tests/e2e/tests/postFormExpirationTestPro.spec.ts create mode 100644 tests/e2e/tests/postFormFieldBehaviourTest.spec.ts create mode 100644 tests/e2e/tests/postFormGuestSetupTest.spec.ts create mode 100644 tests/e2e/tests/postFormLifecycleTest.spec.ts create mode 100644 tests/e2e/tests/postFormPricingTestPro.spec.ts create mode 100644 tests/e2e/tests/postFormSecurityTest.spec.ts create mode 100644 tests/e2e/tests/postFormViewControlTestPro.spec.ts create mode 100644 tests/e2e/utils/fieldTypes.ts diff --git a/.github/workflows/e2e-wpuf.yml b/.github/workflows/e2e-wpuf.yml index 9ea2edfbb..42e900ef3 100644 --- a/.github/workflows/e2e-wpuf.yml +++ b/.github/workflows/e2e-wpuf.yml @@ -48,6 +48,16 @@ jobs: # feature's files together on one runner (post-* together, reg-* together) so # any intra-feature ordering/state stays intact — no cross-shard dependency. # workers:1 keeps files serial within a shard, so admin sessions never collide. + # + # Groups are balanced by TEST COUNT, not file count, so no single runner + # becomes the long pole (~65-105 tests each, 513 total excluding setup). + # Every group additionally pays the ~37-test setup project, so keep the count + # of groups sane — more groups means more repeated setup, not free speed. + # + # `extra_plugins` are mounted ON TOP of the base set (see the override step + # below). Only list a plugin for the group whose specs actually exercise it: + # every mapped plugin boots on every WP request, and MailPoet/EDD/WC-Vendors + # are pure overhead for a job that never touches them. e2e: name: e2e (${{ matrix.group }}) if: github.repository != 'weDevsOfficial/wp-user-frontend' && github.repository != 'weDevsOfficial/wpuf-pro' @@ -57,12 +67,31 @@ jobs: fail-fast: false matrix: include: - - group: post - files: tests/postFormTest.spec.ts tests/postFormSettingsTest.spec.ts + # 105 tests — the single biggest spec, runs alone. + - group: fields + files: tests/fieldOptionSettingsTest.spec.ts + extra_plugins: '' + # 94 tests — second biggest spec, also alone. + - group: post-settings + files: tests/postFormSettingsTest.spec.ts + extra_plugins: '' + # 93 tests. RF0009-RF0011 drive the WC Vendors / WCFM vendor forms and + # EM0001-EM0005 the MailPoet subscribe-on-registration path. - group: registration files: tests/regFormTestPro.spec.ts tests/regFormSettingsTestPro.spec.ts tests/frontendLoginTest.spec.ts tests/mailpoetRegistrationTestPro.spec.ts - - group: fields-subscription - files: tests/fieldOptionSettingsTest.spec.ts tests/subscriptionTest.spec.ts + extra_plugins: 'wc-vendors wc-multivendor-membership mailpoet' + # 82 tests. postFormTest submits to the EDD `download` post type. + - group: post-forms + files: tests/postFormTest.spec.ts tests/postFormDisplayAdvancedTest.spec.ts tests/postFormFieldBehaviourTest.spec.ts tests/postFormLifecycleTest.spec.ts tests/postFormGuestSetupTest.spec.ts tests/postFormSecurityTest.spec.ts + extra_plugins: 'easy-digital-downloads' + # 72 tests — form builder + every field type. + - group: form-editor + files: tests/formEditorTest.spec.ts tests/allFieldTypesTest.spec.ts + extra_plugins: '' + # 67 tests — subscriptions, pay-per-post, expiration, view control. + - group: monetization + files: tests/subscriptionTest.spec.ts tests/postFormPricingTestPro.spec.ts tests/postFormExpirationTestPro.spec.ts tests/postFormViewControlTestPro.spec.ts + extra_plugins: '' steps: # Setup PHP @@ -183,6 +212,54 @@ jobs: # unzip the-events-calendar.latest-stable.zip # rm the-events-calendar.latest-stable.zip + # Per-group wp-env plugin set. Every mapped plugin boots on EVERY WordPress + # request, so a job that never touches MailPoet/EDD/WC-Vendors should not + # pay for loading them on all ~500 admin page loads. + # + # The base set is what the shared setup project (alphaSetupTest) needs and + # is therefore non-negotiable in every group: + # - wp-user-frontend + wpuf-pro : the plugins under test + # - wp-mail-log : LS0069/PFS notification-email assertions + # - dokan-lite : LS0030 activates it in setup + # - woocommerce : dokan-lite hard-requires it + # `matrix.extra_plugins` adds the group-specific ones on top. + # + # wp-env's merge replaces the `plugins` array wholesale but Object.assign's + # the `config` object, so `.wp-env.json`'s phpVersion + WP_MEMORY_LIMIT + + # DISABLE_WP_CRON still apply. Local runs are untouched: this file is only + # written in CI and is gitignored. + - name: Write per-group wp-env plugin override + working-directory: tests/e2e + env: + EXTRA_PLUGINS: ${{ matrix.extra_plugins }} + run: | + node -e ' + const fs = require( "fs" ); + const base = [ + "../../", + "../../plugins/wpuf-pro", + "../../plugins/wp-mail-log", + "../../plugins/woocommerce", + "../../plugins/dokan-lite", + ]; + const extra = ( process.env.EXTRA_PLUGINS || "" ) + .split( /\s+/ ) + .filter( Boolean ) + .map( ( name ) => "../../plugins/" + name ); + const plugins = base.concat( extra ); + for ( const p of plugins ) { + if ( ! fs.existsSync( p ) ) { + throw new Error( "wp-env plugin path missing: " + p ); + } + } + fs.writeFileSync( + ".wp-env.override.json", + JSON.stringify( { plugins }, null, 2 ) + "\n" + ); + ' + echo "--- .wp-env.override.json (${{ matrix.group }}) ---" + cat .wp-env.override.json + # Start wordpress environment - name: Start WordPress Env and Show URL id: wp-env @@ -247,8 +324,10 @@ jobs: # the full suite (3x the work, past the 120-min budget with workers:1). npm run test:setup:ci npx playwright test --config=playwright.config.ts --project=e2e ${{ matrix.files }} - # REST layer is browserless and quick; run it once, in the post group. - if [ "${{ matrix.group }}" = "post" ]; then npm run test:api:ci; fi + # REST layer is browserless and quick; run it once, in the post-forms + # group. Must name a group that exists in the matrix above or the API + # suite silently never runs. + if [ "${{ matrix.group }}" = "post-forms" ]; then npm run test:api:ci; fi # continue-on-error: true # Upload this shard's blob report so the merge job can combine all shards diff --git a/docs/post-form.md b/docs/post-form.md new file mode 100644 index 000000000..fff026593 --- /dev/null +++ b/docs/post-form.md @@ -0,0 +1,506 @@ +# WPUF Post Form — functionality, features, and test plan + +Reference build: form **#10992 "Sample Form"** on `dokantesting.test` +(`wp-admin/admin.php?page=wpuf-post-forms&action=edit&id=10992`), WPUF + WPUF Pro, +new (Vue) form-editor UI. + +> State when documented: form #10992 has **no fields saved** — the canvas opens on the +> "Add fields and build your desired form" empty state. Any test that assumes fields +> exist must build them first. + +--- + +## 1. Purpose + +A **post form** lets a site visitor create and edit WordPress content **from the frontend**, +without ever seeing wp-admin. The admin designs the form in the builder; WPUF renders it on a +page via shortcode and maps each field to a post property (`post_title`, `post_content`, +taxonomy) or to post meta (custom fields). + +Typical uses: guest post submission, classifieds/listings, directory entries, vendor product +submission (Dokan), job boards, membership-gated content, pay-per-post. + +Rendering entry points (`includes/Frontend/Shortcode.php`): + +| Shortcode | What it does | +|---|---| +| `[wpuf_form id="10992"]` | Renders the form for **new** submissions | +| `[wpuf_edit]` | Renders the same form in **edit** mode (`?pid=`) | +| `[wpuf_dashboard]` | Frontend list of the user's posts, with Edit/Delete actions | + +The `#10992` chip beside the form title in the editor copies that ID/shortcode. + +--- + +## 2. Editor anatomy + +Header: form title (+ dropdown), form-ID copy chip, **Preview**, **Save**. +Two top-level tabs: **Form Editor** and **Settings**. + +### 2.1 Form Editor + +- **Canvas** (left) — the live field stage. Each field row exposes on hover: drag handle, + **Edit**, **Copy**, **Remove**. Fields reorder by drag. +- **Add Fields** panel (right) — searchable field catalogue, grouped (see §3). Click adds + the field to the end of the canvas. +- **Field Options** panel (right) — options of the currently selected field (click a field + or its **Edit** action). + +First time a *custom* field is added, an info modal appears: *"Do you want to show custom +field data inside your post?"* → points to **WPUF → Settings → Frontend Posting → Show +custom fields on post content area** plus the per-field **Show Data in Post** option. +Buttons: **Okay** / **Don't show again**. + +### 2.2 Field Options — common option set + +Basic (varies by field type): + +| Option | Applies to | Notes | +|---|---|---| +| Field Label | all | printed as `data-label` on the frontend row | +| Meta Key | custom fields only | the `post_meta` key the value is stored under | +| Help text | all | hint under the control | +| Required | custom fields | Yes/No; post fields like Post Title are required implicitly | +| Read Only | custom fields | renders disabled control | +| Show Icon | all | Yes/No | + +Advanced Options (collapsed by default): + +- Placeholder text, Default value +- Content restriction — restricted type (**Minimum** / **Maximum**) × restricted by + (**Character** / **Word**) × count +- Field Size — Small / Medium / Large +- CSS Class Name +- **Show Data in Post** (custom fields) and **Hide Field Label in Post** +- **Visibility** — Everyone / Hidden / Logged-in users only / Subscription users only +- **Conditional Logic** — Yes/No, then show/hide this field based on another field's value + +--- + +## 3. Field catalogue + +Matches `tests/e2e/utils/fieldTypes.ts` (slug = builder `data-form-field`). ★ = Pro. + +**Post Fields** — Post Title, Post Content, Post Excerpt, Featured Image +**Taxonomies** — Category (`taxonomy`), Tags (`post_tags`) +**Custom Fields** — Text, Textarea, Dropdown, Multi Select, Radio, Checkbox, Website URL, +Email Address, Hidden Field, Image Upload, Repeat Field ★, Date / Time ★, Time Field ★, +File Upload ★, Country List ★, Numeric Field ★, Phone Field ★, Address Field ★, +Google Map ★, Step Start ★, Embed ★ +**Pricing Fields** ★ — Price, Pricing Checkbox, Pricing Radio, Pricing Dropdown, +Pricing Multi-Select, Cart Total +**Others** — Columns ★, Section Break, Custom HTML, reCaptcha, Cloudflare Turnstile, +Shortcode, Action Hook, Terms & Conditions ★, Ratings ★, Really Simple Captcha ★, +Math Captcha ★ + +Backend classes live in `includes/Fields/` (`Form_Field_*`, all implementing +`Field_Contract`); Pro field classes ship with wpuf-pro. + +Environment-dependent (won't render without external config): Google Map (Maps JS + Places +key, referer allowlist), reCaptcha / Cloudflare Turnstile / Really Simple Captcha (keys or +companion plugin), Cart Total (payments). + +Structural fields render no labelled row: Hidden Field, Step Start, Columns, Shortcode, +Action Hook. + +--- + +## 4. Settings tab + +Sidebar: **General · Payment Settings · Notification Settings · Display Settings · Advanced · +Post Expiration · AI Review · N8N · Modules**. Every panel has **Cancel** / **Save Form**. + +### 4.1 General + +**Before Post Settings** — behaviour up to submission: +- Show Form Title, Form Description +- **Post Type** (default `post`; any registered CPT) +- Default Categories +- **Successful Redirection** — Newly created post / Same page / Another page / To a URL +- **Post Submission Status** — Published / Draft / Pending / Private +- Enable saving as draft +- Submit Post Button Text +- Choose Form Template +- **Enable Multi-Step** (pairs with the Step Start field) + +**After Post Settings** — behaviour on edit/update: +- Post Update Status (Published / Draft / Pending / Private / No change) +- Successful Redirection (default: same page) +- Post Update Message +- **Lock User Editing After** *N* Hours +- Update Post Button Text + +**Posting Control** — `post_permission`: who may submit. Includes guest posting +(`guest_post`, handled in `Frontend_Form::publish_guest_post()` with email verification) +and role-based restriction. + +**View Control** — who may *see* the rendered form: +- Restrict by user roles → allowed roles + Unauthorized Message (Roles) +- Restrict by subscription packs → allowed packs + Unauthorized Message (Subscription) + +### 4.2 Payment Settings +- Enable Payments (pay-per-post) +- Enable Pricing Fields Payment + +### 4.3 Notification Settings +- **New Post Notification** — enable, To, Subject, Email Body +- **Update Post Notification** — enable + same fields + +Placeholders usable in To/Subject/Body: `{post_title}`, `{post_content}`, `{post_excerpt}`, +`{tags}`, `{category}`, `{author}`, `{author_email}`, `{author_bio}`, `{sitename}`, +`{siteurl}`, `{permalink}`, `{editlink}`, `{custom_{META_KEY}}` (e.g. `{custom_website_url}`). + +### 4.4 Display Settings +- Choose Form Style (form-template gallery) +- Use Theme CSS +- Label Position — Above Element / left / right / hidden + +### 4.5 Advanced +- Enable User Comment (comment status on created post: Open/Closed) +- Enable Form Scheduling (form active between two dates) +- Limit Form Entries (max submissions) +- Conditional Logic on Submit Button (Yes/No) + +### 4.6 Post Expiration +Enable Post Expiration → Expiration Time + Duration Type (Day(s)/Month(s)/Year(s)) → +Post Status after expiry (default Draft) → Send post expiration email to author. + +### 4.7 AI Review +"Review Submitted Posts by AI" — routes submissions through the AI moderation flow +(`includes/AI/`). + +### 4.8 N8N +Enable N8N Integration — POSTs submitted post data to an n8n webhook. + +### 4.9 Modules +Lists WPUF modules affecting this form. On #10992: *"No modules have been activated yet."* +with a **Go To Module Page** link. + +--- + +## 5. End-to-end flow + +1. Admin builds form → **Save**. +2. Admin places `[wpuf_form id="10992"]` on a page (setup wizard creates a default one). +3. Visitor opens the page → View Control decides render vs unauthorized message. +4. Visitor fills fields → validation (required, content restriction, captcha, conditional + logic) → **Submit**. +5. WPUF creates the post with **Post Submission Status**, maps post fields + taxonomies, + writes custom fields to post meta, fires notification email, applies payment / + expiration / AI review if enabled. +6. Redirect per **Successful Redirection**. +7. Later edits go through `[wpuf_dashboard]` → Edit (`[wpuf_edit]` page, `?pid=`), governed + by **Post Update Status**, **Lock User Editing After**, and Update notification. + +⚠️ The dashboard Edit link needs **Settings → Frontend Posting → Edit Page** set, or it +links back to the post itself (see `BUGS-FOUND.md` §20). + +--- + +## 6. Test plan + +Existing coverage lives in `tests/e2e/` (IDs in `features-map/features-map.yml`); post-form +settings already have many mapped cases — check there before adding new ones. Below is the +full surface with what to assert. + +### 6.1 Builder (editor UI) + +| # | Case | Assert | +|---|---|---| +| B1 | Open form editor by ID | title, `#10992` chip, Form Editor / Settings tabs render | +| B2 | Empty state | "Add fields and build your desired form" shown when no fields | +| B3 | Add every field type | each catalogue entry lands on canvas with expected `data-form-field` slug | +| B4 | Field search box | typing filters the panel; no-match state | +| B5 | Copy field | duplicate appears with a fresh meta key | +| B6 | Remove field | row disappears; Save persists removal | +| B7 | Drag reorder | order persists after Save + reload | +| B8 | Custom-field info modal | shows on first custom field; **Don't show again** suppresses it | +| B9 | Save + reload | every field and option round-trips | +| B10 | Preview | opens the rendered form for the current (saved) state | +| B11 | Pro-gated fields | on Lite, Pro fields are absent/upsell — no JS error | + +### 6.2 Field options + +| # | Case | Assert | +|---|---|---| +| F1 | Field label | frontend row shows the label / `data-label` | +| F2 | Meta key | value stored under that `post_meta` key after submit | +| F3 | Required = Yes | submitting empty blocks with validation message | +| F4 | Read only | control rendered disabled; value not writable | +| F5 | Help text / Placeholder / Default value | rendered on the frontend control | +| F6 | Content restriction min/max × character/word | under/over limit blocks submit | +| F7 | Field size S/M/L + CSS class | class applied on frontend markup | +| F8 | Show Data in Post / Hide Field Label in Post | meta printed (or not) in post content | +| F9 | Visibility: Hidden / Logged-in only / Subscription only | field hidden for the wrong audience | +| F10 | Conditional logic | field shows/hides as the controlling field changes | + +### 6.3 Settings — General + +| # | Case | Assert | +|---|---|---| +| G1 | Show Form Title / Description | rendered on frontend | +| G2 | Post Type = CPT | submission creates that CPT | +| G3 | Default categories | pre-selected + applied when field absent | +| G4 | Redirection: same page / new post / another page / URL | landing URL after submit | +| G5 | Submission status: publish / draft / pending / private | post status in wp-admin list | +| G6 | Save as draft | draft button appears; draft created, not published | +| G7 | Submit button text | button label matches | +| G8 | Multi-step + Step Start | steps navigate; validation per step | +| G9 | Update status + update message + update button text | on edit flow | +| G10 | Lock User Editing After N hours | edit blocked once the window passes | +| G11 | Posting control: guest post | guest can submit; email-verification flow publishes | +| G12 | Posting control: role restriction | disallowed role blocked | +| G13 | View control: roles / subscription packs | correct unauthorized message shown | + +### 6.4 Settings — other panels + +| # | Case | Assert | +|---|---|---| +| P1 | Enable Payments (pay-per-post) | checkout appears; post goes live only after payment | +| P2 | Pricing fields payment | Cart Total sums pricing fields; charged amount matches | +| N1 | New post notification | email sent to `To`, subject/body placeholders resolved | +| N2 | Update post notification | email on edit only, not on create | +| N3 | `{custom_}` placeholder | resolves to the submitted value | +| D1 | Form style / template | matching CSS class on the frontend form | +| D2 | Use Theme CSS | WPUF stylesheet dropped | +| D3 | Label position | label placement in DOM/CSS matches setting | +| A1 | Enable user comment | created post has comments open/closed accordingly | +| A2 | Form scheduling | before start / after end → form unavailable message | +| A3 | Limit form entries | submit blocked at the cap | +| A4 | Conditional logic on submit button | submit disabled until condition met | +| E1 | Post expiration | after the window, post flips to the configured status | +| E2 | Expiration email | author notified | +| AI1 | AI review enabled | submission held for AI moderation | +| X1 | N8N integration | webhook receives the submission payload | +| M1 | Modules panel | empty state + Go To Module Page link | + +### 6.5 Regression / environment notes + +- Google Map, reCaptcha, Turnstile, Really Simple Captcha, Cart Total are **environment + dependent** — report/skip rather than fail when unconfigured (see `tests/e2e/CLAUDE.md`). +- Repeat Field is a known defect: saved as `input_type "repeat"` but registered as + `repeat_field`, so it never renders (`BUGS-FOUND.md`). +- Frontend dashboard Edit requires **Settings → Frontend Posting → Edit Page** + (`BUGS-FOUND.md` §20) — set it in setup before dashboard-edit tests. +- Free/Pro split: gate every ★ case behind a Pro check so Lite runs stay green. + +--- + +## 7. Coverage gaps (audited against `features-map/features-map.yml` + `tests/`) + +Existing specs: `postFormTest`, `postFormSettingsTest`, `fieldOptionSettingsTest`, +`formEditorTest`, `allFieldTypesTest`, `subscriptionTest`, `api/wpufRestApi`. +What is **not** covered today: + +### 7.1 Settings panels with zero coverage + +| Gap | Why it matters | Suggested case | +|---|---|---| +| **AI Review** panel | whole feature untested | enable → submit → post held for AI moderation, status/meta reflects review | +| **N8N integration** | webhook never exercised | enable + point at a local receiver → assert payload shape on submit | +| **Modules** panel | empty state + link untested | assert empty state text and **Go To Module Page** target | +| **Display → Use Theme CSS** | style regressions invisible | toggle → WPUF stylesheet absent/present on frontend | +| **Display → Label Position** | 4 positions, none tested | above / left / right / hidden → DOM/CSS placement | +| **Advanced → Form Scheduling** | date-window logic untested | before start, inside window, after end → correct message vs form | +| **Payment → Enable Pricing Fields Payment** | only pay-per-post covered | pricing fields + Cart Total → charged amount matches sum | +| **View Control (both)** | restrict-by-role and restrict-by-subscription never tested | allowed vs disallowed viewer → correct unauthorized message | + +### 7.2 Mapped but only half-tested + +| Gap | Current state | Missing assertion | +|---|---|---| +| **Post Expiration** | only "Admin is enabling post expiration" | post actually flips to configured status at expiry; duration types (day/month/year); expiration email to author | +| **Lock User Editing After N hours** | setting is written | edit blocked once the window passes (and allowed before) | +| **Guest posting** | guest submits + post validated | email-verification path: verify link publishes the post, admin notified after verification (`Frontend_Form::publish_guest_post`) | +| **Choose Form Template** | one preset flow | each shipped template produces its expected field set | +| **Default categories** | validated from FE | applied when the Category field is *absent* from the form | +| **Post type** | post / WC product / download | a plain custom CPT (non-WooCommerce) | +| **Copy field** | duplicate appears | duplicate gets a unique meta key (collision = silent data overwrite) | + +### 7.3 Field-level gaps + +| Gap | Missing | +|---|---| +| Section Break / Custom HTML | HTML actually rendered on the frontend, and escaped where it should be | +| Shortcode field | the embedded shortcode is executed on the rendered form | +| Action Hook field | `do_action( '' )` fires — attach a probe and assert output | +| Columns ★ | dropping fields into columns, column layout on the frontend | +| Terms & Conditions ★ | required-accept blocks submit (only "Open in New Window" is covered) | +| Ratings ★ | option set + submitted rating value stored in meta | +| Embed ★ | option set + rendered embed | +| Hidden Field | value persisted to post meta after submit | +| File Upload ★ | allowed file types / max size rejection (only Max Files covered) | +| Checkbox / Radio / Multi Select | option-list editing (only Dropdown options are covered), inline-list, default selections | +| Repeat Field ★ | still `test.fail()` on a known bug — needs a real case once fixed | + +### 7.4 Lifecycle / frontend gaps + +- **Authorization on edit**: opening `[wpuf_edit]?pid=` for a post you don't own — must be rejected. Untested. +- **Draft resume**: save-as-draft → reopen from dashboard → publish. Only the draft creation is covered. +- **Multi-step validation**: Next blocked while a required field on the current step is empty; back navigation retains values. +- **Conditional logic depth**: per-field multi-condition and chained (field A → B → C); only submit-button any/all and a single field rule are covered. +- **Form list screen**: duplicate form, trash/restore, search, bulk actions — **no coverage at all**. + +### 7.5 Security / API gaps + +- No REST/AJAX coverage for **post-form CRUD** — only `GET /wpuf_form` list. Missing: form save with a bad/missing nonce, save as a non-admin (capability check), malformed field payload. +- XSS coverage is limited to the **form title**. Field label, help text, placeholder, Custom HTML and the unauthorized messages are unverified. +- Captcha fields (reCaptcha, Turnstile, Really Simple Captcha) are env-gated and therefore effectively **unverified** — needs a CI environment with test keys, or the spam path stays untested. + +### 7.6 Priority + +1. View Control (roles + subscription) — user-visible access control, zero coverage. +2. Edit authorization (`pid` of another user's post) — security. +3. Post form save nonce/capability negative tests — security. +4. Post Expiration end-to-end + Lock User Editing — silent data/behaviour bugs. +5. Form Scheduling, Label Position, Use Theme CSS — cheap, purely deterministic. +6. Form list screen actions (duplicate/trash/search). +7. Pricing fields payment + Cart Total. +8. AI Review / N8N — new subsystems, currently untested. + +--- + +## 8. Test cases for the §7 gaps + +IDs use the `PFG####` (post-form gap) prefix and are registered in +`tests/e2e/features-map/features-map.yml`. **Pri** = 1 (do first) … 3 (nice to have). +Shared preconditions unless stated otherwise: admin logged in, a saved post form with +Post Title + Post Content + one Text custom field, and a page holding +`[wpuf_form id="
"]`. + +**Implemented in** (`tests/e2e/tests/`, page object `pages/postFormGaps.ts`): + +| Cases | Spec | +|---|---| +| PFG0000–0006 | `postFormViewControlTestPro.spec.ts` | +| PFG0010–0023, 0040–0044 | `postFormDisplayAdvancedTest.spec.ts` | +| PFG0030–0033 | `postFormPricingTestPro.spec.ts` | +| PFG0050–0056 | `postFormExpirationTestPro.spec.ts` | +| PFG0060–0067 | `postFormGuestSetupTest.spec.ts` | +| PFG0070–0081 | `postFormFieldBehaviourTest.spec.ts` | +| PFG0090–0099 | `postFormLifecycleTest.spec.ts` | +| PFG0100–0107 | `postFormSecurityTest.spec.ts` | + +Side effects with no UI surface (post meta, expiry cron, seeded roles/packs) go through +`utils/wpEnvCli.ts`; those tests self-skip when no wp-env CLI container is reachable, the +same way the Pro- and key-gated cases self-skip. + +### 8.1 View Control (§7.1) — Pri 1 + +| ID | Case | Steps | Expected | +|---|---|---|---| +| PFG0001 | Restrict by roles — allowed role sees the form | Settings → General → View Control → **Restrict by user roles** on, allow `editor` → Save → log in as an editor → open the form page | Form renders normally; submit works | +| PFG0002 | Restrict by roles — disallowed role blocked | Same setup → log in as a subscriber → open the form page | Form is not rendered; the configured **Unauthorized Message (Roles)** is shown verbatim | +| PFG0003 | Restrict by roles — logged-out visitor | Same setup → open the page logged out | Blocked, message shown, no form markup in DOM | +| PFG0004 | Restrict by subscription packs — subscriber with the pack | Create a free pack → View Control → **Restrict by subscription packs** on, select that pack → user buys the pack → open the form page | Form renders | +| PFG0005 | Restrict by subscription packs — user without the pack | Same setup → a user with no pack opens the page | Blocked with **Unauthorized Message (Subscription)** | +| PFG0006 | Both restrictions on | Roles = editor **and** pack = X → editor without the pack | Blocked (both gates must pass); message identifies the failing gate | + +### 8.2 Display Settings (§7.1) — Pri 2 + +| ID | Case | Steps | Expected | +|---|---|---|---| +| PFG0010 | Use Theme CSS off | Settings → Display → Use Theme CSS = off → Save → open form page | WPUF form stylesheet is enqueued (`wpuf-*.css` present) | +| PFG0011 | Use Theme CSS on | Toggle on → Save → reload form page | WPUF form stylesheet is not enqueued; markup unchanged | +| PFG0012 | Label Position = Above Element | Set → Save → frontend | Label node precedes the control in DOM / layout class matches | +| PFG0013 | Label Position = Left / Right | Set each → Save → frontend | Corresponding label-position class applied; label renders on that side | +| PFG0014 | Label Position = Hidden | Set → Save → frontend | No visible label text (still accessible to screen readers if aria/label kept) | +| PFG0015 | Form style selection | Pick a non-default form style → Save → reload editor → frontend | Style persists in the editor and the frontend wrapper carries the style's class | + +### 8.3 Advanced → Form Scheduling (§7.1) — Pri 2 + +| ID | Case | Steps | Expected | +|---|---|---|---| +| PFG0020 | Inside the schedule window | Advanced → Enable Form Scheduling, start = yesterday, end = tomorrow → Save → open form page | Form renders; submit succeeds | +| PFG0021 | Before the window opens | Start = tomorrow, end = +7 days → Save → open form page | Form hidden; "form is not available / scheduled" message shown | +| PFG0022 | After the window closes | Start = −7 days, end = yesterday → Save → open form page | Form hidden; expired-schedule message shown | +| PFG0023 | Scheduling off | Disable → Save → open form page | Form always renders regardless of the stored dates | + +### 8.4 Payment — pricing fields (§7.1) — Pri 2 ★Pro + +| ID | Case | Steps | Expected | +|---|---|---|---| +| PFG0030 | Enable Pricing Fields Payment | Add Price + Pricing Checkbox + Cart Total → Payment Settings → Enable Pricing Fields Payment → Save | Setting persists after reload; frontend renders the pricing fields and a Total | +| PFG0031 | Cart Total sums selections | Frontend: select two priced options | Cart Total updates live to the sum | +| PFG0032 | Charged amount matches the cart | Submit → payment screen | Amount due equals Cart Total; post stays unpublished until payment is accepted | +| PFG0033 | Pricing payment disabled | Turn the setting off → submit | No payment step; post created directly | + +### 8.5 AI Review / N8N / Modules (§7.1) — Pri 3 + +| ID | Case | Steps | Expected | +|---|---|---|---| +| PFG0040 | AI Review enabled | Settings → AI Review → Review Submitted Posts by AI on → Save → submit a post from the frontend | Setting persists; submission routed through AI moderation — post status / review meta reflects the verdict, no PHP notice in the log | +| PFG0041 | AI Review disabled | Toggle off → submit | Normal flow, no review meta written | +| PFG0042 | N8N enabled | Settings → N8N → enable, webhook URL = local receiver → Save → submit a post | Receiver gets one POST; payload contains post ID, title and the custom-field values | +| PFG0043 | N8N bad/unreachable URL | Set an unreachable URL → submit | Submission still succeeds (post created); failure is logged, not fatal | +| PFG0044 | Modules panel empty state | Settings → Modules on a site with no modules active | "No modules have been activated yet." + **Go To Module Page** links to the WPUF Modules screen | + +### 8.6 Post Expiration & edit lock (§7.2) — Pri 1 + +| ID | Case | Steps | Expected | +|---|---|---|---| +| PFG0050 | Expiration settings persist | Post Expiration → enable, time = 1, Day(s), status = Draft, email on → Save → reload | All four values round-trip | +| PFG0051 | Post flips status at expiry | Submit a post, then move the expiration date into the past (meta edit) and run the expiry cron | Post status becomes the configured one (Draft); it is no longer publicly visible | +| PFG0052 | Duration types | Repeat PFG0051 for Month(s) and Year(s) | Stored expiry date matches the chosen unit | +| PFG0053 | Expiration email | With "Send post expiration email to author" on, trigger expiry | Author receives the expiration mail once | +| PFG0054 | Expiration disabled | Disable → submit | No expiry meta written; post stays published | +| PFG0055 | Lock editing — inside the window | Lock User Editing After = 2 hours → submit a post → open it from the dashboard immediately | Edit form loads and updates save | +| PFG0056 | Lock editing — after the window | Same form, backdate the post beyond the lock → open Edit from the dashboard | Editing blocked with the lock message; no edit form rendered | + +### 8.7 Guest posting & form setup (§7.2) — Pri 1–2 + +| ID | Case | Steps | Expected | +|---|---|---|---| +| PFG0060 | Guest submission pends until verified | Posting Control = guest post, require verification → submit as a guest with a valid email | Post is created but not published; verification mail sent to the guest | +| PFG0061 | Verification link publishes | Open the verification link from the mail | Post moves to the configured submission status; guest user created/linked as author | +| PFG0062 | Admin notified after verification | Same flow with New Post Notification on | Admin mail fires **after** verification, not at submit time (`wpuf_guest_post_email_verified`) | +| PFG0063 | Invalid/expired verification link | Tamper with the activation key | Rejected; post stays unpublished | +| PFG0064 | Form template presets | Create a form from each shipped template | Each produces its documented field set and settings | +| PFG0065 | Default category with no Category field | Set Default Categories, remove the Category field → submit | Post lands in the default category | +| PFG0066 | Plain custom post type | Register a test CPT → Post Type = that CPT → submit | Post created under the CPT; appears in its admin list | +| PFG0067 | Copy field meta key uniqueness | Add a Text field with meta key `foo` → **Copy** → Save → submit with different values | Duplicate carries a distinct meta key; both values stored, neither overwritten | + +### 8.8 Field behaviour (§7.3) — Pri 2 + +| ID | Case | Steps | Expected | +|---|---|---|---| +| PFG0070 | Section Break renders | Add Section Break with title + description → frontend | Title/description printed; a separator wraps the following fields | +| PFG0071 | Custom HTML renders and is scoped | Custom HTML = `
hi
` → frontend | Element present; a `` in each → frontend | Escaped as text, script never executes | +| PFG0105 | XSS in unauthorized messages | Script payload in the roles/subscription unauthorized message → blocked viewer | Escaped when displayed | +| PFG0106 | XSS in Custom HTML field | Script payload in Custom HTML | Documented behaviour asserted explicitly (allowed for admins / stripped) — pick one and lock it in | +| PFG0107 | Captcha env-gated spam path | With test keys configured in CI, submit with a missing/invalid captcha token | Submission rejected; with a valid token it succeeds. Skip-with-report when keys are absent | diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore index c42b738be..bfe5564f5 100644 --- a/tests/e2e/.gitignore +++ b/tests/e2e/.gitignore @@ -1,2 +1,7 @@ # Saved logged-in sessions (Playwright storageState) — never commit credentials/cookies. .auth/ + +# Per-group wp-env plugin set, generated by the e2e workflow (and handy locally +# for trimming plugins). `.wp-env.json` stays the committed default. +.wp-env.override.json +.playwright-mcp/ diff --git a/tests/e2e/.wp-env.json b/tests/e2e/.wp-env.json index c70c7eba7..2e1e5ec97 100644 --- a/tests/e2e/.wp-env.json +++ b/tests/e2e/.wp-env.json @@ -1,8 +1,9 @@ { - "phpVersion": "7.4", + "phpVersion": "8.2", "config": { "WP_MEMORY_LIMIT": "512M", - "WP_MAX_MEMORY_LIMIT": "512M" + "WP_MAX_MEMORY_LIMIT": "512M", + "DISABLE_WP_CRON": true }, "plugins": [ "../../", @@ -15,4 +16,4 @@ "../../plugins/wc-multivendor-membership", "../../plugins/mailpoet" ] -} \ No newline at end of file +} diff --git a/tests/e2e/.wp-env.override b/tests/e2e/.wp-env.override index 1499621b6..cfafbc246 100644 --- a/tests/e2e/.wp-env.override +++ b/tests/e2e/.wp-env.override @@ -1,5 +1,5 @@ { - "phpVersion": "7.4", + "phpVersion": "8.2", "plugins": [ "../../../../", "../../../wp-user-frontend", diff --git a/tests/e2e/features-map/features-map.yml b/tests/e2e/features-map/features-map.yml index bfdbf2c4b..e31c1cff9 100644 --- a/tests/e2e/features-map/features-map.yml +++ b/tests/e2e/features-map/features-map.yml @@ -851,3 +851,310 @@ features: name: Lost password for a known user reaches the mailer (env-gated skip without SMTP) - id: FL0006 name: Valid credentials log the user in (logged-in view shown) + + # New post form editor (builder chrome + frontend output) — formEditorTest.spec.ts + - id: FE0000 + name: Admin creates a blank post form to exercise the editor + - id: FE0001 + name: Editor shell renders header, tabs, stage and side panel + - id: FE0002 + name: Inline form title rename persists after reload + - id: FE0004 + name: Form switcher navigates to another form editor + - id: FE0005 + name: Form id chip matches the id in the URL + - id: FE0006 + name: Preview link renders the form on the preview page + - id: FE0007 + name: Save persists the stage field set + - id: FE0008 + name: Form Editor / Settings tabs toggle their panels + - id: FE0010 + name: Add Fields panel lists every field group + - id: FE0011 + name: Field search filters the panel and clearing restores it + - id: FE0012 + name: Search with no match shows no field buttons + - id: FE0013 + name: Clicking a field button adds it to the stage + - id: FE0016 + name: Single-instance post fields cannot be added twice + - id: FE0017 + name: Hover reveals Edit / Copy / Remove on a stage field + - id: FE0018 + name: Edit switches the side panel to Field Options + - id: FE0019 + name: Copy duplicates a field on the stage + - id: FE0020 + name: Remove deletes a field from the stage + - id: FE0021 + name: Drag reorder persists after save + - id: FE0022 + name: Required field shows the asterisk on the stage + - id: FE0023 + name: Step Start renders as a multistep divider + - id: FE0032 + name: Settings sidebar lists every section + - id: FE0033 + name: Clicking a settings section renders its panel + - id: FE0034 + name: Post settings (submit text) persist after save + - id: FE0040 + name: Frontend form renders with the selected layout class + - id: FE0046 + name: Frontend submit creates the post + - id: FE0047 + name: Required validation blocks an empty frontend submit + - id: FE0003 + name: Empty form title is not persisted (known bug, test.fail) + - id: FE0009 + name: Leaving a dirty editor raises the unsaved-changes guard + - id: FE0014 + name: Dragging a panel field onto the stage adds it + - id: FE0015 + name: Pro-only fields are shown gated with the pro badge + - id: FE0024 + name: Field Options shows an empty state on a form with no fields + - id: FE0025 + name: Label edit updates the stage label live + - id: FE0026 + name: Meta key is locked for post fields + - id: FE0027 + name: Help text edit shows under the field on the stage + - id: FE0028 + name: Required toggle adds and removes the asterisk + - id: FE0029 + name: Field size option applies the size class on the stage + - id: FE0030 + name: Dropdown options render on the stage + - id: FE0031 + name: Conditional logic is offered once more than one field exists + - id: FE0035 + name: Multistep toggle reveals the progress bar options + - id: FE0036 + name: Form style (layout) selection persists + - id: FE0037 + name: Payment settings persist after save + - id: FE0038 + name: Notification settings persist after save + - id: FE0039 + name: Form title and description toggles reach the frontend + - id: FE0041 + name: Form title and description render on the template + - id: FE0042 + name: Field help text renders under the field + - id: FE0043 + name: Multistep navigation works on the frontend + - id: FE0044 + name: Native selects use the template chevron + - id: FE0045 + name: Image field with a single-file limit blocks a second upload + - id: FE0048 + name: A subscriber cannot open the form builder + - id: FE0049 + name: An unknown form id does not break the editor page + - id: FE0050 + name: A script payload in the form title is escaped on the frontend + + # All field types (one form with every field) — allFieldTypesTest.spec.ts + - id: AF0001 + name: Every field type in the catalogue is added to one form + - id: AF0002 + name: Post Fields render on the stage + - id: AF0003 + name: Taxonomy fields render on the stage + - id: AF0004 + name: Custom fields render on the stage + - id: AF0005 + name: Pricing fields render on the stage + - id: AF0006 + name: Others fields render on the stage + - id: AF0007 + name: The field set survives a save + reload + - id: AF0008 + name: Frontend renders a row and control for every core field type + - id: AF0009 + name: Frontend renders the multistep wrapper for Step Start + - id: AF0010 + name: Environment-dependent fields are reported (captchas, map, cart total) + - id: AF0011 + name: Every rendered field accepts input and the form submits + - id: AF0012 + name: The submitted post exists with the entered title + - id: AF0013 + name: Field values round-trip through the WPUF edit form + - id: AF0014 + name: Repeat Field renders on the frontend (known bug, test.fail) + - id: AF0015 + name: No field type renders more than one row (known bug, test.fail) + - id: AF0016 + name: An empty submit is blocked by the required-field rules (known bug with Math Captcha) + - id: AF0017 + name: Email Address rejects a malformed address + - id: AF0018 + name: Website URL rejects a malformed URL + - id: AF0019 + name: Numeric Field only accepts numbers + - id: AF0020 + name: Math Captcha rejects a wrong answer + - id: AF0021 + name: Step Start without multistep enabled keeps the form usable (known bug, test.fail) + + # Post-form coverage gaps (docs/post-form.md §7–§8) — PFG specs + # postFormViewControlTestPro.spec.ts + - id: PFG0000 + name: Setup — form, page and a post to guard + - id: PFG0001 + name: Restrict by roles — an allowed role sees the content + - id: PFG0002 + name: Restrict by roles — a disallowed role gets the message + - id: PFG0003 + name: Restrict by roles — a logged-out visitor is blocked + - id: PFG0004 + name: Restrict by packs — a subscriber holding the pack passes + - id: PFG0005 + name: Restrict by packs — a user without the pack is blocked + - id: PFG0006 + name: Both view-control gates on — passing one is not enough + + # postFormDisplayAdvancedTest.spec.ts + - id: PFG0010 + name: Use Theme CSS off keeps WPUF form styling + - id: PFG0011 + name: Use Theme CSS on hands styling to the theme + - id: PFG0012 + name: Label Position = Above Element + - id: PFG0013 + name: Label Position = Left / Right + - id: PFG0014 + name: Label Position = Hidden + - id: PFG0015 + name: Form style selection persists and reaches the frontend + - id: PFG0020 + name: Inside the schedule window the form is submittable + - id: PFG0021 + name: Before the window opens the pending notice shows + - id: PFG0022 + name: After the window closes the expired notice shows + - id: PFG0023 + name: Scheduling off restores the form + - id: PFG0040 + name: AI Review enabled writes a review record + - id: PFG0041 + name: AI Review disabled writes nothing + - id: PFG0042 + name: N8N enabled still lets the submission through + - id: PFG0043 + name: N8N with an unreachable webhook is not fatal + - id: PFG0044 + name: Modules panel shows its empty state and link + + # postFormPricingTestPro.spec.ts + - id: PFG0030 + name: Pricing-fields payment setting persists and the pricing rows render + - id: PFG0031 + name: Cart Total sums the selected pricing options + - id: PFG0032 + name: The charge matches the cart and the post waits for payment + - id: PFG0033 + name: With pricing payment off the post is created directly + + # postFormExpirationTestPro.spec.ts + - id: PFG0050 + name: Expiration settings round-trip through Save + - id: PFG0051 + name: An expired post flips to the configured status + - id: PFG0052 + name: Duration types write the matching expiration date + - id: PFG0053 + name: Expiration email is queued for the author + - id: PFG0054 + name: Expiration disabled writes no expiry metadata + - id: PFG0055 + name: Inside the edit window the post is still editable + - id: PFG0056 + name: After the edit window the edit form is refused + + # postFormGuestSetupTest.spec.ts + - id: PFG0060 + name: A guest submission is held until the email is verified + - id: PFG0061 + name: The verification link publishes the post + - id: PFG0062 + name: Admin notification fires after verification, not at submit + - id: PFG0063 + name: A tampered verification link is rejected + - id: PFG0064 + name: The Post Form template ships its documented field set + - id: PFG0065 + name: Default category applies when the form has no Category field + - id: PFG0066 + name: A non-WooCommerce post type is created as that type + - id: PFG0067 + name: Copying a field keeps the meta keys unique + + # postFormFieldBehaviourTest.spec.ts + - id: PFG0070 + name: Section Break prints its title and description + - id: PFG0071 + name: Custom HTML output reaches the rendered form + - id: PFG0072 + name: A Shortcode field is executed, not printed + - id: PFG0073 + name: An Action Hook field fires its hook + - id: PFG0074 + name: Columns render as a multi-column row + - id: PFG0075 + name: A required Terms & Conditions blocks submit + - id: PFG0076 + name: A Ratings value is stored on the post + - id: PFG0077 + name: An Embed value is stored on the post + - id: PFG0078 + name: Hidden Field writes its value to post meta + - id: PFG0079 + name: File Upload rejects a file over the size limit + - id: PFG0080 + name: Checkbox / Radio / Multi Select values round-trip + - id: PFG0081 + name: Repeat Field renders on the frontend (known bug, test.fail) + + # postFormLifecycleTest.spec.ts + - id: PFG0090 + name: Editing another user's post is refused + - id: PFG0091 + name: A bogus pid / missing nonce is handled, not fatal + - id: PFG0092 + name: A saved draft can be resumed and published + - id: PFG0093 + name: Multistep Next is blocked while a step is invalid + - id: PFG0094 + name: Multistep Back keeps what was already entered + - id: PFG0095 + name: Conditional logic chains A to B to C + - id: PFG0096 + name: A conditional field reappears when the rule is satisfied + - id: PFG0097 + name: Duplicating a form copies it under a new id + - id: PFG0098 + name: Trashing a form removes it from the default list + - id: PFG0099 + name: Searching the list filters by form name + + # postFormSecurityTest.spec.ts + - id: PFG0100 + name: Saving without a nonce is refused + - id: PFG0101 + name: Saving with an invalid nonce is refused + - id: PFG0102 + name: Saving as a non-admin is refused + - id: PFG0103 + name: A malformed field payload does not corrupt the form + - id: PFG0104 + name: Script payloads in label / help / placeholder are escaped + - id: PFG0105 + name: Script payloads in the unauthorized messages are escaped + - id: PFG0106 + name: Custom HTML renders as authored (documented behaviour) + - id: PFG0107 + name: A missing captcha answer blocks submit diff --git a/tests/e2e/pages/base.ts b/tests/e2e/pages/base.ts index 3ba15c1af..afd7b9502 100644 --- a/tests/e2e/pages/base.ts +++ b/tests/e2e/pages/base.ts @@ -77,7 +77,22 @@ export class Base { async navigateToURL(url: string) { try { await this.waitForLoading(); - await this.page.goto(url); + + try { + await this.page.goto(url); + } catch (error) { + // Chromium reports ERR_ABORTED when a still-settling page cancels + // the navigation (wp-admin redirects, a lingering unload handler). + // Nothing is wrong with the target — just ask for it again. + if (!String(error).includes('ERR_ABORTED')) { + throw error; + } + + console.log('\x1b[33m%s\x1b[0m', `↻ Navigation to ${url} aborted, retrying`); + await this.page.waitForTimeout(1000); + await this.page.goto(url); + } + await this.waitForLoading(); console.log('\x1b[34m%s\x1b[0m', `✅ Navigated to ${url}`); return true; @@ -150,6 +165,24 @@ export class Base { } } + /** + * Dismiss an open SweetAlert2 dialog, if any. + * + * The form builder pops "Oops... You already have this field in the form" + * when a field that is already on the form is clicked again. Its backdrop + * swallows every later click, so an undismissed dialog stalls the whole + * spec until the test times out. No-op when no dialog is showing. + */ + async dismissBlockingModal() { + const confirm = this.page.locator('//div[contains(@class,"swal2-container")]//button[contains(@class,"swal2-confirm")]').first(); + + if (await confirm.isVisible().catch(() => false)) { + await confirm.click(); + await this.page.locator('//div[contains(@class,"swal2-container")]').first() + .waitFor({ state: 'hidden' }).catch(() => {}); + } + } + // Validate and Click any async validateAndClickAny(locator: string) { try { diff --git a/tests/e2e/pages/fieldOptionSettings.ts b/tests/e2e/pages/fieldOptionSettings.ts index 5c82c423e..83d1eaa9f 100644 --- a/tests/e2e/pages/fieldOptionSettings.ts +++ b/tests/e2e/pages/fieldOptionSettings.ts @@ -817,7 +817,11 @@ export class FieldOptionSettingsPage extends Base { //Create Post await this.validateAndClick(Selectors.postForms.postFormsFrontendCreate.submitPostFormsFE); await this.page.waitForTimeout(2000); - await this.navigateToURL(this.postsPage); + // The publish-time post is backdated, and edit.php lists 20 rows sorted by + // date DESC — once the suite has created >20 posts the row falls onto page + // two and the assertion can never see it. Search for the exact title so the + // row is always on the page being asserted. + await this.navigateToURL(`${this.postsPage}?s=${encodeURIComponent(PostTitle)}`); await this.assertionValidate(Selectors.fieldOptionsSettings.fieldOptionsPanel.dateTimeOptions.validatePostPublishTime(PostTitle)); console.log(`Publish time validated`); } diff --git a/tests/e2e/pages/formEditor.ts b/tests/e2e/pages/formEditor.ts new file mode 100644 index 000000000..a6fb50a46 --- /dev/null +++ b/tests/e2e/pages/formEditor.ts @@ -0,0 +1,1161 @@ +import * as dotenv from 'dotenv'; +dotenv.config({ quiet: true }); +import { expect, request, type Page } from '@playwright/test'; +import { Selectors } from './selectors'; +import { Base } from './base'; +import { Urls } from '../utils/testData'; + +/** + * Page object for the revamped post form editor (admin builder chrome). + * + * Covers: header (inline title, form switcher, Preview, Save), the + * Form Editor / Settings tab pair, the Add Fields / Field Options side + * panel and the sortable field stage — plus the frontend rendering of a + * form built with it. + */ +export class FormEditorPage extends Base { + constructor( page: Page ) { + super( page ); + } + + // protected so PostFormGapsPage (pages/postFormGaps.ts) can reuse the same + // builder/frontend selectors instead of re-declaring them. + protected fe = Selectors.formEditor; + + /*********************************/ + /********* Header (shell) ********/ + /*********************************/ + + async openFormEditor( formId: string ) { + await this.navigateToURL( this.accessFormWithId + formId ); + // A blank form renders an empty
    stage (zero height), so wait for it + // to be attached rather than visible. + await this.page.waitForSelector( this.fe.tabs.editorStage, { state: 'attached' } ); + } + + async validateEditorShell() { + await this.assertionValidate( this.fe.header.logo ); + await this.assertionValidate( this.fe.header.titleInput ); + await this.assertionValidate( this.fe.header.formIdChip ); + await this.assertionValidate( this.fe.header.previewLink ); + await this.assertionValidate( this.fe.header.saveButton ); + await this.assertionValidate( this.fe.tabs.navTab( 'Form Editor' ) ); + await this.assertionValidate( this.fe.tabs.navTab( 'Settings' ) ); + await this.validateAttached( this.fe.tabs.editorStage ); + await this.assertionValidate( this.fe.panel.panelTab( 'Add Fields' ) ); + await this.assertionValidate( this.fe.panel.panelTab( 'Field Options' ) ); + } + + // The "#123" chip must match the id the editor was opened with. + async validateFormIdChip() { + const formId = await this.getFormId(); + const chip = ( await this.page.locator( this.fe.header.formIdChip ).first().innerText() ).trim(); + expect( chip.replace( '#', '' ) ).toBe( formId ); + } + + async doRenameForm( newName: string ) { + await this.validateAndFillStrings( this.fe.header.titleInput, newName ); + await this.page.locator( this.fe.header.titleInput ).press( 'Enter' ); + await this.doSaveForm(); + } + + async validateFormNamePersisted( expectedName: string ) { + await this.page.reload(); + await this.page.waitForSelector( this.fe.header.titleInput ); + await expect( this.page.locator( this.fe.header.titleInput ) ).toHaveValue( expectedName ); + } + + async doSaveForm() { + // Wait on the save ajax itself — wp-admin keeps polling (heartbeat), so + // "networkidle" never settles here. + const saved = this.page.waitForResponse( ( response ) => + response.url().includes( 'admin-ajax.php' ) + && ( response.request().postData() || '' ).includes( 'wpuf_form_builder_save_form' ) + ); + + await this.validateAndClick( this.fe.header.saveButton ); + await saved; + } + + // Switches to whichever other form the dropdown offers — form names in the + // list are whatever the site already has, so they are not assumed. + async validateSwitcherNavigatesTo() { + const currentId = await this.getFormId(); + await this.validateAndClick( this.fe.header.switcherArrow ); + + const target = this.page.locator( this.fe.header.switcherOtherForm( currentId ) ); + const targetName = ( await target.innerText() ).trim(); + await target.click(); + + await this.page.waitForSelector( this.fe.tabs.editorStage, { state: 'attached' } ); + expect( await this.getFormId() ).not.toBe( currentId ); + await expect( this.page.locator( this.fe.header.titleInput ) ).toHaveValue( targetName ); + } + + async validatePreviewOpensForm() { + const formId = await this.getFormId(); + const href = await this.page.locator( this.fe.header.previewLink ).first().getAttribute( 'href' ); + expect( href ).toContain( `form_id=${ formId }` ); + + await this.navigateToURL( this.accessFormPreview + formId ); + await this.assertionValidate( this.fe.frontend.form ); + } + + /*********************************/ + /************ Tabs ***************/ + /*********************************/ + + async doOpenTab( name: 'Form Editor' | 'Settings' ) { + await this.validateAndClick( this.fe.tabs.navTab( name ) ); + } + + async validateTabSwitching() { + await this.doOpenTab( 'Settings' ); + await expect( this.page.locator( this.fe.tabs.settingsPanel ) ).toBeVisible(); + await expect( this.page.locator( this.fe.tabs.editorStage ) ).toBeHidden(); + + await this.doOpenTab( 'Form Editor' ); + await expect( this.page.locator( this.fe.tabs.editorStage ) ).toBeVisible(); + await expect( this.page.locator( this.fe.tabs.settingsPanel ) ).toBeHidden(); + } + + /*********************************/ + /******** Add Fields panel *******/ + /*********************************/ + + async doOpenPanelTab( name: 'Add Fields' | 'Field Options' ) { + await this.validateAndClick( this.fe.panel.panelTab( name ) ); + } + + async validateFieldGroups( groups: string[] ) { + await this.doOpenPanelTab( 'Add Fields' ); + for ( const group of groups ) { + await this.assertionValidate( this.fe.panel.groupHeading( group ) ); + } + } + + async validateFieldButtons( labels: string[] ) { + for ( const label of labels ) { + await this.assertionValidate( this.fe.panel.fieldButton( label ) ); + } + } + + async validateSearchFiltersFields( term: string, expectedLabel: string, hiddenLabel: string ) { + await this.validateAndFillStrings( this.fe.panel.searchField, term ); + await expect( this.page.locator( this.fe.panel.fieldButton( expectedLabel ) ) ).toBeVisible(); + await expect( this.page.locator( this.fe.panel.fieldButton( hiddenLabel ) ) ).toBeHidden(); + + // Clearing the search restores the full list. + await this.page.locator( this.fe.panel.searchField ).fill( '' ); + await expect( this.page.locator( this.fe.panel.fieldButton( hiddenLabel ) ) ).toBeVisible(); + } + + async validateSearchNoMatch( term: string ) { + await this.validateAndFillStrings( this.fe.panel.searchField, term ); + await expect( this.page.locator( this.fe.panel.visibleFieldButtons ).locator( 'visible=true' ) ).toHaveCount( 0 ); + await this.page.locator( this.fe.panel.searchField ).fill( '' ); + } + + async doAddField( label: string ) { + await this.doOpenPanelTab( 'Add Fields' ); + await this.validateAndClick( this.fe.panel.fieldButton( label ) ); + await this.dismissBuilderPopup(); + } + + // The builder pops SweetAlerts (e.g. "show custom field data inside your + // post?") that block every later click until they are closed. + async dismissBuilderPopup() { + const popup = this.page.locator( this.fe.panel.builderPopup ); + + if ( ! await popup.isVisible().catch( () => false ) ) { + return; + } + + const cancel = this.page.locator( this.fe.panel.builderPopupCancel ); + const dismiss = await cancel.isVisible().catch( () => false ) + ? cancel + : this.page.locator( this.fe.panel.builderPopupConfirm ); + + await dismiss.click(); + await expect( popup ).toBeHidden(); + } + + async validateFieldOnStage( type: string ) { + await expect( this.page.locator( this.fe.stage.fieldByType( type ) ) ).toHaveCount( 1 ); + } + + // Post Title / Content / Excerpt / Featured Image are single-instance: once + // used, the panel button warns instead of adding a second copy. + async validateSingleInstanceField( label: string, type: string ) { + await this.doOpenPanelTab( 'Add Fields' ); + await this.page.locator( this.fe.panel.fieldButton( label ) ).click(); + await expect( this.page.locator( this.fe.panel.builderPopupText ) ).toContainText( 'already have this field' ); + await this.dismissBuilderPopup(); + await expect( this.page.locator( this.fe.stage.fieldByType( type ) ) ).toHaveCount( 1 ); + } + + /*********************************/ + /********* Field stage ***********/ + /*********************************/ + + // Copy leaves more than one item of the same type on the stage, so every + // stage action targets the first match. + async validateFieldActions( type: string ) { + await this.page.locator( this.fe.stage.fieldByType( type ) ).first().hover(); + await expect( this.page.locator( this.fe.stage.actionEdit( type ) ).first() ).toBeVisible(); + await expect( this.page.locator( this.fe.stage.actionCopy( type ) ).first() ).toBeVisible(); + await expect( this.page.locator( this.fe.stage.actionRemove( type ) ).first() ).toBeVisible(); + } + + async validateEditOpensFieldOptions( type: string ) { + await this.page.locator( this.fe.stage.fieldByType( type ) ).first().hover(); + await this.page.locator( this.fe.stage.actionEdit( type ) ).first().click(); + await expect( this.page.locator( this.fe.panel.panelTab( 'Field Options' ) ) ).toHaveClass( /wpuf-bg-white/ ); + } + + async validateCopyDuplicatesField( type: string ) { + const before = await this.page.locator( this.fe.stage.fieldByType( type ) ).count(); + await this.page.locator( this.fe.stage.fieldByType( type ) ).first().hover(); + await this.page.locator( this.fe.stage.actionCopy( type ) ).first().click(); + await this.dismissBuilderPopup(); + await expect( this.page.locator( this.fe.stage.fieldByType( type ) ) ).toHaveCount( before + 1 ); + } + + // Remove asks for confirmation ("Yes, delete it") before dropping the field. + async doRemoveField( type: string ) { + await this.page.locator( this.fe.stage.fieldByType( type ) ).first().hover(); + await this.page.locator( this.fe.stage.actionRemove( type ) ).first().click(); + + const popup = this.page.locator( this.fe.panel.builderPopup ); + await expect( popup ).toBeVisible(); + await this.page.locator( this.fe.panel.builderPopupConfirm ).click(); + await expect( popup ).toBeHidden(); + } + + async validateFieldRemoved( type: string, expectedCount: number ) { + await expect( this.page.locator( this.fe.stage.fieldByType( type ) ) ).toHaveCount( expectedCount ); + } + + async validateRequiredMark( type: string ) { + await this.assertionValidate( this.fe.stage.requiredMark( type ) ); + } + + // Step Start ships with wpuf-pro, so Lite-only runs will not offer it. + async isFieldAvailable( label: string ): Promise { + await this.doOpenPanelTab( 'Add Fields' ); + + return this.page.locator( this.fe.panel.fieldButton( label ) ).isVisible().catch( () => false ); + } + + async validateStepStartOnStage() { + await this.assertionValidate( this.fe.stage.stepStart ); + } + + // Drag the given field to the top of the stage with the sortable handle. + // jQuery UI needs a small move past its distance threshold before it starts + // sorting, hence the nudge between mouse down and the real move. + async doReorderFieldUp( type: string ) { + const handle = this.page.locator( this.fe.stage.dragHandle( type ) ).first(); + const target = this.page.locator( this.fe.stage.allFields ).first(); + + // The handle lives in the hover-revealed action bar (opacity only), so a + // forced hover is enough to place the cursor on it. + await this.page.locator( this.fe.stage.fieldByType( type ) ).first().hover( { force: true } ); + await handle.hover( { force: true } ); + + const start = await handle.boundingBox(); + const box = await target.boundingBox(); + + await this.page.mouse.down(); + await this.page.mouse.move( start.x + start.width / 2, start.y + start.height / 2 - 15, { steps: 5 } ); + await this.page.mouse.move( box.x + box.width / 2, box.y + 5, { steps: 20 } ); + await this.page.mouse.up(); + await this.waitForLoading(); + } + + async getStageFieldOrder(): Promise { + return this.page.locator( this.fe.stage.allFields ).evaluateAll( + ( items ) => items.map( ( item ) => ( item.className.match( /form-field-([a-z_]+)/ ) || [] )[ 1 ] ) + ); + } + + async validateStageOrderPersisted( expectedOrder: string[] ) { + await this.page.reload(); + await this.page.waitForSelector( this.fe.tabs.editorStage ); + expect( await this.getStageFieldOrder() ).toEqual( expectedOrder ); + } + + /*********************************/ + /********** Settings tab *********/ + /*********************************/ + + async validateSettingsSections( sections: string[], groups: string[] = [] ) { + await this.doOpenTab( 'Settings' ); + for ( const group of groups ) { + await this.assertionValidate( this.fe.settings.sidebarGroup( group ) ); + } + for ( const section of sections ) { + await this.assertionValidate( this.fe.settings.sidebarItem( section ) ); + } + } + + // Panel controls are selectize-driven (the native + * is hidden, so `selectOption()` cannot be used on these panels) + * - "run this as another user / as a guest" contexts, for view-control and + * edit-authorization assertions + * - the Post Forms list screen (search, row menu, bulk actions, status tabs) + * - the frontend messages the settings produce (restricted content, schedule + * notices, [wpuf_edit] refusals) + */ +export class PostFormGapsPage extends FormEditorPage { + constructor( page: Page ) { + super( page ); + } + + private gaps = Selectors.postFormGaps; + + /*********************************/ + /***** Settings: selectize *******/ + /*********************************/ + + // Settings selects are selectize-enhanced: the real is display:none and replaced by a + // `.custom-multiselect` widget, so drive the widget, not the select. postMultiSelectFormsFE: '//select[@name="multi_select[]"]', + postMultiSelectToggleFE: '//select[@name="multi_select[]"]/following-sibling::div[contains(@class,"custom-multiselect")]//div[contains(@class,"multiselect-input")]', + postMultiSelectOptionFE: (value: string) => `//select[@name="multi_select[]"]/following-sibling::div[contains(@class,"custom-multiselect")]//div[contains(@class,"multiselect-option")][@data-value="${value}"]//label`, // Radio postRadioFormsFE: '//input[@name="radio"]', // Checkbox @@ -1124,8 +1129,13 @@ export const Selectors = { postStatusColumn: (title: string, status: string, a: string, b: string) => `//td${a}[normalize-space(text())="${title}"]//..${b}//span[normalize-space(text())="${status}"]`, saveDraftButton: '//a[normalize-space(text())="Save Draft"]', draftSavedAlert: '//span[@class="wpuf-draft-saved"]', - multiStepProgressbar: '//div[normalize-space(text())="Step Start (100%)"]', - multiStepByStep: '//li[normalize-space(text())="Step Start"]', + // The revamped multistep bar renders a header + track instead of the old + // "Step Start (100%)" div. Pro (frontend-form-step.js) writes + // `.wpuf-progressbar-percent-text`, free (frontend-form.js) writes + // `.wpuf-progressbar-percent` — match the shared prefix so both pass. + multiStepProgressbar: '//div[contains(@class,"wpuf-multistep-progressbar")]//span[contains(@class,"wpuf-progressbar-percent")]', + // step_by_step renders a wizard whose label carries the step legend. + multiStepByStep: '//div[contains(@class,"wpuf-step-wizard")]//div[contains(@class,"wpuf-step-label")][normalize-space()="Step Start"]', removeStepStart: '//div[@class="step-start-indicator"]/../../../..//span[4]', confirmDelete: '//button[normalize-space()="Yes, delete it"]', threeDotButton: '(//div[contains(@class,"wpuf-relative wpuf-inline-block")]//button)[1]', @@ -1489,8 +1499,9 @@ export const Selectors = { multiStepTypeContainer: '//label[@for="multistep_progressbar_type-selectized"]//..//..//div[contains(@class,"selectize-control")]//div[contains(@class,"selectize-input")]', multiStepTypeDropdown: '//label[@for="multistep_progressbar_type-selectized"]//..//..//div[contains(@class,"selectize-dropdown-content")]', multiStepTypeOption: (value: string) => `//label[@for="multistep_progressbar_type-selectized"]//..//..//div[contains(@class,"selectize-dropdown-content")]//div[@data-value="${value}"]`, - multiStepProgressbar: '//div[normalize-space(text())="Step Start (100%)"]', - multiStepByStep: '//li[normalize-space(text())="Step Start"]', + // Same revamped markup as postFormSettings.multiStepProgressbar above. + multiStepProgressbar: '//div[contains(@class,"wpuf-multistep-progressbar")]//span[contains(@class,"wpuf-progressbar-percent")]', + multiStepByStep: '//div[contains(@class,"wpuf-step-wizard")]//div[contains(@class,"wpuf-step-label")][normalize-space()="Step Start"]', }, // Custom Fields Section @@ -2177,4 +2188,185 @@ export const Selectors = { cancelButton: '//a[contains(text(),"Cancel") or contains(@class,"cancel")]', }, }, + + /*********************************/ + /**** New Form Editor (FE) *******/ + /*********************************/ + + // Revamped post form builder chrome: header (inline title, form switcher, + // Preview, Save), Form Editor / Settings tabs, Add Fields / Field Options + // side panel and the drag-sortable field stage. + formEditor: { + // Header + header: { + logo: '//img[@alt="WPUF Icon"]', + titleInput: '//form[@id="wpuf-form-builder"]//input[@name="post_title"]', + formIdChip: '//span[contains(@class,"form-id")]', + switcherArrow: '//i[contains(@class,"form-switcher-arrow")]', + switcherItem: (formName: string) => `//ul[contains(@class,"wpuf-dropdown-content")]//a[normalize-space()="${formName}"]`, + // First entry in the switcher that is not the form being edited. + switcherOtherForm: (formId: string) => `(//ul[contains(@class,"wpuf-dropdown-content")]//a[not(contains(@href,"id=${formId}"))])[1]`, + previewLink: '//a[contains(@href,"wpuf_preview")]', + saveButton: '//button[normalize-space()="Save"]', + }, + + // Top level tabs + tabs: { + navTab: (name: string) => `//a[contains(@class,"wpuf-nav-tab")][normalize-space()="${name}"]`, + // The active tab is the one painted white — "wpuf-nav-tab-active" is + // a static class present on both tabs. + activeNavTab: (name: string) => `//a[contains(@class,"wpuf-nav-tab")][contains(@class,"wpuf-bg-white")][normalize-space()="${name}"]`, + editorStage: '//ul[contains(@class,"sortable-list")]', + settingsPanel: '//div[@id="wpuf-form-builder-settings"]', + }, + + // Right side panel + panel: { + panelTab: (name: string) => `//a[contains(@class,"wpuf-tab")][normalize-space()="${name}"]`, + searchField: '//input[@id="search"]', + groupHeading: (group: string) => `//h3[normalize-space()="${group}"]`, + fieldButton: (label: string) => `//div[contains(@id,"panel-form-field-buttons")]//p[normalize-space()="${label}"]`, + visibleFieldButtons: '//div[contains(@id,"panel-form-field-buttons")]//p', + // Re-adding a single-instance field pops an "Oops…" SweetAlert (OK only); + // adding a custom field pops the "show custom field data" SweetAlert + // (Okay = cancel, Don't show again = confirm). Prefer cancel so the + // "don't show again" preference is never written by a test. + builderPopup: '//div[contains(@class,"swal2-popup")]', + builderPopupText: '//div[contains(@class,"swal2-popup")]//div[contains(@class,"swal2-html-container")]', + builderPopupCancel: '//button[contains(@class,"swal2-cancel")]', + builderPopupConfirm: '//button[contains(@class,"swal2-confirm")]', + // Field Options panel with nothing selected. + fieldOptionsEmptyState: '//div[contains(@class,"wpuf-form-builder-field-options")]//p[contains(.,"please start adding fields")]', + fieldOptionsPanel: '//div[contains(@class,"wpuf-form-builder-field-options")]', + fieldOptionByTitle: (title: string) => `//div[contains(@class,"wpuf-form-builder-field-options")]//label[normalize-space()="${title}"]`, + // Fields shown as pro previews carry the pro badge image. + proPreviewBadge: '//div[contains(@class,"group/pro-field")]//img[contains(@src,"pro-badge")]', + fieldOptionsLabel:'//div[contains(@class,"wpuf-form-builder-field-options")]//input[@name="label"] | //label[normalize-space()="Field Label"]/following::input[1]', + }, + + // Field stage + stage: { + allFields: '//ul[contains(@class,"sortable-list")]/li[contains(@class,"field-items")]', + fieldByType: (type: string) => `//li[contains(@class,"form-field-${type}")]`, + fieldLabel: (type: string) => `//li[contains(@class,"form-field-${type}")]//label`, + requiredMark: (type: string) => `//li[contains(@class,"form-field-${type}")]//span[contains(@class,"required") or normalize-space()="*"]`, + actionEdit: (type: string) => `//li[contains(@class,"form-field-${type}")]//span[normalize-space()="Edit"]`, + actionCopy: (type: string) => `//li[contains(@class,"form-field-${type}")]//span[normalize-space()="Copy"]`, + actionRemove: (type: string) => `//li[contains(@class,"form-field-${type}")]//span[normalize-space()="Remove"]`, + dragHandle: (type: string) => `//li[contains(@class,"form-field-${type}")]//i[contains(@class,"move")]`, + stepStart: '//li[contains(@class,"field-items")][contains(.,"Step Start")]', + helpText: (type: string) => `//li[contains(@class,"form-field-${type}")]//p[contains(@class,"wpuf-help") or contains(@class,"wpuf-text-gray")]`, + selectOptions: (type: string) => `//li[contains(@class,"form-field-${type}")]//select/option`, + }, + + // Settings tab + settings: { + // "Post Settings" / "Modules" are group headings; the clickable + // sections (General, Payment Settings, …) are links under them. + sidebarGroup: (name: string) => `//div[@id="wpuf-form-builder-settings"]//h2[normalize-space()="${name}"]`, + sidebarItem: (name: string) => `//div[@id="wpuf-form-builder-settings"]//li//a[normalize-space()="${name}"]`, + sectionHeading: (name: string) => `//div[@id="wpuf-form-builder-settings"]//h2[normalize-space()="${name}"]`, + submitTextField: '//input[@id="submit_text"]', + postStatusField: '//select[@id="post_status"]', + formTemplateField: '//select[@id="form_template"]', + enableMultistep: '//input[@id="enable_multistep"]', + multistepProgressbar: '//select[@id="multistep_progressbar_type"]', + // Generic accessors — every setting control uses its setting key as id. + controlById: (id: string) => `//div[@id="wpuf-form-builder-settings"]//*[@id="${id}"]`, + formLayoutPicker: '//div[@id="wpuf-form-builder-settings"]//*[@id="form_layout"]', + // pic-radio settings render as a div of radio inputs, not a stays hidden next to a + // .selectize-control sibling inside the same wrapper. + settings: { + selectizeInput: (id: string) => `//select[@id="${id}"]/parent::*//div[contains(@class,"selectize-input")]`, + selectizeOption: (id: string, value: string) => + `//select[@id="${id}"]/parent::*//div[contains(@class,"selectize-dropdown-content")]//div[@data-value="${value}"]`, + selectizeOptionByText: (id: string, text: string) => + `//select[@id="${id}"]/parent::*//div[contains(@class,"selectize-dropdown-content")]//div[normalize-space()="${text}"]`, + // A selectize chip is `
    Editor×
    `, + // so normalize-space() over the whole node yields "Editor×" and never + // matches. text() takes only the label node. + selectizeItem: (id: string, text: string) => + `//select[@id="${id}"]/parent::*//div[contains(@class,"selectize-input")]//div[contains(@class,"item")][normalize-space(text())="${text}"]`, + }, + + // Field Options controls for the structural fields (Section Break, + // Custom HTML, Shortcode, Action Hook, Hidden Field, Terms & Conditions). + // Those inputs carry no name/id, so they are addressed by their label. + fieldOptions: { + inputByLabel: (label: string) => + `(//div[contains(@class,"wpuf-form-builder-field-options")]//label[normalize-space()="${label}"]/following::input)[1]`, + textareaByLabel: (label: string) => + `(//div[contains(@class,"wpuf-form-builder-field-options")]//label[normalize-space()="${label}"]/following::textarea)[1]`, + // Conditional Logic block: Yes/No radios, then the rule row — + // [1] the field to watch, [2] the operator (!=empty / ==empty). + conditionalYes: '(//label[normalize-space()="Conditional Logic"]/following::input[@type="radio"])[1]', + conditionalNo: '(//label[normalize-space()="Conditional Logic"]/following::input[@type="radio"])[2]', + conditionalField: '(//label[normalize-space()="Conditional Logic"]/following::select)[1]', + conditionalOperator: '(//label[normalize-space()="Conditional Logic"]/following::select)[2]', + }, + + // Post Forms list screen (Vue table: tabs with counts, bulk actions, + // search box and a per-row "…" menu holding Edit / Duplicate / Trash). + formList: { + searchInput: '//input[@placeholder="Search Forms"]', + statusTab: (name: string) => `//a[contains(normalize-space(),"${name}")] | //button[contains(normalize-space(),"${name}")]`, + row: (formName: string) => `//tr[.//span[normalize-space()="${formName}"]]`, + allRows: '//tbody//tr[.//input[@type="checkbox"]]', + rowCheckbox: (formName: string) => `//tr[.//span[normalize-space()="${formName}"]]//input[@type="checkbox"]`, + rowMenuToggle: (formName: string) => `(//tr[.//span[normalize-space()="${formName}"]]//button)[last()]`, + rowMenuItem: (action: string) => `//div[contains(@class,"wpuf-origin-top-right")]//*[normalize-space()="${action}"]`, + bulkActionSelect: '//select[.//option[contains(normalize-space(),"Bulk actions")]]', + bulkApplyButton: '//button[normalize-space()="Apply"]', + }, + + // Messages WPUF prints instead of content/form. + frontend: { + restrictedContent: '//div[contains(@class,"wpuf-restricted-content")]', + // Scheduling / entry-limit / "not logged in" notices. + formMessage: '//div[contains(@class,"wpuf-message")]', + // [wpuf_edit] refusals (invalid post, not allowed to edit, lock notices). + infoMessage: '//div[contains(@class,"wpuf-info")]', + successMessage: '//div[contains(@class,"wpuf-success")]', + postContent: '//div[contains(@class,"entry-content") or contains(@class,"post-content")]', + }, + }, }; \ No newline at end of file diff --git a/tests/e2e/pages/settingsSetup.ts b/tests/e2e/pages/settingsSetup.ts index e6526a33f..ef66a2740 100644 --- a/tests/e2e/pages/settingsSetup.ts +++ b/tests/e2e/pages/settingsSetup.ts @@ -2,7 +2,7 @@ import dotenv from 'dotenv'; dotenv.config({ quiet: true }); import { expect, type Page, type Dialog } from '@playwright/test'; import { Selectors } from './selectors'; -import { Urls } from '../utils/testData'; +import { Urls, Users } from '../utils/testData'; import { Base } from './base'; import { waitForSiteReady } from '../utils/siteReady'; export class SettingsSetupPage extends Base { @@ -873,8 +873,20 @@ export class SettingsSetupPage extends Base { // instead of a fixed 20s sleep (same worst-case ceiling, faster when done). await this.page.waitForTimeout(3000); await waitForSiteReady(this.page, 60000); + // The reset rewrites the users table, so the admin cookie we came in with (and any + // cached `.auth/` session) is dead — every admin URL now bounces to wp-login.php. + // Log in again before touching the plugins screen. Imported lazily: basicLogin.ts + // imports this module, so a top-level import would be a cycle. + const { BasicLoginPage } = await import('./basicLogin'); + await new BasicLoginPage(this.page).basicLogin(Users.adminUsername, Users.adminPassword); + // The reset wipes the DB, so the next admin page load is the *first* one after + // (re)activation and plugins hijack it with a one-shot redirect — WPUF sends us to + // its setup wizard. A reload keeps us on the wizard URL; navigating again lands on + // plugins.php because the redirect transient is already consumed. await this.navigateToURL(this.pluginsPage); - await this.page.reload(); + if (!this.page.url().includes('plugins.php')) { + await this.navigateToURL(this.pluginsPage); + } await this.validateAndClick(Selectors.settingsSetup.pluginStatusCheck.clickWCvendors); diff --git a/tests/e2e/tests/allFieldTypesTest.spec.ts b/tests/e2e/tests/allFieldTypesTest.spec.ts new file mode 100644 index 000000000..5c7a24533 --- /dev/null +++ b/tests/e2e/tests/allFieldTypesTest.spec.ts @@ -0,0 +1,402 @@ +import { Browser, BrowserContext, Page, test, expect, chromium } from '@playwright/test'; +import { BasicLoginPage } from '../pages/basicLogin'; +import { PostFormPage } from '../pages/postForm'; +import { FormEditorPage } from '../pages/formEditor'; +import { Users, PostForm } from '../utils/testData'; +import { FieldTypes, fieldTypesByGroup } from '../utils/fieldTypes'; +import { faker } from '@faker-js/faker'; +import { configureSpecFailFast } from '../utils/specFailFast'; +import { waitForSiteReady } from '../utils/siteReady'; + +let browser: Browser; +let context: BrowserContext; +let page: Page; + +test.beforeAll( async () => { + browser = await chromium.launch(); + context = await browser.newContext(); + page = await context.newPage(); + + // The builder guards navigation once the form is dirty. + page.on( 'dialog', async ( dialog ) => { + await dialog.accept().catch( () => {} ); + } ); +} ); + +test.describe( 'All-Field-Types', () => { + configureSpecFailFast(); + + /**------------------------- ALL FIELD TYPES ---------------------------** + * + * @TestScenario : [One post form holding every field type the builder offers] + * + * Builds a single form with all 44 field types, then validates them in the + * builder (stage) and on the rendered frontend form. Types the install does + * not offer (Pro fields on a Lite/unlicensed site) are reported and skipped + * rather than failing the run. + * + * @Test_AF0001 : Every field type in the catalogue is added to one form + * @Test_AF0002 : Post Fields render on the stage + * @Test_AF0003 : Taxonomy fields render on the stage + * @Test_AF0004 : Custom fields render on the stage + * @Test_AF0005 : Pricing fields render on the stage + * @Test_AF0006 : "Others" fields render on the stage + * @Test_AF0007 : The field set survives a save + reload + * @Test_AF0008 : Frontend renders a row and control for every core field type + * @Test_AF0009 : Frontend renders the multistep wrapper for Step Start + * @Test_AF0010 : Environment-dependent fields are reported (captchas, map, cart total) + * + */ + + // Reuse an existing all-fields fixture when AF_FORM_ID / AF_PAGE_SLUG are set — + // rebuilding 40 fields through the builder takes minutes. + const existingFormId = process.env.AF_FORM_ID || ''; + const existingPageSlug = process.env.AF_PAGE_SLUG || ''; + + const formName = `AF All Fields ${ faker.string.alphanumeric( 5 ) }`; + const pageSlug = existingPageSlug || `af-all-fields-${ faker.string.alphanumeric( 5 ) }`.toLowerCase(); + const dashboardSlug = `af-dashboard-${ faker.string.alphanumeric( 5 ) }`.toLowerCase(); + const editPageSlug = `af-edit-${ faker.string.alphanumeric( 5 ) }`.toLowerCase(); + const postTitle = `AF Post ${ faker.string.alphanumeric( 5 ) }`; + + let entered: Record = {}; + let formId: string; + let added: string[] = []; + let unavailable: string[] = []; + let refused: Record = {}; + + const availableInGroup = ( group: Parameters[ 0 ] ) => + fieldTypesByGroup( group ).filter( ( field ) => added.includes( field.slug ) ); + + // Taxonomy buttons save under a different template name than their slug. + const stageSlugs = ( group: Parameters[ 0 ] ) => + availableInGroup( group ).map( ( field ) => field.stage || field.slug ); + + test( 'AF0001 : Every field type in the catalogue is added to one form', { tag: [ '@Lite', '@Test_AF0001' ] }, async () => { + test.setTimeout( 300000 ); + await waitForSiteReady( page, 15000 ); + await new BasicLoginPage( page ).basicLoginAndPluginVisit( Users.adminUsername, Users.adminPassword ); + + const editor = new FormEditorPage( page ); + + if ( existingFormId ) { + // Fixture reuse: take the field set from the form that already exists. + formId = existingFormId; + await editor.openFormEditor( formId ); + + const onStage = await editor.getStageFieldTypes(); + added = FieldTypes + .filter( ( field ) => onStage.includes( field.stage || field.slug ) ) + .map( ( field ) => field.slug ); + unavailable = []; + refused = {}; + console.log( `reusing form ${ formId } with ${ added.length } field types` ); + expect( added.length, `form ${ formId } holds no known field types` ).toBeGreaterThan( 0 ); + + return; + } + + await new PostFormPage( page ).createBlankFormPostForm( formName ); + formId = await editor.getFormId(); + + ( { added, unavailable, refused } = await editor.doAddAllFieldTypes( FieldTypes.map( ( field ) => field.slug ) ) ); + console.log( `added ${ added.length }/${ FieldTypes.length } field types` ); + + if ( unavailable.length ) { + const proOnly = unavailable.every( ( slug ) => FieldTypes.find( ( f ) => f.slug === slug )?.pro ); + console.log( `not offered by this install: ${ unavailable.join( ', ' ) }` ); + // Only Pro-gated fields may be missing; a missing Lite field is a bug. + expect( proOnly, `Lite field types missing from the builder: ${ unavailable.join( ', ' ) }` ).toBeTruthy(); + } + + for ( const [ slug, reason ] of Object.entries( refused ) ) { + console.log( `refused by the builder: ${ slug } — ${ reason }` ); + } + + // A refusal is only acceptable for fields that need external configuration. + const unexpectedRefusals = Object.keys( refused ).filter( + ( slug ) => ! FieldTypes.find( ( f ) => f.slug === slug )?.envDep + ); + expect( unexpectedRefusals, `field types the builder refused to add: ${ unexpectedRefusals.join( ', ' ) }` ).toEqual( [] ); + + expect( added.length, 'no field types could be added' ).toBeGreaterThan( 0 ); + await editor.doSaveForm(); + } ); + + test( 'AF0002 : Post Fields render on the stage', { tag: [ '@Lite', '@Test_AF0002' ] }, async () => { + await new FormEditorPage( page ).validateStageHasFieldTypes( stageSlugs( 'Post Fields' ) ); + } ); + + test( 'AF0003 : Taxonomy fields render on the stage', { tag: [ '@Lite', '@Test_AF0003' ] }, async () => { + await new FormEditorPage( page ).validateStageHasFieldTypes( stageSlugs( 'Taxonomies' ) ); + } ); + + test( 'AF0004 : Custom fields render on the stage', { tag: [ '@Lite', '@Test_AF0004' ] }, async () => { + await new FormEditorPage( page ).validateStageHasFieldTypes( stageSlugs( 'Custom Fields' ) ); + } ); + + test( 'AF0005 : Pricing fields render on the stage', { tag: [ '@Pro', '@Test_AF0005' ] }, async () => { + const pricing = stageSlugs( 'Pricing Fields' ); + test.skip( ! pricing.length, 'Pricing fields require wpuf-pro' ); + await new FormEditorPage( page ).validateStageHasFieldTypes( pricing ); + } ); + + test( 'AF0006 : "Others" fields render on the stage', { tag: [ '@Lite', '@Test_AF0006' ] }, async () => { + await new FormEditorPage( page ).validateStageHasFieldTypes( stageSlugs( 'Others' ) ); + } ); + + test( 'AF0007 : The field set survives a save + reload', { tag: [ '@Lite', '@Test_AF0007' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.openFormEditor( formId ); + + const onStage = await editor.getStageFieldTypes(); + const expectedOnStage = FieldTypes + .filter( ( field ) => added.includes( field.slug ) ) + .map( ( field ) => field.stage || field.slug ); + const missing = expectedOnStage.filter( ( slug ) => ! onStage.includes( slug ) ); + expect( missing, `field types lost after save: ${ missing.join( ', ' ) }` ).toEqual( [] ); + } ); + + test( 'AF0008 : Frontend renders a row and control for every core field type', { tag: [ '@Lite', '@Test_AF0008' ] }, async () => { + test.setTimeout( 180000 ); + const editor = new FormEditorPage( page ); + + if ( ! existingPageSlug ) { + await new PostFormPage( page ).createPageWithShortcode( `[wpuf_form id="${ formId }"]`, pageSlug ); + } + + await editor.openFrontendForm( pageSlug ); + + const core = FieldTypes.filter( + ( field ) => added.includes( field.slug ) && ! field.envDep && ! field.noRow && ! field.frontendSkip && ! field.knownBug + ); + + for ( const field of core ) { + await editor.validateFrontendRow( field.label, field.control ); + } + + console.log( `validated ${ core.length } core field rows on the frontend` ); + } ); + + test( 'AF0009 : Frontend renders the multistep wrapper for Step Start', { tag: [ '@Pro', '@Test_AF0009' ] }, async () => { + test.skip( ! added.includes( 'step_start' ), 'Step Start requires wpuf-pro' ); + await new FormEditorPage( page ).validateMultistepRenderedOnFrontend(); + } ); + + test( 'AF0010 : Environment-dependent fields are reported', { tag: [ '@Lite', '@Test_AF0010' ] }, async () => { + const editor = new FormEditorPage( page ); + const envDep = FieldTypes.filter( ( field ) => added.includes( field.slug ) && field.envDep ); + const notRendered: string[] = []; + + for ( const field of envDep ) { + if ( ! await editor.checkFrontendRow( field.label ) ) { + notRendered.push( field.label ); + } + } + + // These need API keys / extra plugins / payments, so a miss is a config + // gap, not a product failure — surface it instead of failing the suite. + console.log( + notRendered.length + ? `env-dependent fields not rendered (needs configuration): ${ notRendered.join( ', ' ) }` + : 'all env-dependent fields rendered' + ); + expect( envDep.length, 'no env-dependent fields were added' ).toBeGreaterThan( 0 ); + } ); + + /**------------------- END TO END: SUBMIT + ROUND TRIP -------------------**/ + + // BUG (see BUGS-FOUND.md): a Step Start field on a form whose multistep + // setting is off renders a permanently hidden .wpuf-multistep-fieldset, so + // every field after it — including Submit — is unreachable. + test( 'AF0021 : Step Start without multistep enabled keeps the form usable', { tag: [ '@Pro', '@Test_AF0021' ] }, async () => { + test.setTimeout( 240000 ); + test.skip( ! added.includes( 'step_start' ), 'Step Start requires wpuf-pro' ); + test.fail(); + const editor = new FormEditorPage( page ); + + // Precondition: the form has a Step Start but multistep switched off. + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + const hasMultistep = await editor.isSettingAvailable( 'enable_multistep' ); + + if ( hasMultistep ) { + await editor.doToggleSetting( 'enable_multistep', false ); + await editor.doSaveForm(); + } + + try { + await editor.openFrontendForm( pageSlug ); + const rows = await editor.countFrontendRows(); + expect( rows.hidden, `${ rows.hidden } field rows are hidden with no way to reach them` ).toBe( 0 ); + await expect( page.locator( '//form[contains(@class,"wpuf-form")]//input[contains(@class,"wpuf-submit-button")]' ).first() ).toBeVisible(); + } finally { + // Put multistep back so the submit flow below can reach the button. + if ( hasMultistep ) { + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + await editor.doToggleSetting( 'enable_multistep', true ); + await editor.doSaveForm(); + } + } + } ); + + test( 'AF0011 : Every rendered field accepts input and the form submits', { tag: [ '@Lite', '@Test_AF0011' ] }, async () => { + test.setTimeout( 420000 ); + const editor = new FormEditorPage( page ); + + // The form carries a Step Start, so multistep has to be switched on for + // the wizard (and the submit button) to be reachable at all. + if ( added.includes( 'step_start' ) ) { + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + + if ( await editor.isSettingAvailable( 'enable_multistep' ) ) { + await editor.doToggleSetting( 'enable_multistep', true ); + await editor.doSaveForm(); + } + } + + await editor.openFrontendForm( pageSlug ); + + entered = await editor.doAdvanceAllSteps( PostForm.imageUpload ); + + // Post Title drives the post we look for afterwards. + await page.locator( '//form[contains(@class,"wpuf-form")]//input[@name="post_title"]' ).first().fill( postTitle ); + + // The captcha refreshes its equation on every blocked attempt, so answer it last. + await editor.doSolveMathCaptcha(); + + const submit = page.locator( '//form[contains(@class,"wpuf-form")]//input[contains(@class,"wpuf-submit-button")]' ).first(); + await expect( submit, 'submit button is not reachable after filling every field' ).toBeVisible(); + await submit.click(); + + // Success = the form goes away (redirect or success message). + await expect( + page.locator( '//form[contains(@class,"wpuf-form")]' ), + `submit was blocked: ${ ( await editor.validateFrontendValidationErrors() ).join( '; ' ) }` + ).toBeHidden( { timeout: 60000 } ); + } ); + + test( 'AF0012 : The submitted post exists with the entered title', { tag: [ '@Lite', '@Test_AF0012' ] }, async () => { + await new FormEditorPage( page ).validatePostCreated( postTitle ); + } ); + + test( 'AF0013 : Field values round-trip through the WPUF edit form', { tag: [ '@Lite', '@Test_AF0013' ] }, async () => { + test.setTimeout( 240000 ); + const editor = new FormEditorPage( page ); + const postForm = new PostFormPage( page ); + + // The dashboard's Edit link is built from the global Edit Page setting; + // without it the link falls back to the post permalink and no form loads. + await postForm.createPageWithShortcode( '[wpuf_edit]', editPageSlug ); + await editor.doSetGlobalEditPage( editPageSlug ); + + await postForm.createPageWithShortcode( '[wpuf_dashboard]', dashboardSlug ); + await editor.doOpenDashboardEditForm( dashboardSlug, postTitle ); + + const values = await editor.readEditFormValues(); + expect( values.title, 'post title did not round-trip into the edit form' ).toBe( postTitle ); + + // Display-only / one-shot fields legitimately hold no stored value. + const noStoredValue = [ 'Math Captcha', 'Total', 'Section Break', 'Custom HTML', 'Shortcode', 'wpuf-column-field', 'reCaptcha', 'Cloudflare Turnstile', 'Really Simple Captcha' ]; + + // Every other row that was filled on submit must come back filled on edit. + const lost = Object.keys( entered ) + .filter( ( label ) => label && entered[ label ] && ! noStoredValue.includes( label ) ) + .filter( ( label ) => values.empty.includes( label ) ); + + console.log( `filled on edit: ${ values.filled.join( ', ' ) }` ); + console.log( `empty on edit: ${ values.empty.join( ', ' ) }` ); + expect( lost, `fields that lost their value on edit: ${ lost.join( ', ' ) }` ).toEqual( [] ); + } ); + + /**--------------------- KNOWN PRODUCT DEFECTS -------------------------**/ + + // BUG: Field_Repeat::get_field_props() stores input_type "repeat" while the + // field is registered as "repeat_field", so the renderer finds no handler and + // drops the field. test.fail() keeps the suite honest until it is fixed. + test( 'AF0014 : Repeat Field renders on the frontend', { tag: [ '@Pro', '@Test_AF0014' ] }, async () => { + test.skip( ! added.includes( 'repeat_field' ), 'Repeat Field requires wpuf-pro' ); + test.fail(); + const editor = new FormEditorPage( page ); + await editor.openFrontendForm( pageSlug ); + await editor.validateFrontendRow( 'Repeat Field', 'input' ); + } ); + + // BUG: the Shortcode field prints its own
  • on top of the generic field + // wrapper, so every shortcode field renders twice (the first row is empty). + test( 'AF0015 : No field type renders more than one row', { tag: [ '@Lite', '@Test_AF0015' ] }, async () => { + test.fail(); + const editor = new FormEditorPage( page ); + await editor.openFrontendForm( pageSlug ); + + const labels = ( await editor.getFrontendFieldLabels() ).filter( Boolean ); + const duplicated = labels.filter( ( label, index ) => labels.indexOf( label ) !== index ); + + expect( [ ...new Set( duplicated ) ], `field labels rendered more than once: ${ duplicated.join( ', ' ) }` ).toEqual( [] ); + } ); + + /**------------------ VALIDATION RULES PER FIELD TYPE ------------------**/ + + // BUG (see BUGS-FOUND.md): on a form holding Math Captcha, the captcha submit + // handler calls stopImmediatePropagation() before WPUF's own validation runs, + // so clicking Submit on an empty form renders no message at all — not even the + // captcha's own. WPUF's validateForm() does produce the right errors when it is + // reached, which is why this is a wiring defect rather than missing validation. + test( 'AF0016 : An empty submit is blocked by the required-field rules', { tag: [ '@Lite', '@Test_AF0016' ] }, async () => { + test.setTimeout( 180000 ); + test.fail( added.includes( 'math_captcha' ), 'Math Captcha suppresses all submit feedback' ); + const editor = new FormEditorPage( page ); + await editor.openFrontendForm( pageSlug ); + + const errors = await editor.validateRequiredFieldsBlockSubmit(); + console.log( `empty-submit errors: ${ errors.join( ' | ' ) }` ); + expect( errors.length, 'the empty form was submitted with no complaint' ).toBeGreaterThan( 0 ); + } ); + + test( 'AF0017 : Email Address rejects a malformed address', { tag: [ '@Lite', '@Test_AF0017' ] }, async () => { + const editor = new FormEditorPage( page ); + const valid = await editor.validateHtml5Validity( 'Email Address', 'not-an-email' ); + expect( valid, 'the email field accepted "not-an-email"' ).toBeFalsy(); + } ); + + test( 'AF0018 : Website URL rejects a malformed URL', { tag: [ '@Lite', '@Test_AF0018' ] }, async () => { + const editor = new FormEditorPage( page ); + const valid = await editor.validateHtml5Validity( 'Website URL', 'not a url' ); + expect( valid, 'the URL field accepted "not a url"' ).toBeFalsy(); + } ); + + test( 'AF0019 : Numeric Field only accepts numbers', { tag: [ '@Pro', '@Test_AF0019' ] }, async () => { + test.skip( ! added.includes( 'numeric_text_field' ), 'Numeric Field requires wpuf-pro' ); + const editor = new FormEditorPage( page ); + await editor.openFrontendForm( pageSlug ); + + const input = page.locator( `${ editor.frontendRow( 'Numeric Field' ) }//input` ).first(); + await expect( input, 'the numeric field is not a number input' ).toHaveAttribute( 'type', 'number' ); + + // Typing letters into a number input leaves it empty in every browser. + await input.click(); + await page.keyboard.type( 'abc' ); + await expect( input, 'letters were accepted by the numeric field' ).toHaveValue( '' ); + + await input.fill( '42' ); + await expect( input ).toHaveValue( '42' ); + } ); + + test( 'AF0020 : Math Captcha rejects a wrong answer', { tag: [ '@Pro', '@Test_AF0020' ] }, async () => { + test.setTimeout( 180000 ); + test.skip( ! added.includes( 'math_captcha' ), 'Math Captcha requires wpuf-pro' ); + const editor = new FormEditorPage( page ); + await editor.openFrontendForm( pageSlug ); + + await editor.doAdvanceAllSteps( PostForm.imageUpload ); + await page.locator( '//form[contains(@class,"wpuf-form")]//input[@name="post_title"]' ).first().fill( `${ postTitle } captcha` ); + await editor.doAnswerMathCaptcha( '99999' ); + await editor.doSubmitFrontendForm(); + + await expect( page.locator( '//form[contains(@class,"wpuf-form")]' ), 'a wrong captcha answer still submitted the form' ).toBeVisible(); + const errors = await editor.getVisibleValidationErrors(); + console.log( `captcha errors: ${ errors.join( ' | ' ) }` ); + } ); +} ); diff --git a/tests/e2e/tests/alphaSetupTest.spec.ts b/tests/e2e/tests/alphaSetupTest.spec.ts index 6b28fca2d..53e2857c7 100644 --- a/tests/e2e/tests/alphaSetupTest.spec.ts +++ b/tests/e2e/tests/alphaSetupTest.spec.ts @@ -271,7 +271,9 @@ test.describe('Login and Setup', () => { await SettingsSetup.enableOpenAI(); }); - test.skip('LS0029 : Admin is enabling Anthropic AI', { tag: ['@Basic'] }, async () => { + // Was skipped outright; the Anthropic provider exists in the AI tab and saves + // exactly like Google/OpenAI above, so it is covered the same way. + test('LS0029 : Admin is enabling Anthropic AI', { tag: ['@Basic'] }, async () => { const SettingsSetup = new SettingsSetupPage(page); await SettingsSetup.enableAnthropicAI(); }); @@ -304,8 +306,8 @@ test.describe('Login and Setup', () => { test('LS0035 : Admin validates AI provider selection persisted', { tag: ['@Basic', '@Test_LS0035'] }, async () => { const SettingsSetup = new SettingsSetupPage(page); - // LS0028 enabled OpenAI last, so it is the persisted active provider. - await SettingsSetup.validateAIProviderPersistence('openai'); + // LS0029 enabled Anthropic last, so it is the persisted active provider. + await SettingsSetup.validateAIProviderPersistence('anthropic'); }); test('LS0036 : Admin validates WPUF general settings persist (round-trip)', { tag: ['@Basic', '@Test_LS0036'] }, async () => { diff --git a/tests/e2e/tests/formEditorTest.spec.ts b/tests/e2e/tests/formEditorTest.spec.ts new file mode 100644 index 000000000..dd2b46f72 --- /dev/null +++ b/tests/e2e/tests/formEditorTest.spec.ts @@ -0,0 +1,518 @@ +import { Browser, BrowserContext, Page, test, expect, chromium } from '@playwright/test'; +import { BasicLoginPage } from '../pages/basicLogin'; +import { PostFormPage } from '../pages/postForm'; +import { FormEditorPage } from '../pages/formEditor'; +import { Users, PostForm } from '../utils/testData'; +import { faker } from '@faker-js/faker'; +import { configureSpecFailFast } from '../utils/specFailFast'; +import { waitForSiteReady } from '../utils/siteReady'; + +let browser: Browser; +let context: BrowserContext; +let page: Page; + +test.beforeAll( async () => { + browser = await chromium.launch(); + context = await browser.newContext(); + page = await context.newPage(); + + // The builder arms a beforeunload guard once a form is edited; Playwright + // dismisses dialogs by default, which cancels the navigation and hangs the + // next step. Always accept so navigation goes through. + page.on( 'dialog', async ( dialog ) => { + await dialog.accept().catch( () => {} ); + } ); +} ); + +test.describe( 'Form-Editor', () => { + configureSpecFailFast(); + + /**--------------------------- NEW FORM EDITOR ---------------------------** + * + * @TestScenario : [New post form editor — builder chrome + frontend output] + * + * Ordered/stateful spec: one form is built up across the run, so the tests + * below are listed in execution order. Tests tagged @Pro self-skip when the + * Pro feature is missing (unlicensed wpuf-pro strips those fields/settings + * through `Feature_Lock`). + * + * Setup + * @Test_FE0000 : Admin creates a blank post form to exercise the editor + * + * Editor shell + * @Test_FE0001 : Editor shell renders (header, tabs, stage, side panel) + * @Test_FE0005 : Form id chip matches the id in the URL + * + * Add Fields panel + * @Test_FE0010 : All field groups render in the Add Fields panel + * @Test_FE0011 : Field search filters the panel, clearing restores it + * @Test_FE0012 : Search with no match shows no field buttons + * @Test_FE0013 : Clicking a field button adds it to the stage + * @Test_FE0016 : Single-instance post fields cannot be added twice + * + * Field stage + * @Test_FE0017 : Hover reveals Edit / Copy / Remove on a stage field + * @Test_FE0018 : Edit switches the side panel to Field Options + * @Test_FE0019 : Copy duplicates a field on the stage + * @Test_FE0020 : Remove deletes a field from the stage + * @Test_FE0022 : Required field shows the asterisk on the stage + * @Test_FE0023 : Step Start renders as a multistep divider (@Pro) + * @Test_FE0021 : Drag reorder persists after save + * @Test_FE0007 : Save persists the stage field set + * + * Header, tabs and settings + * @Test_FE0002 : Inline form title rename persists after reload + * @Test_FE0008 : Form Editor / Settings tabs toggle their panels + * @Test_FE0032 : Settings sidebar lists every section + * @Test_FE0033 : Clicking a settings section renders its panel + * @Test_FE0034 : Post settings (submit text) persist after save + * @Test_FE0004 : Form switcher navigates to another form editor + * @Test_FE0006 : Preview link renders the form on the preview page + * + * Frontend baseline + * @Test_FE0040 : Frontend form renders with the selected layout class + * @Test_FE0047 : Required validation blocks an empty frontend submit + * @Test_FE0046 : Frontend submit creates the post (end-to-end) + * + * Field Options panel + * @Test_FE0024 : Field Options shows an empty state on a form with no fields + * @Test_FE0025 : Label edit updates the stage label live + * @Test_FE0027 : Help text edit shows under the field on the stage + * @Test_FE0028 : Required toggle adds and removes the asterisk + * @Test_FE0029 : Field size option applies the size class on the stage + * @Test_FE0026 : Meta key is locked for post fields + * @Test_FE0031 : Conditional logic is offered with more than one field (@Pro) + * @Test_FE0030 : Dropdown options render on the stage + * + * Panel & header extras + * @Test_FE0014 : Dragging a panel field onto the stage adds it + * @Test_FE0015 : Pro-only fields are shown gated with the pro badge + * @Test_FE0009 : Leaving a dirty editor raises the unsaved-changes guard + * + * Settings persistence + * @Test_FE0035 : Multistep toggle reveals the progress bar options (@Pro) + * @Test_FE0036 : Form style (layout) selection persists (@Pro) + * @Test_FE0037 : Payment settings persist after save + * @Test_FE0038 : Notification settings persist after save + * @Test_FE0039 : Form title and description toggles reach the frontend + * + * Frontend templates + * @Test_FE0041 : Form title and description render on the template + * @Test_FE0042 : Field help text renders under the field + * @Test_FE0044 : Native selects use the template chevron + * @Test_FE0045 : Image field with a single-file limit blocks a second upload + * @Test_FE0043 : Multistep navigation works on the frontend (@Pro) + * + * Negative / security + * @Test_FE0049 : An unknown form id does not break the editor page + * @Test_FE0050 : A script payload in the form title is escaped on the frontend + * @Test_FE0003 : Empty form title is not persisted (known bug — test.fail) + * @Test_FE0048 : A subscriber cannot open the form builder + * + */ + + const formName = `FE Editor ${ faker.string.alphanumeric( 5 ) }`; + const secondFormName = `FE Switch ${ faker.string.alphanumeric( 5 ) }`; + const renamedForm = `${ formName } Renamed`; + const pageSlug = `fe-editor-${ faker.string.alphanumeric( 5 ) }`.toLowerCase(); + const postTitle = `FE Post ${ faker.string.alphanumeric( 5 ) }`; + const submitText = 'Publish It'; + const fieldLabel = `Label ${ faker.string.alphanumeric( 5 ) }`; + const helpText = `Help ${ faker.string.alphanumeric( 5 ) }`; + const formDescription = `Description ${ faker.string.alphanumeric( 5 ) }`; + const notificationSubject = `Subject ${ faker.string.alphanumeric( 5 ) }`; + const xssTitle = `XSS ${ faker.string.alphanumeric( 4 ) }`; + const subscriberName = `fesub${ faker.string.alphanumeric( 6 ) }`.toLowerCase(); + const subscriberPass = faker.internet.password( { length: 14 } ); + + let formId: string; + let savedOrder: string[]; + + test( 'FE0000 : Admin creates a blank post form to exercise the editor', { tag: [ '@Lite', '@Test_FE0000' ] }, async () => { + await waitForSiteReady( page, 15000 ); + await new BasicLoginPage( page ).basicLoginAndPluginVisit( Users.adminUsername, Users.adminPassword ); + + const postForm = new PostFormPage( page ); + // A second form is needed so the header form switcher has something to switch to. + await postForm.createBlankFormPostForm( secondFormName ); + await postForm.createBlankFormPostForm( formName ); + formId = await new FormEditorPage( page ).getFormId(); + } ); + + test( 'FE0001 : Editor shell renders header, tabs, stage and side panel', { tag: [ '@Lite', '@Test_FE0001' ] }, async () => { + await new FormEditorPage( page ).validateEditorShell(); + } ); + + test( 'FE0005 : Form id chip matches the id in the URL', { tag: [ '@Lite', '@Test_FE0005' ] }, async () => { + await new FormEditorPage( page ).validateFormIdChip(); + } ); + + test( 'FE0010 : Add Fields panel lists every field group', { tag: [ '@Lite', '@Test_FE0010' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.validateFieldGroups( [ 'Post Fields', 'Taxonomies', 'Custom Fields', 'Pricing Fields', 'Others' ] ); + await editor.validateFieldButtons( [ 'Post Title', 'Post Content', 'Category', 'Text', 'Dropdown', 'Section Break' ] ); + } ); + + test( 'FE0011 : Field search filters the panel and clearing restores it', { tag: [ '@Lite', '@Test_FE0011' ] }, async () => { + await new FormEditorPage( page ).validateSearchFiltersFields( 'Dropdown', 'Dropdown', 'Post Title' ); + } ); + + test( 'FE0012 : Search with no match shows no field buttons', { tag: [ '@Lite', '@Test_FE0012' ] }, async () => { + await new FormEditorPage( page ).validateSearchNoMatch( 'zzzznotafield' ); + } ); + + test( 'FE0013 : Clicking a field button adds it to the stage', { tag: [ '@Lite', '@Test_FE0013' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doAddField( 'Post Title' ); + await editor.validateFieldOnStage( 'post_title' ); + await editor.doAddField( 'Post Content' ); + await editor.validateFieldOnStage( 'post_content' ); + await editor.doAddField( 'Text' ); + await editor.validateFieldOnStage( 'text_field' ); + } ); + + test( 'FE0016 : Single-instance post fields cannot be added twice', { tag: [ '@Lite', '@Test_FE0016' ] }, async () => { + await new FormEditorPage( page ).validateSingleInstanceField( 'Post Title', 'post_title' ); + } ); + + test( 'FE0017 : Hover reveals Edit / Copy / Remove on a stage field', { tag: [ '@Lite', '@Test_FE0017' ] }, async () => { + await new FormEditorPage( page ).validateFieldActions( 'text_field' ); + } ); + + test( 'FE0018 : Edit switches the side panel to Field Options', { tag: [ '@Lite', '@Test_FE0018' ] }, async () => { + await new FormEditorPage( page ).validateEditOpensFieldOptions( 'text_field' ); + } ); + + test( 'FE0019 : Copy duplicates a field on the stage', { tag: [ '@Lite', '@Test_FE0019' ] }, async () => { + await new FormEditorPage( page ).validateCopyDuplicatesField( 'text_field' ); + } ); + + test( 'FE0020 : Remove deletes a field from the stage', { tag: [ '@Lite', '@Test_FE0020' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doRemoveField( 'text_field' ); + await editor.validateFieldRemoved( 'text_field', 1 ); + } ); + + test( 'FE0022 : Required field shows the asterisk on the stage', { tag: [ '@Lite', '@Test_FE0022' ] }, async () => { + await new FormEditorPage( page ).validateRequiredMark( 'post_title' ); + } ); + + test( 'FE0023 : Step Start renders as a multistep divider', { tag: [ '@Pro', '@Test_FE0023' ] }, async () => { + const editor = new FormEditorPage( page ); + // Step Start is a Pro field — skip instead of failing on Lite-only runs. + test.skip( ! await editor.isFieldAvailable( 'Step Start' ), 'Step Start requires wpuf-pro' ); + await editor.doAddField( 'Step Start' ); + await editor.validateStepStartOnStage(); + // Leaving it behind turns the shared form multistep, which parks the + // submit button in a hidden step for every later frontend test. + await editor.doRemoveField( 'step_start' ); + await editor.validateFieldRemoved( 'step_start', 0 ); + } ); + + test( 'FE0021 : Drag reorder persists after save', { tag: [ '@Lite', '@Test_FE0021' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doReorderFieldUp( 'post_content' ); + savedOrder = await editor.getStageFieldOrder(); + await editor.doSaveForm(); + await editor.validateStageOrderPersisted( savedOrder ); + } ); + + test( 'FE0007 : Save persists the stage field set', { tag: [ '@Lite', '@Test_FE0007' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.validateFieldOnStage( 'post_title' ); + await editor.validateFieldOnStage( 'post_content' ); + } ); + + test( 'FE0002 : Inline form title rename persists after reload', { tag: [ '@Lite', '@Test_FE0002' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doRenameForm( renamedForm ); + await editor.validateFormNamePersisted( renamedForm ); + } ); + + test( 'FE0008 : Form Editor / Settings tabs toggle their panels', { tag: [ '@Lite', '@Test_FE0008' ] }, async () => { + await new FormEditorPage( page ).validateTabSwitching(); + } ); + + test( 'FE0032 : Settings sidebar lists every section', { tag: [ '@Lite', '@Test_FE0032' ] }, async () => { + await new FormEditorPage( page ).validateSettingsSections( + [ 'General', 'Payment Settings', 'Notification Settings', 'Display Settings', 'Advanced' ], + [ 'Post Settings', 'Modules' ] + ); + } ); + + test( 'FE0033 : Clicking a settings section renders its panel', { tag: [ '@Lite', '@Test_FE0033' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.validateSettingsSectionOpens( 'General', '//select[@id="post_status"]' ); + await editor.validateSettingsSectionOpens( 'Display Settings', '//input[@id="show_form_title"]' ); + } ); + + test( 'FE0034 : Post settings (submit text) persist after save', { tag: [ '@Lite', '@Test_FE0034' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doSetSubmitText( submitText ); + await editor.validateSubmitTextPersisted( submitText ); + } ); + + test( 'FE0004 : Form switcher navigates to another form editor', { tag: [ '@Lite', '@Test_FE0004' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.validateSwitcherNavigatesTo(); + await editor.openFormEditor( formId ); + } ); + + test( 'FE0006 : Preview link renders the form on the preview page', { tag: [ '@Lite', '@Test_FE0006' ] }, async () => { + await new FormEditorPage( page ).validatePreviewOpensForm(); + } ); + + test( 'FE0040 : Frontend form renders with the selected layout class', { tag: [ '@Lite', '@Test_FE0040' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.openFormEditor( formId ); + await new PostFormPage( page ).createPageWithShortcode( `[wpuf_form id="${ formId }"]`, pageSlug ); + await editor.openFrontendForm( pageSlug ); + await editor.validateFormLayout( 'layout1' ); + } ); + + test( 'FE0047 : Required validation blocks an empty frontend submit', { tag: [ '@Lite', '@Test_FE0047' ] }, async () => { + await new FormEditorPage( page ).validateRequiredValidation(); + } ); + + test( 'FE0046 : Frontend submit creates the post', { tag: [ '@Lite', '@Test_FE0046' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.openFrontendForm( pageSlug ); + await editor.doSubmitFrontendPost( postTitle, faker.lorem.paragraph() ); + await editor.validatePostCreated( postTitle ); + } ); + + /**------------------------ FIELD OPTIONS PANEL ------------------------**/ + + test( 'FE0024 : Field Options shows an empty state on a form with no fields', { tag: [ '@Lite', '@Test_FE0024' ] }, async () => { + const editor = new FormEditorPage( page ); + // A form with fields auto-selects the first one, so the empty state needs + // a fresh blank form. + await new PostFormPage( page ).createBlankFormPostForm( `FE Empty ${ faker.string.alphanumeric( 5 ) }` ); + await editor.validateFieldOptionsEmptyState(); + await editor.openFormEditor( formId ); + } ); + + test( 'FE0025 : Label edit updates the stage label live', { tag: [ '@Lite', '@Test_FE0025' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doEditField( 'text_field' ); + await editor.validateLabelUpdatesStage( 'text_field', fieldLabel ); + } ); + + test( 'FE0027 : Help text edit shows under the field on the stage', { tag: [ '@Lite', '@Test_FE0027' ] }, async () => { + await new FormEditorPage( page ).validateHelpTextUpdatesStage( 'text_field', helpText ); + } ); + + test( 'FE0028 : Required toggle adds and removes the asterisk', { tag: [ '@Lite', '@Test_FE0028' ] }, async () => { + await new FormEditorPage( page ).validateRequiredTogglesAsterisk( 'text_field' ); + } ); + + test( 'FE0029 : Field size option applies the size class on the stage', { tag: [ '@Lite', '@Test_FE0029' ] }, async () => { + await new FormEditorPage( page ).validateFieldSizeAppliesClass( 'text_field', 'small' ); + } ); + + test( 'FE0026 : Meta key is locked for post fields', { tag: [ '@Lite', '@Test_FE0026' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doEditField( 'post_title' ); + await editor.validateMetaKeyLocked(); + } ); + + test( 'FE0031 : Conditional logic is offered once more than one field exists', { tag: [ '@Pro', '@Test_FE0031' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doEditField( 'text_field' ); + // Fields_Manager::add_conditional_field() registers it in the `advanced` + // section, which is collapsed until expanded — without this the option is + // never visible and the pro gate below misfires on a Pro site too. + await editor.doExpandAdvancedOptions(); + // Conditional logic ships with wpuf-pro. + test.skip( ! await editor.isFieldOptionAvailable( 'Conditional Logic' ), 'Conditional Logic requires wpuf-pro' ); + expect( await editor.isFieldOptionAvailable( 'Conditional Logic' ) ).toBeTruthy(); + } ); + + test( 'FE0030 : Dropdown options render on the stage', { tag: [ '@Lite', '@Test_FE0030' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doAddField( 'Dropdown' ); + await editor.validateFieldOnStage( 'dropdown_field' ); + // Default preset: the "- select -" placeholder plus one empty option. + await editor.validateStageDropdownOptions( 'dropdown_field', 2 ); + } ); + + /**------------------------ PANEL & HEADER EXTRAS ----------------------**/ + + test( 'FE0014 : Dragging a panel field onto the stage adds it', { tag: [ '@Lite', '@Test_FE0014' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doDragFieldToStage( 'Textarea' ); + await editor.validateFieldOnStage( 'textarea_field' ); + } ); + + test( 'FE0015 : Pro-only fields are shown gated with the pro badge', { tag: [ '@Lite', '@Test_FE0015' ] }, async () => { + const editor = new FormEditorPage( page ); + // With wpuf-pro active there is nothing to gate. + test.skip( ! await editor.hasProPreviewFields(), 'No pro-preview fields (wpuf-pro is active)' ); + await editor.validateProPreviewFieldsAreGated(); + } ); + + test( 'FE0009 : Leaving a dirty editor raises the unsaved-changes guard', { tag: [ '@Lite', '@Test_FE0009' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.openFormEditor( formId ); + await editor.validateUnsavedChangesGuard(); + } ); + + /**--------------------------- SETTINGS TAB ----------------------------**/ + + test( 'FE0035 : Multistep toggle reveals the progress bar options', { tag: [ '@Pro', '@Test_FE0035' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + // Multistep is a Pro form setting. + test.skip( ! await editor.isSettingAvailable( 'enable_multistep' ), 'Multistep requires wpuf-pro' ); + await editor.validateMultistepRevealsOptions(); + } ); + + test( 'FE0036 : Form style (layout) selection persists', { tag: [ '@Pro', '@Test_FE0036' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doOpenSettingsSection( 'Display Settings' ); + // The layout picker ships with wpuf-pro. + test.skip( ! await editor.isSettingAvailable( 'form_layout' ), 'Form style picker requires wpuf-pro' ); + await editor.doPickRadioSetting( 'form_layout', 'layout2' ); + await editor.doSaveForm(); + await page.reload(); + await editor.doOpenSettingsSection( 'Display Settings' ); + await editor.validateRadioSettingPicked( 'form_layout', 'layout2' ); + // Later frontend tests assert the default layout, so hand the shared + // form back the way it was found. + await editor.doPickRadioSetting( 'form_layout', 'layout1' ); + await editor.doSaveForm(); + } ); + + test( 'FE0037 : Payment settings persist after save', { tag: [ '@Lite', '@Test_FE0037' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doOpenSettingsSection( 'Payment Settings' ); + await editor.doToggleSetting( 'payment_options', true ); + + // The per-post cost row only exists when pay-per-post is switched on + // globally, so treat it as optional here. + const hasPerPostCost = await editor.isSettingVisible( 'pay_per_post_cost' ); + + if ( hasPerPostCost ) { + await editor.doFillSetting( 'pay_per_post_cost', '25' ); + } + + await editor.doSaveForm(); + await page.reload(); + await editor.doOpenSettingsSection( 'Payment Settings' ); + await editor.validateSettingChecked( 'payment_options', true ); + + if ( hasPerPostCost ) { + await editor.validateSettingValue( 'pay_per_post_cost', '25' ); + } + } ); + + test( 'FE0038 : Notification settings persist after save', { tag: [ '@Lite', '@Test_FE0038' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doOpenSettingsSection( 'Notification Settings' ); + await editor.doFillSetting( 'new_subject', notificationSubject ); + await editor.doSaveForm(); + await page.reload(); + await editor.doOpenSettingsSection( 'Notification Settings' ); + await editor.validateSettingValue( 'new_subject', notificationSubject ); + } ); + + test( 'FE0039 : Form title and description toggles reach the frontend', { tag: [ '@Lite', '@Test_FE0039' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.doOpenSettingsSection( 'General' ); + await editor.doToggleSetting( 'show_form_title', true ); + await editor.doFillSetting( 'form_description', formDescription ); + await editor.doSaveForm(); + + await editor.openFrontendForm( pageSlug ); + await editor.validateFormTitleAndDescription( renamedForm, formDescription ); + } ); + + /**------------------------- FRONTEND TEMPLATES ------------------------**/ + + test( 'FE0041 : Form title and description render on the template', { tag: [ '@Lite', '@Test_FE0041' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.openFrontendForm( pageSlug ); + await editor.validateFormLayout( 'layout1' ); + await editor.validateFormTitleAndDescription( renamedForm, formDescription ); + } ); + + test( 'FE0042 : Field help text renders under the field', { tag: [ '@Lite', '@Test_FE0042' ] }, async () => { + const editor = new FormEditorPage( page ); + // FE0027 only proved the live stage preview; persist it before checking + // the rendered form. + await editor.openFormEditor( formId ); + await editor.doEditField( 'text_field' ); + await editor.validateHelpTextUpdatesStage( 'text_field', helpText ); + await editor.doSaveForm(); + + await editor.openFrontendForm( pageSlug ); + await editor.validateHelpTextRendered( helpText ); + } ); + + test( 'FE0044 : Native selects use the template chevron', { tag: [ '@Lite', '@Test_FE0044' ] }, async () => { + const editor = new FormEditorPage( page ); + // Needs a saved select on the form — FE0030 only checked the stage. + await editor.openFormEditor( formId ); + await editor.doAddField( 'Dropdown' ); + await editor.doSaveForm(); + + await editor.openFrontendForm( pageSlug ); + await editor.validateSelectStyling(); + } ); + + test( 'FE0045 : Image field with a single-file limit blocks a second upload', { tag: [ '@Lite', '@Test_FE0045' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.openFormEditor( formId ); + await editor.doAddField( 'Image Upload' ); + await editor.doSaveForm(); + + await editor.openFrontendForm( pageSlug ); + await editor.validateSingleImageUploadEnforced( PostForm.imageUpload ); + } ); + + test( 'FE0043 : Multistep navigation works on the frontend', { tag: [ '@Pro', '@Test_FE0043' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.openFormEditor( formId ); + // Needs the Pro Step Start field on the stage. + test.skip( ! await editor.isFieldAvailable( 'Step Start' ), 'Multistep requires wpuf-pro' ); + await editor.doOpenSettingsSection( 'Display Settings' ); + await editor.doToggleSetting( 'enable_multistep', true ); + await editor.doOpenTab( 'Form Editor' ); + // Step Start opens a step, so two of them are needed for a form with + // something to navigate to: one above the fields, one before submit. + await editor.doAddField( 'Step Start' ); + await editor.doReorderFieldUp( 'step_start' ); + await editor.doAddField( 'Step Start' ); + await editor.doSaveForm(); + await editor.openFrontendForm( pageSlug ); + await editor.validateMultistepNavigation(); + } ); + + /**--------------------------- NEGATIVE CASES --------------------------**/ + + test( 'FE0049 : An unknown form id does not break the editor page', { tag: [ '@Lite', '@Test_FE0049' ] }, async () => { + await new FormEditorPage( page ).validateInvalidFormIdHandled( '99999999' ); + } ); + + test( 'FE0050 : A script payload in the form title is escaped on the frontend', { tag: [ '@Lite', '@Test_FE0050' ] }, async () => { + const editor = new FormEditorPage( page ); + await editor.openFormEditor( formId ); + await editor.validateTitleXssEscaped( xssTitle, pageSlug ); + } ); + + // KNOWN BUG (see BUGS-FOUND.md #2): the builder saves an empty form title, so + // the form ends up nameless in the forms list. Marked test.fail() so the suite + // stays honest — it turns red the day the guard lands. Runs late because it + // blanks the form name. + test( 'FE0003 : Empty form title is not persisted', { tag: [ '@Lite', '@Test_FE0003' ] }, async () => { + test.fail(); + const editor = new FormEditorPage( page ); + await editor.openFormEditor( formId ); + await editor.validateEmptyTitleRejected( xssTitle ); + } ); + + test( 'FE0048 : A subscriber cannot open the form builder', { tag: [ '@Lite', '@Test_FE0048' ] }, async () => { + await new FormEditorPage( page ).validateBuilderBlockedForSubscriber( formId, subscriberName, subscriberPass ); + } ); +} ); diff --git a/tests/e2e/tests/postFormDisplayAdvancedTest.spec.ts b/tests/e2e/tests/postFormDisplayAdvancedTest.spec.ts new file mode 100644 index 000000000..63a725e5f --- /dev/null +++ b/tests/e2e/tests/postFormDisplayAdvancedTest.spec.ts @@ -0,0 +1,332 @@ +import { Browser, BrowserContext, Page, test, expect, chromium } from '@playwright/test'; +import { BasicLoginPage } from '../pages/basicLogin'; +import { PostFormPage } from '../pages/postForm'; +import { PostFormGapsPage } from '../pages/postFormGaps'; +import { Users } from '../utils/testData'; +import { faker } from '@faker-js/faker'; +import { configureSpecFailFast } from '../utils/specFailFast'; +import { waitForSiteReady } from '../utils/siteReady'; +import { wpCliAvailable, wpCli, getPostIdByTitle, getPostMeta } from '../utils/wpEnvCli'; + +let browser: Browser; +let context: BrowserContext; +let page: Page; + +test.beforeAll( async () => { + browser = await chromium.launch(); + context = await browser.newContext(); + page = await context.newPage(); + + page.on( 'dialog', async ( dialog ) => { + await dialog.accept().catch( () => {} ); + } ); +} ); + +test.describe( 'Post-Form-Display-Advanced', () => { + configureSpecFailFast(); + + /**------------- DISPLAY / ADVANCED / INTEGRATIONS (§8.2–8.5) -------------** + * + * @TestScenario : [Display Settings, Advanced, Post Expiration siblings] + * + * Display + * @Test_PFG0010 : Use Theme CSS off keeps WPUF's own form styling + * @Test_PFG0011 : Use Theme CSS on hands styling to the theme + * @Test_PFG0012 : Label Position = Above Element + * @Test_PFG0013 : Label Position = Left / Right + * @Test_PFG0014 : Label Position = Hidden + * @Test_PFG0015 : Form style selection persists and reaches the frontend (@Pro) + * + * Advanced — form scheduling + * @Test_PFG0020 : Inside the schedule window the form is submittable + * @Test_PFG0021 : Before the window opens the pending notice shows + * @Test_PFG0022 : After the window closes the expired notice shows + * @Test_PFG0023 : Scheduling off restores the form + * + * Integrations + * @Test_PFG0040 : AI Review enabled writes a review record (env-gated) + * @Test_PFG0041 : AI Review disabled writes nothing + * @Test_PFG0042 : N8N enabled still lets the submission through + * @Test_PFG0043 : N8N with an unreachable webhook is not fatal + * @Test_PFG0044 : Modules panel shows its empty state and link + * + */ + + const formName = `DA Form ${ faker.string.alphanumeric( 5 ) }`; + const pageSlug = `da-form-${ faker.string.alphanumeric( 5 ) }`.toLowerCase(); + const pendingMessage = `Not open yet ${ faker.string.alphanumeric( 5 ) }`; + const expiredMessage = `Closed already ${ faker.string.alphanumeric( 5 ) }`; + + let formId: string; + + const isoDate = ( offsetDays: number ): string => { + const date = new Date(); + date.setDate( date.getDate() + offsetDays ); + + return date.toISOString().slice( 0, 10 ); + }; + + test( 'PFG0010 : Setup + Use Theme CSS off keeps WPUF form styling', { tag: [ '@Lite', '@Test_PFG0010' ] }, async () => { + await waitForSiteReady( page, 15000 ); + await new BasicLoginPage( page ).basicLoginAndPluginVisit( Users.adminUsername, Users.adminPassword ); + + const postForm = new PostFormPage( page ); + await postForm.createBlankFormPostForm( formName ); + + const editor = new PostFormGapsPage( page ); + formId = await editor.getFormId(); + await editor.doAddField( 'Post Title' ); + await editor.doAddField( 'Post Content' ); + await editor.doSaveForm(); + + await postForm.createPageWithShortcode( `[wpuf_form id="${ formId }"]`, pageSlug ); + await editor.validateThemeCssUsed( pageSlug, false ); + } ); + + test( 'PFG0011 : Use Theme CSS on hands styling to the theme', { tag: [ '@Lite', '@Test_PFG0011' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + await editor.doToggleSetting( 'use_theme_css', true ); + await editor.doSaveForm(); + + await editor.validateThemeCssUsed( pageSlug, true ); + + // Put it back — the label-position assertions below read the WPUF markup. + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + await editor.doToggleSetting( 'use_theme_css', false ); + await editor.doSaveForm(); + } ); + + test( 'PFG0012 : Label Position = Above Element', { tag: [ '@Lite', '@Test_PFG0012' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + await editor.doSelectSetting( 'label_position', 'above' ); + await editor.doSaveForm(); + + await editor.validateLabelPosition( pageSlug, 'above' ); + } ); + + test( 'PFG0013 : Label Position = Left / Right', { tag: [ '@Lite', '@Test_PFG0013' ] }, async () => { + const editor = new PostFormGapsPage( page ); + + for ( const position of [ 'left', 'right' ] ) { + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + await editor.doSelectSetting( 'label_position', position ); + await editor.doSaveForm(); + await editor.validateLabelPosition( pageSlug, position ); + } + } ); + + test( 'PFG0014 : Label Position = Hidden', { tag: [ '@Lite', '@Test_PFG0014' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + await editor.doSelectSetting( 'label_position', 'hidden' ); + await editor.doSaveForm(); + + await editor.validateLabelPosition( pageSlug, 'hidden' ); + // With labels hidden the field label must not be painted on screen. + await expect( page.locator( '//form[contains(@class,"wpuf-form")]//label[normalize-space()="Post Title"]' ).first() ).toBeHidden(); + + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + await editor.doSelectSetting( 'label_position', 'above' ); + await editor.doSaveForm(); + } ); + + test( 'PFG0015 : Form style selection persists and reaches the frontend', { tag: [ '@Pro', '@Test_PFG0015' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + // The layout picker ships with wpuf-pro. + test.skip( ! await editor.isSettingAvailable( 'form_layout' ), 'Form style picker requires wpuf-pro' ); + + await editor.doPickRadioSetting( 'form_layout', 'layout2' ); + await editor.doSaveForm(); + await page.reload(); + await editor.doOpenSettingsSection( 'Display Settings' ); + await editor.validateRadioSettingPicked( 'form_layout', 'layout2' ); + + await editor.openFrontendForm( pageSlug ); + await editor.validateFormLayout( 'layout2' ); + + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Display Settings' ); + await editor.doPickRadioSetting( 'form_layout', 'layout1' ); + await editor.doSaveForm(); + } ); + + /**------------------------- FORM SCHEDULING --------------------------**/ + + test( 'PFG0020 : Inside the schedule window the form is submittable', { tag: [ '@Lite', '@Test_PFG0020' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doScheduleForm( isoDate( -1 ), isoDate( 1 ), pendingMessage, expiredMessage ); + + await editor.validateFormSubmittable( pageSlug ); + } ); + + test( 'PFG0021 : Before the window opens the pending notice shows', { tag: [ '@Lite', '@Test_PFG0021' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doScheduleForm( isoDate( 2 ), isoDate( 9 ), pendingMessage, expiredMessage ); + + await editor.validateFormScheduleNotice( pageSlug, pendingMessage ); + } ); + + test( 'PFG0022 : After the window closes the expired notice shows', { tag: [ '@Lite', '@Test_PFG0022' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doScheduleForm( isoDate( -9 ), isoDate( -2 ), pendingMessage, expiredMessage ); + + await editor.validateFormScheduleNotice( pageSlug, expiredMessage ); + } ); + + test( 'PFG0023 : Scheduling off restores the form', { tag: [ '@Lite', '@Test_PFG0023' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doUnscheduleForm(); + + await editor.validateFormSubmittable( pageSlug ); + } ); + + /**--------------------- AI REVIEW / N8N / MODULES ---------------------**/ + + test( 'PFG0040 : AI Review enabled writes a review record', { tag: [ '@Pro', '@Test_PFG0040' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'AI Review' ); + test.skip( ! await editor.isSettingAvailable( 'ai_review_enabled' ), 'AI Review requires wpuf-pro' ); + test.skip( ! wpCliAvailable(), 'Reading the review meta needs wp-cli' ); + + // The service refuses to review unless a provider + key are configured in + // WPUF → Settings → AI (option `wpuf_ai`), so this is env-gated. + const aiConfigured = ( () => { + try { + return wpCli( 'option get wpuf_ai --format=json' ).includes( 'ai_provider' ); + } catch { + return false; + } + } )(); + test.skip( ! aiConfigured, 'AI review needs a configured provider in the wpuf_ai option' ); + + await editor.doToggleSetting( 'ai_review_enabled', true ); + await editor.doSaveForm(); + + const title = `AI Review ${ faker.string.alphanumeric( 5 ) }`; + await editor.openFrontendForm( pageSlug ); + await editor.doSubmitFrontendPost( title, faker.lorem.paragraph() ); + + const postId = getPostIdByTitle( title ); + expect( postId ).toBeGreaterThan( 0 ); + await expect + .poll( () => getPostMeta( postId, 'wpuf_ai_post_review' ), { timeout: 60000 } ) + .not.toBe( '' ); + } ); + + // BUG (see BUGS-FOUND.md): the AI review runs on every submission regardless + // of the form's `ai_review_enabled` setting, stamping `wpuf_ai_post_review` + // (status "failed", "AI service not configured") onto posts from forms that + // never opted in. test.fail() keeps the expectation on record. + test( 'PFG0041 : AI Review disabled writes nothing', { tag: [ '@Pro', '@Test_PFG0041' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'AI Review' ); + test.skip( ! await editor.isSettingAvailable( 'ai_review_enabled' ), 'AI Review requires wpuf-pro' ); + test.skip( ! wpCliAvailable(), 'Reading the review meta needs wp-cli' ); + test.fail(); + + await editor.doToggleSetting( 'ai_review_enabled', false ); + await editor.doSaveForm(); + + const title = `No AI Review ${ faker.string.alphanumeric( 5 ) }`; + await editor.openFrontendForm( pageSlug ); + await editor.doSubmitFrontendPost( title, faker.lorem.paragraph() ); + + const postId = getPostIdByTitle( title ); + expect( postId ).toBeGreaterThan( 0 ); + expect( getPostMeta( postId, 'wpuf_ai_post_review' ) ).toBe( '' ); + } ); + + test( 'PFG0042 : N8N enabled still lets the submission through', { tag: [ '@Lite', '@Test_PFG0042' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'N8N' ); + test.skip( ! await editor.isSettingAvailable( 'enable_n8n' ), 'N8N integration is not available on this build' ); + + await editor.doToggleSetting( 'enable_n8n', true ); + // QA_N8N_WEBHOOK_URL lets a CI job point this at a real receiver and + // assert the payload there; without it the webhook target is inert and + // only the "submission survives the POST attempt" half is proven. + await editor.doFillSetting( 'n8n_webhook_url', process.env.QA_N8N_WEBHOOK_URL || 'https://example.test/wpuf-n8n' ); + await editor.doSaveForm(); + await page.reload(); + await editor.doOpenSettingsSection( 'N8N' ); + await editor.validateSettingChecked( 'enable_n8n', true ); + + const title = `N8N Post ${ faker.string.alphanumeric( 5 ) }`; + await editor.openFrontendForm( pageSlug ); + await editor.doSubmitFrontendPost( title, faker.lorem.paragraph() ); + await editor.validatePostCreated( title ); + } ); + + test( 'PFG0043 : N8N with an unreachable webhook is not fatal', { tag: [ '@Lite', '@Test_PFG0043' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'N8N' ); + test.skip( ! await editor.isSettingAvailable( 'enable_n8n' ), 'N8N integration is not available on this build' ); + + await editor.doFillSetting( 'n8n_webhook_url', 'http://127.0.0.1:9/never-listens' ); + await editor.doSaveForm(); + + const title = `N8N Dead ${ faker.string.alphanumeric( 5 ) }`; + await editor.openFrontendForm( pageSlug ); + await editor.doSubmitFrontendPost( title, faker.lorem.paragraph() ); + await editor.validatePostCreated( title ); + await expect( page.locator( 'body' ) ).not.toContainText( 'Fatal error' ); + + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'N8N' ); + await editor.doToggleSetting( 'enable_n8n', false ); + await editor.doSaveForm(); + } ); + + test( 'PFG0044 : Modules panel shows its empty state and link', { tag: [ '@Lite', '@Test_PFG0044' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doOpenTab( 'Settings' ); + + // The sidebar only renders clickable
  • entries for a group's `sub_items`. + // Pro registers the Modules group with `sub_items => apply_filters( ..., [] )`, + // so with no module contributing one the group is a heading with nothing to + // click, and the empty-state panel (gated on Lite or an empty modules item) + // is not rendered at all. + const modulesNavItem = page.locator( '//div[@id="wpuf-form-builder-settings"]//li//a[normalize-space()="Modules"]' ).first(); + + if ( await modulesNavItem.isVisible().catch( () => false ) ) { + await editor.doOpenSettingsSection( 'Modules' ); + + const emptyState = page.locator( '//*[contains(normalize-space(),"No modules have been activated yet")]' ).first(); + + if ( await emptyState.isVisible().catch( () => false ) ) { + const modulesLink = page.locator( '//a[normalize-space()="Go To Module Page"]' ).first(); + await expect( modulesLink ).toBeVisible(); + expect( await modulesLink.getAttribute( 'href' ) ).toContain( 'wpuf-modules' ); + } else { + // Modules are active — the panel lists them instead. + await expect( page.locator( '//div[@id="wpuf-form-builder-settings"]' ) ).toBeVisible(); + } + + return; + } + + // No clickable entry: the Modules group must still be present as a heading. + await expect( + page.locator( '//div[@id="wpuf-form-builder-settings"]//h2[.//span[normalize-space()="Modules"]]' ).first() + ).toBeVisible(); + } ); +} ); diff --git a/tests/e2e/tests/postFormExpirationTestPro.spec.ts b/tests/e2e/tests/postFormExpirationTestPro.spec.ts new file mode 100644 index 000000000..872b00b38 --- /dev/null +++ b/tests/e2e/tests/postFormExpirationTestPro.spec.ts @@ -0,0 +1,320 @@ +import { Browser, BrowserContext, Page, test, expect, chromium } from '@playwright/test'; +import { BasicLoginPage } from '../pages/basicLogin'; +import { PostFormPage } from '../pages/postForm'; +import { PostFormGapsPage } from '../pages/postFormGaps'; +import { Users } from '../utils/testData'; +import { faker } from '@faker-js/faker'; +import { configureSpecFailFast } from '../utils/specFailFast'; +import { waitForSiteReady } from '../utils/siteReady'; +import { + wpCliAvailable, + wpCli, + getPostIdByTitle, + getPostField, + getPostMeta, + setPostMeta, + deletePostMeta, + runCronHook, + seedUserWithRole, +} from '../utils/wpEnvCli'; + +let browser: Browser; +let context: BrowserContext; +let page: Page; + +test.beforeAll( async () => { + browser = await chromium.launch(); + context = await browser.newContext(); + page = await context.newPage(); + + page.on( 'dialog', async ( dialog ) => { + await dialog.accept().catch( () => {} ); + } ); +} ); + +test.describe( 'Post-Form-Expiration-And-Edit-Lock', () => { + configureSpecFailFast(); + + /**--------------- POST EXPIRATION + EDIT LOCK (§8.6) ---------------** + * + * @TestScenario : [Settings → Post Expiration, and General → Lock User Editing] + * + * Expiry itself runs on the daily `wpuf_remove_expired_post_hook` cron + * (wpuf-pro/includes/Post_Expiration.php), so the tests move the stored + * expiration date into the past and fire the hook instead of waiting a day. + * + * @Test_PFG0050 : Expiration settings round-trip through Save + * @Test_PFG0051 : An expired post flips to the configured status + * @Test_PFG0052 : Duration types write the matching expiration date + * @Test_PFG0053 : Expiration email is queued for the author (env-gated) + * @Test_PFG0054 : Expiration disabled writes no expiry metadata + * @Test_PFG0055 : Inside the edit window the post is still editable + * @Test_PFG0056 : After the edit window the edit form is refused + * + */ + + const formName = `EXP Form ${ faker.string.alphanumeric( 5 ) }`; + const pageSlug = `exp-form-${ faker.string.alphanumeric( 5 ) }`.toLowerCase(); + const dashboardSlug = `exp-dash-${ faker.string.alphanumeric( 5 ) }`.toLowerCase(); + const editSlug = `exp-edit-${ faker.string.alphanumeric( 5 ) }`.toLowerCase(); + const expirationMessage = `Expired notice ${ faker.string.alphanumeric( 5 ) }`; + const authorLogin = `expauth${ faker.string.alphanumeric( 6 ) }`.toLowerCase(); + const authorPass = faker.internet.password( { length: 14 } ); + + let formId: string; + // Set by PFG0055, read by PFG0056 (serial spec). + let lockPostTitle: string; + + test( 'PFG0050 : Expiration settings round-trip through Save', { tag: [ '@Pro', '@Test_PFG0050' ] }, async () => { + test.skip( ! wpCliAvailable(), 'Expiration assertions need wp-cli (post meta + cron)' ); + + await waitForSiteReady( page, 15000 ); + await new BasicLoginPage( page ).basicLoginAndPluginVisit( Users.adminUsername, Users.adminPassword ); + + const postForm = new PostFormPage( page ); + await postForm.createBlankFormPostForm( formName ); + + const editor = new PostFormGapsPage( page ); + formId = await editor.getFormId(); + await editor.doAddField( 'Post Title' ); + await editor.doAddField( 'Post Content' ); + await editor.doSaveForm(); + await postForm.createPageWithShortcode( `[wpuf_form id="${ formId }"]`, pageSlug ); + + await editor.doOpenSettingsSection( 'Post Expiration' ); + // Post expiration ships with wpuf-pro. + test.skip( ! await editor.isSettingAvailable( 'enable_post_expiration' ), 'Post Expiration requires wpuf-pro' ); + + await editor.doToggleSetting( 'enable_post_expiration', true ); + await editor.doFillSetting( 'expiration_time_value', '1' ); + await editor.doSelectSetting( 'expiration_time_type', 'day' ); + await editor.doSelectSetting( 'expired_post_status', 'draft' ); + await editor.doToggleSetting( 'enable_mail_after_expired', true ); + await editor.doFillSetting( 'post_expiration_message', expirationMessage ); + await editor.doSaveForm(); + + await page.reload(); + await editor.doOpenSettingsSection( 'Post Expiration' ); + await editor.validateSettingChecked( 'enable_post_expiration', true ); + await editor.validateSettingText( 'expiration_time_value', '1' ); + await editor.validateSettingSelected( 'expiration_time_type', 'day' ); + await editor.validateSettingSelected( 'expired_post_status', 'draft' ); + await editor.validateSettingChecked( 'enable_mail_after_expired', true ); + } ); + + // Set by PFG0051, reused by PFG0051a. + let expiringPostId = 0; + + test( 'PFG0051 : The form expiration settings land on the submitted post', { tag: [ '@Pro', '@Test_PFG0051' ] }, async () => { + const editor = new PostFormGapsPage( page ); + const title = `EXP Post ${ faker.string.alphanumeric( 5 ) }`; + + await editor.openFrontendForm( pageSlug ); + await editor.doSubmitFrontendPost( title, faker.lorem.paragraph() ); + + expiringPostId = getPostIdByTitle( title ); + expect( expiringPostId ).toBeGreaterThan( 0 ); + expect( getPostField( expiringPostId, 'post_status' ) ).toBe( 'publish' ); + expect( getPostMeta( expiringPostId, 'wpuf-expired_post_status' ) ).toBe( 'draft' ); + } ); + + // BUG (see BUGS-FOUND.md #21): Frontend_Form_Ajax::wpuf_user_subscription_pack() + // enters the subscription-pack branch on `isset($pack['_enable_post_expiration'])` + // rather than on its value, so a pack with expiration switched OFF still writes + // `wpuf-post_expiration_date = 1970-01-01` and the pack's `_expired_post_status`. + // Pro's Post_Expiration::save_expiration_meta() then bails on the same isset() + // test, so the form's own settings never reach the post. PFG0051 above passes + // only because the submitting user holds no pack yet. + test( 'PFG0051b : A pack with expiration off does not override the form', { tag: [ '@Pro', '@Test_PFG0051b' ] }, async () => { + test.fail(); + const editor = new PostFormGapsPage( page ); + const title = `EXP Pack ${ faker.string.alphanumeric( 5 ) }`; + + // A completed pack that explicitly disables its own post expiration. + const pack = { + pack_id: '0', + status: 'completed', + expire: new Date( Date.now() + 86400000 ).toISOString().slice( 0, 19 ).replace( 'T', ' ' ), + _enable_post_expiration: 'no', + _post_expiration_time: '', + _expired_post_status: 'publish', + }; + + try { + wpCli( `user meta update ${ Users.adminUsername } _wpuf_subscription_pack '${ JSON.stringify( pack ) }' --format=json` ); + + await editor.openFrontendForm( pageSlug ); + await editor.doSubmitFrontendPost( title, faker.lorem.paragraph() ); + + const postId = getPostIdByTitle( title ); + expect( postId ).toBeGreaterThan( 0 ); + // The form says draft / 1 day; the disabled pack must not overrule it. + expect( getPostMeta( postId, 'wpuf-expired_post_status' ) ).toBe( 'draft' ); + expect( getPostMeta( postId, 'wpuf-post_expiration_date' ) ).not.toBe( '1970-01-01' ); + } finally { + wpCli( `user meta delete ${ Users.adminUsername } _wpuf_subscription_pack` ); + } + } ); + + test( 'PFG0051a : The expiry cron flips a due post to its expired status', { tag: [ '@Pro', '@Test_PFG0051a' ] }, async () => { + test.skip( ! expiringPostId, 'PFG0051 did not produce a post to expire' ); + + // Seed both metas directly so this covers process_expired_posts() itself, + // independent of the save-time defect PFG0051 records. + const yesterday = new Date( Date.now() - 86400000 ).toISOString().slice( 0, 10 ); + setPostMeta( expiringPostId, 'wpuf-expired_post_status', 'draft' ); + setPostMeta( expiringPostId, 'wpuf-post_expiration_date', yesterday ); + deletePostMeta( expiringPostId, 'wpuf-post_expired' ); + + runCronHook( 'wpuf_remove_expired_post_hook' ); + + expect( getPostField( expiringPostId, 'post_status' ) ).toBe( 'draft' ); + // The guard meta stops the same post being processed twice. + expect( getPostMeta( expiringPostId, 'wpuf-post_expired' ) ).not.toBe( '' ); + } ); + + test( 'PFG0052 : Duration types write the matching expiration date', { tag: [ '@Pro', '@Test_PFG0052' ] }, async () => { + const editor = new PostFormGapsPage( page ); + + // Post_Form.php offers exactly day / week / month — there is no "year". + // `expiration_time_value` is 1 (set in PFG0050), so each type is one unit. + for ( const type of [ 'week', 'month' ] as const ) { + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Post Expiration' ); + await editor.doSelectSetting( 'expiration_time_type', type ); + await editor.doSaveForm(); + + const title = `EXP ${ type } ${ faker.string.alphanumeric( 5 ) }`; + await editor.openFrontendForm( pageSlug ); + await editor.doSubmitFrontendPost( title, faker.lorem.paragraph() ); + + const postId = getPostIdByTitle( title ); + + // WPUF stores gmdate( 'Y-m-d', strtotime( '+1 ' ) ). + const expected = new Date(); + + if ( type === 'week' ) { + expected.setDate( expected.getDate() + 7 ); + expect( getPostMeta( postId, 'wpuf-post_expiration_date' ) ) + .toBe( expected.toISOString().slice( 0, 10 ) ); + } else { + expected.setMonth( expected.getMonth() + 1 ); + // Compare on year-month: day-of-month rolls over for long months. + expect( getPostMeta( postId, 'wpuf-post_expiration_date' ).slice( 0, 7 ) ) + .toBe( expected.toISOString().slice( 0, 7 ) ); + } + } + + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Post Expiration' ); + await editor.doSelectSetting( 'expiration_time_type', 'day' ); + await editor.doSaveForm(); + } ); + + test( 'PFG0053 : Expiration email is queued for the author', { tag: [ '@Pro', '@Test_PFG0053' ] }, async () => { + const editor = new PostFormGapsPage( page ); + const title = `EXP Mail ${ faker.string.alphanumeric( 5 ) }`; + + await editor.openFrontendForm( pageSlug ); + await editor.doSubmitFrontendPost( title, faker.lorem.paragraph() ); + + const postId = getPostIdByTitle( title ); + // The message meta is only written when "send email" is on, so it is the + // stable pre-condition for the mail itself. + expect( getPostMeta( postId, 'wpuf-post_expiration_message' ) ).toContain( expirationMessage ); + + const yesterday = new Date( Date.now() - 86400000 ).toISOString().slice( 0, 10 ); + setPostMeta( postId, 'wpuf-post_expiration_date', yesterday ); + runCronHook( 'wpuf_remove_expired_post_hook' ); + + // Reading the delivered mail needs a mail-log plugin; without one only the + // status flip above is provable. + await page.goto( new PostFormGapsPage( page ).wpMailLogPage ).catch( () => {} ); + const hasMailLog = await page.locator( '//table' ).first().isVisible().catch( () => false ); + test.skip( ! hasMailLog, 'No mail-log plugin on this site — expiration mail cannot be read' ); + + await expect( page.locator( 'body' ) ).toContainText( title ); + } ); + + test( 'PFG0054 : Expiration disabled writes no expiry metadata', { tag: [ '@Pro', '@Test_PFG0054' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'Post Expiration' ); + await editor.doToggleSetting( 'enable_post_expiration', false ); + await editor.doSaveForm(); + + const title = `EXP Off ${ faker.string.alphanumeric( 5 ) }`; + await editor.openFrontendForm( pageSlug ); + await editor.doSubmitFrontendPost( title, faker.lorem.paragraph() ); + + const postId = getPostIdByTitle( title ); + expect( getPostMeta( postId, 'wpuf-post_expiration_date' ) ).toBe( '' ); + expect( getPostField( postId, 'post_status' ) ).toBe( 'publish' ); + } ); + + /**------------------------- EDIT LOCK WINDOW -------------------------**/ + + test( 'PFG0055 : Inside the edit window the post is still editable', { tag: [ '@Lite', '@Test_PFG0055' ] }, async () => { + const editor = new PostFormGapsPage( page ); + const postForm = new PostFormPage( page ); + + // The lock only applies to the post author, so the post is submitted by a + // plain author account driving the dashboard → edit link. + seedUserWithRole( authorLogin, `${ authorLogin }@example.test`, authorPass, 'author' ); + + await postForm.createPageWithShortcodeGeneral( '[wpuf_dashboard]', dashboardSlug ); + await postForm.createPageWithShortcodeGeneral( '[wpuf_edit]', editSlug ); + // The dashboard builds its Edit href from this global option. + await editor.doSetGlobalEditPage( editSlug ); + + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'General' ); + await editor.doFillSetting( 'lock_edit_post', '2' ); + await editor.doSaveForm(); + + const title = `LOCK Post ${ faker.string.alphanumeric( 5 ) }`; + + await editor.withUser( authorLogin, authorPass, async ( authorPage ) => { + await authorPage.goto( `${ editor.siteHomePage }/${ pageSlug }/` ); + await authorPage.locator( '//form[contains(@class,"wpuf-form")]//input[@name="post_title"]' ).fill( title ); + await authorPage.locator( '//form[contains(@class,"wpuf-form")]//textarea[@name="post_content"]' ) + .fill( faker.lorem.paragraph() ) + .catch( async () => { + await authorPage.frameLocator( 'form[class*="wpuf-form"] iframe[id$="_ifr"]' ).locator( 'body' ).fill( faker.lorem.paragraph() ); + } ); + await authorPage.locator( '//form[contains(@class,"wpuf-form")]//input[contains(@class,"wpuf-submit-button")]' ).click(); + await expect( authorPage.locator( '//form[contains(@class,"wpuf-form")]' ) ).toBeHidden( { timeout: 60000 } ); + + const href = await editor.getDashboardEditHref( authorPage, dashboardSlug, title ); + expect( href, 'the dashboard produced no Edit link — check Settings → Frontend Posting → Edit Page' ).toContain( 'pid=' ); + await editor.validateEditFormOpens( authorPage, href ); + } ); + + const postId = getPostIdByTitle( title ); + expect( postId ).toBeGreaterThan( 0 ); + expect( Number( getPostMeta( postId, '_wpuf_lock_user_editing_post_time' ) ) ).toBeGreaterThan( Date.now() / 1000 ); + + lockPostTitle = title; + } ); + + test( 'PFG0056 : After the edit window the edit form is refused', { tag: [ '@Lite', '@Test_PFG0056' ] }, async () => { + const editor = new PostFormGapsPage( page ); + const title = lockPostTitle; + const postId = getPostIdByTitle( title ); + + // Move the stored lock timestamp into the past — same effect as waiting + // out the configured hours. + setPostMeta( postId, '_wpuf_lock_user_editing_post_time', String( Math.floor( Date.now() / 1000 ) - 60 ) ); + + await editor.withUser( authorLogin, authorPass, async ( authorPage ) => { + const href = await editor.getDashboardEditHref( authorPage, dashboardSlug, title ); + await editor.validateEditRefused( authorPage, href, /allocated time for editing this post has been expired/i ); + } ); + + // Leave the shared form without an edit lock. + await editor.openFormEditor( formId ); + await editor.doOpenSettingsSection( 'General' ); + await editor.doFillSetting( 'lock_edit_post', '0' ); + await editor.doSaveForm(); + } ); +} ); diff --git a/tests/e2e/tests/postFormFieldBehaviourTest.spec.ts b/tests/e2e/tests/postFormFieldBehaviourTest.spec.ts new file mode 100644 index 000000000..9a85e7af0 --- /dev/null +++ b/tests/e2e/tests/postFormFieldBehaviourTest.spec.ts @@ -0,0 +1,352 @@ +import { Browser, BrowserContext, Page, test, expect, chromium } from '@playwright/test'; +import { BasicLoginPage } from '../pages/basicLogin'; +import { PostFormPage } from '../pages/postForm'; +import { PostFormGapsPage } from '../pages/postFormGaps'; +import { Users, PostForm } from '../utils/testData'; +import { faker } from '@faker-js/faker'; +import { configureSpecFailFast } from '../utils/specFailFast'; +import { waitForSiteReady } from '../utils/siteReady'; +import { wpCliAvailable, wpCli, getPostIdByTitle, installHookProbe, removeHookProbe } from '../utils/wpEnvCli'; + +let browser: Browser; +let context: BrowserContext; +let page: Page; + +test.beforeAll( async () => { + browser = await chromium.launch(); + context = await browser.newContext(); + page = await context.newPage(); + + page.on( 'dialog', async ( dialog ) => { + await dialog.accept().catch( () => {} ); + } ); +} ); + +test.describe( 'Post-Form-Field-Behaviour', () => { + configureSpecFailFast(); + + /**------------------ STRUCTURAL / VALUE FIELDS (§8.8) ------------------** + * + * @TestScenario : [Fields whose behaviour, not just their stage row, matters] + * + * The existing allFieldTypes spec proves every field can be added and + * rendered. These tests prove what each of them actually *does*. + * + * @Test_PFG0070 : Section Break prints its title and description + * @Test_PFG0071 : Custom HTML output reaches the rendered form + * @Test_PFG0072 : A Shortcode field is executed, not printed + * @Test_PFG0073 : An Action Hook field fires its hook (probe env-gated) + * @Test_PFG0074 : Columns render as a multi-column row (@Pro) + * @Test_PFG0075 : A required Terms & Conditions blocks submit (@Pro) + * @Test_PFG0076 : A Ratings value is stored on the post (@Pro) + * @Test_PFG0077 : An Embed value is stored on the post (@Pro) + * @Test_PFG0078 : Hidden Field writes its value to post meta + * @Test_PFG0079 : File Upload rejects a file over the size limit (@Pro) + * @Test_PFG0080 : Checkbox / Radio / Multi Select values round-trip + * @Test_PFG0081 : Repeat Field renders on the frontend (known bug) + * + */ + + const formName = `FB Form ${ faker.string.alphanumeric( 5 ) }`; + const pageSlug = `fb-form-${ faker.string.alphanumeric( 5 ) }`.toLowerCase(); + const sectionTitle = `Section ${ faker.string.alphanumeric( 5 ) }`; + const sectionDescription = `Describes ${ faker.string.alphanumeric( 5 ) }`; + const htmlMarker = `QA_HTML_${ faker.string.alphanumeric( 5 ) }`; + const hiddenKey = `qa_hidden_${ faker.string.alphanumeric( 4 ) }`.toLowerCase(); + const hiddenValue = `hidden-${ faker.string.alphanumeric( 6 ) }`; + + let formId: string; + + test( 'PFG0070 : Section Break prints its title and description', { tag: [ '@Lite', '@Test_PFG0070' ] }, async () => { + await waitForSiteReady( page, 15000 ); + await new BasicLoginPage( page ).basicLoginAndPluginVisit( Users.adminUsername, Users.adminPassword ); + + const postForm = new PostFormPage( page ); + await postForm.createBlankFormPostForm( formName ); + + const editor = new PostFormGapsPage( page ); + formId = await editor.getFormId(); + await editor.doAddField( 'Post Title' ); + await editor.doAddField( 'Post Content' ); + await editor.doAddField( 'Section Break' ); + await editor.doEditField( 'section_break' ); + await editor.doSetFieldOptionInput( 'Title', sectionTitle ); + await editor.doSetFieldOptionTextarea( 'Description', sectionDescription ); + await editor.doSaveForm(); + + await postForm.createPageWithShortcode( `[wpuf_form id="${ formId }"]`, pageSlug ); + await editor.openFrontendForm( pageSlug ); + await editor.validateFrontendText( sectionTitle ); + await editor.validateFrontendText( sectionDescription ); + } ); + + test( 'PFG0071 : Custom HTML output reaches the rendered form', { tag: [ '@Lite', '@Test_PFG0071' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doAddField( 'Custom HTML' ); + await editor.doEditField( 'custom_html' ); + await editor.doSetFieldOptionTextarea( 'Html Codes', `
    ${ htmlMarker }
    ` ); + await editor.doSaveForm(); + + await editor.openFrontendForm( pageSlug ); + await expect( page.locator( '#qa-html-probe' ) ).toHaveText( htmlMarker ); + } ); + + test( 'PFG0072 : A Shortcode field is executed, not printed', { tag: [ '@Lite', '@Test_PFG0072' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + await editor.doAddField( 'Shortcode' ); + await editor.doEditField( 'shortcode' ); + // [wpuf-login] is always registered, so it needs no test-only plugin. + await editor.doSetFieldOptionInput( 'Shortcode', '[wpuf-login]' ); + await editor.doSaveForm(); + + await editor.openFrontendForm( pageSlug ); + // Executed → its own markup/notice shows and the raw tag never does. + await editor.validateProbeRendered( 'logged in', '[wpuf-login]' ); + } ); + + test( 'PFG0073 : An Action Hook field fires its hook', { tag: [ '@Lite', '@Test_PFG0073' ] }, async () => { + const editor = new PostFormGapsPage( page ); + // A hook only proves itself when something is listening, so the test drops + // its own mu-plugin listener instead of depending on the environment. + const hookName = `wpuf_qa_hook_${ faker.string.alphanumeric( 5 ) }`.toLowerCase(); + const marker = `QA-HOOK-${ faker.string.alphanumeric( 8 ) }`; + const probed = installHookProbe( hookName, marker ); + + try { + await editor.openFormEditor( formId ); + await editor.doAddField( 'Action Hook' ); + await editor.doEditField( 'action_hook' ); + await editor.doSetFieldOptionInput( 'Hook Name', hookName ); + await editor.doSaveForm(); + + await editor.openFrontendForm( pageSlug ); + await expect( page.locator( 'body' ) ).not.toContainText( hookName ); + + if ( probed ) { + await editor.validateProbeRendered( marker ); + } else { + // No wp-env CLI (e.g. a QA_BASE_URL run against a remote site), so + // only the "hook name is not leaked to visitors" half is assertable. + console.log( 'no wp-cli available — skipped the hook-fires assertion' ); + } + } finally { + removeHookProbe(); + } + } ); + + test( 'PFG0074 : Columns render as a multi-column row', { tag: [ '@Pro', '@Test_PFG0074' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + test.skip( ! await editor.isFieldAvailable( 'Columns' ), 'Columns requires wpuf-pro' ); + + await editor.doAddField( 'Columns' ); + await editor.validateFieldOnStage( 'column_field' ); + await editor.doSaveForm(); + + await editor.openFrontendForm( pageSlug ); + // The template wraps each column in its own container. + expect( + await editor.countFrontendMatches( '//form[contains(@class,"wpuf-form")]//*[contains(@class,"wpuf-column")]' ) + ).toBeGreaterThan( 1 ); + } ); + + test( 'PFG0075 : A required Terms & Conditions blocks submit', { tag: [ '@Pro', '@Test_PFG0075' ] }, async () => { + const editor = new PostFormGapsPage( page ); + await editor.openFormEditor( formId ); + test.skip( ! await editor.isFieldAvailable( 'Terms & Conditions' ), 'Terms & Conditions requires wpuf-pro' ); + + await editor.doAddField( 'Terms & Conditions' ); + await editor.doSaveForm(); + + await editor.openFrontendForm( pageSlug ); + const tocTitle = `TOC ${ faker.string.alphanumeric( 5 ) }`; + await page.locator( '//form[contains(@class,"wpuf-form")]//input[@name="post_title"]' ).fill( tocTitle ); + await editor.doFillPostContent( faker.lorem.paragraph() ); + + // Field_Toc renders a native `required` checkbox, so the browser blocks + // the submit itself — there is no WPUF-rendered error string to read. + const consent = page.locator( '//form[contains(@class,"wpuf-form")]//input[@type="checkbox"][@required]' ).first(); + await expect( consent, 'the Terms & Conditions checkbox is not required' ).toHaveCount( 1 ); + expect( await consent.evaluate( ( el: HTMLInputElement ) => el.checkValidity() ) ).toBeFalsy(); + + await editor.doSubmitFrontendForm(); + + // Nothing was submitted: the form is still on screen and no post exists. + await expect( page.locator( '//form[contains(@class,"wpuf-form")]' ) ).toBeVisible(); + + if ( wpCliAvailable() ) { + expect( getPostIdByTitle( tocTitle ) ).toBe( 0 ); + } + + // Accepting it clears the block. + await consent.check(); + expect( await consent.evaluate( ( el: HTMLInputElement ) => el.checkValidity() ) ).toBeTruthy(); + } ); + + test( 'PFG0076 : A Ratings value is stored on the post', { tag: [ '@Pro', '@Test_PFG0076' ] }, async () => { + const editor = new PostFormGapsPage( page ); + test.skip( ! wpCliAvailable(), 'Reading the stored rating needs wp-cli' ); + + await editor.openFormEditor( formId ); + test.skip( ! await editor.isFieldAvailable( 'Ratings' ), 'Ratings requires wpuf-pro' ); + + // Terms & Conditions from the previous test would block every submit below. + await editor.doRemoveField( 'toc' ).catch( () => {} ); + await editor.doAddField( 'Ratings' ); + await editor.doEditField( 'ratings' ); + const ratingKey = await page.locator( '(//label[normalize-space()="Meta Key"]/following::input)[1]' ).inputValue(); + await editor.doSaveForm(); + + await editor.openFrontendForm( pageSlug ); + const title = `RATING ${ faker.string.alphanumeric( 5 ) }`; + await page.locator( '//form[contains(@class,"wpuf-form")]//input[@name="post_title"]' ).fill( title ); + await editor.doFillPostContent( faker.lorem.paragraph() ); + + // Field_Rating renders a