From 2228837c038d408546eefdae2432bd4ae307a736 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 4 Jun 2026 09:17:52 +1000 Subject: [PATCH] test+ci: PHPUnit suite refactor + CI overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end refactor of the PHPUnit suite and CI workflow. Headline outcomes: Test architecture - Mirror src/ 1:1 with Test_*.php naming; 32 root-level test-*.php files relocated to per-class files under tests/phpunit/integration/. - Phase 4 characterization coverage for Controllers, Models, Helpers (Fields/Fonts), and Views; final coverage 81.57% (gate 81.45%). - New AjaxTestCase parallel to WP_Ajax_UnitTestCase; shared HasGfpdfFixtures trait exposes form()/entry()/entries() accessors to both base classes. - Skipped trivial exception classes and pure template wrappers per the documented "test if non-trivial" rule. - tests/phpunit/README.md documents the suite layout, fixtures, and the available fixture keys. Fixtures-to-factory migration (follow-up epic) - Removed the suite-scoped $GLOBALS['GFPDF_Test'] global across 62 test files; bootstrap's create_stubs() deleted along with the orphan gravityform-2.json fixture. - Replaced with per-class static::load_fixtures() declarations populating a protected $fixture_caches map on HasGfpdfFixtures. - Two factory import paths preserved: import_and_get (array-wrapped GF-export JSON) and import_fixture_and_get (raw-object JSON used by load_fixtures). - CI grep gate in .github/workflows/tests.yml blocks regression to \$GLOBALS['GFPDF_Test'], GFAPI::add_form, GFAPI::add_entry in test files. - Suite is 1503 tests / ~4750 assertions / 14 skipped — ~40s single-site, ~38s multisite. Runtime improved vs the +10% budget because bootstrap no longer eagerly creates 7 forms + 5 entry sets. Suite speed + stability - Killed the sleep(1) flake in Test_Url_Signer; lifted gf_factory writes to class scope. - Blocked external HTTP and stripped WP update probes from AJAX setup. - Unique zip names per Test_Templates stage to kill cross-test flake. - Closed leak vectors and shaved wall-clock across the suite; post-Phase-4 flake budget is zero. - Dropped vendored Polls/Quiz/Survey plugins, replaced with a minimal class-stub surface for PHPUnit. CI infrastructure - Consolidated PHPUnit + Playwright + JS test workflows into a single tests.yml; extended deps update workflow. - PR coverage now renders inline via lucassabreu/comment-coverage-clover (Codecov + token removed); coverage-merge.php unions single-site + multisite Clover before the comment step. - tools/phpunit/retry.sh re-runs only the failing Class::method names parsed from JUnit XML once on flake; retries surface as ::warning::retry: annotations. --coverage-clover stripped from retry args so the full-suite Clover isn't overwritten by a single-test subset. - wp-env Docker base images (wordpress:php, wordpress:cli-php, mariadb:lts) cached via docker save/load, removing ~60-90s per matrix cell / Playwright shard. docker/setup-buildx-action@v3 dropped — wp-env never used it. - PHP floor wired to 7.4 in composer.json to match the Gravity Forms requirement. - setup-php now matches \${{ matrix.php }} instead of hardcoded 7.4. - Docker Hub pulls routed through Google's mirror to dodge rate limits. - run-tests-php-all PR label opts into the full PHP-version matrix on demand (cron still runs it weekly); multisite runs on declarative floor (7.4) + ceiling (8.5) cells rather than the 8.3 coverage cell. Known follow-ups (separate epics) - Phase 6 Model_PDF refactor. - 6 pre-existing order-coupled failures surface under --order-by=random (Test_Templates filesystem state, Test_Uninstaller/Test_Actions notices, Test_Rest_Pdf_Preview) — hidden by deterministic discovery order in the legacy suite; not introduced by this work. Plans: .claude/plans/2026-05-25-phpunit-tests-refactor.md, .claude/plans/2026-05-26-fixtures-to-factory-migration.md. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/CLAUDE.md | 121 +- .../2026-05-28-pr-description-format.md | 32 + .../memory/2026-05-28-pr-link-fixed-issues.md | 16 + .claude/memory/MEMORY.md | 4 + .github/README.md | 6 +- .github/workflows/javascript-tests.yml | 42 - .github/workflows/phpunit.tests.yml | 139 - .github/workflows/playwright-e2e.yml | 231 -- .github/workflows/tests.yml | 581 ++++ .github/workflows/update-dependencies.yml | 125 + .github/workflows/wp-core-version-update.yml | 64 - composer.json | 18 +- composer.lock | 222 +- pdf.php | 4 +- src/View/View_Form_Settings.php | 38 +- tests/phpunit/Concerns/HasGfpdfFixtures.php | 238 ++ tests/phpunit/Concerns/UsesFactory.php | 25 + tests/phpunit/README.md | 230 ++ tests/phpunit/integration/AjaxTestCase.php | 37 + .../Controller/Test_Controller_Actions.php | 119 + .../Controller/Test_Controller_Activation.php | 79 + .../Test_Controller_Custom_Fonts.php | 86 +- .../Controller/Test_Controller_Debug.php | 96 + .../Test_Controller_Export_Entries.php | 24 +- .../Test_Controller_Form_Settings.php | 158 + .../Controller/Test_Controller_Install.php | 102 + .../Controller/Test_Controller_Mergetags.php | 52 + .../Controller/Test_Controller_PDF.php | 262 ++ .../Controller/Test_Controller_Pdf_Queue.php | 93 +- .../Test_Controller_Save_Core_Fonts.php | 64 + .../Controller/Test_Controller_Settings.php | 8 +- .../Controller/Test_Controller_Shortcodes.php | 56 + .../Test_Controller_System_Report.php | 10 +- .../Controller/Test_Controller_Templates.php | 40 + .../Test_Controller_Uninstaller.php | 82 + .../Test_Controller_Upgrade_Routines.php | 115 + .../Controller/Test_Controller_Webhooks.php | 15 +- .../Controller/Test_Controller_Zapier.php | 17 +- .../Exceptions/Test_Exception_Hierarchy.php | 65 + .../Helper/Fields/Test_Field_Address.php | 101 + .../Fields/Test_Field_Chainedselect.php | 75 + .../Helper/Fields/Test_Field_Checkbox.php | 98 + .../Helper/Fields/Test_Field_Consent.php | 22 +- .../Helper/Fields/Test_Field_Coupon.php | 80 + .../Helper/Fields/Test_Field_Creditcard.php | 84 + .../Helper/Fields/Test_Field_Date.php | 67 + .../Helper/Fields/Test_Field_Default.php | 50 + .../Helper/Fields/Test_Field_Discount.php | 74 + .../Helper/Fields/Test_Field_Email.php | 56 + .../Fields/Test_Field_Fg_Ls_Consent.php | 70 + .../Fields/Test_Field_Fg_Ls_Signature.php | 74 + .../Helper/Fields/Test_Field_Fileupload.php | 80 + .../Helper/Fields/Test_Field_Form.php | 69 + .../Helper/Fields/Test_Field_Hidden.php | 55 + .../Helper/Fields/Test_Field_Html.php | 57 + .../Helper/Fields/Test_Field_Image_Choice.php | 102 +- .../Helper/Fields/Test_Field_Likert.php | 90 + .../Helper/Fields/Test_Field_List.php | 16 +- .../Helper/Fields/Test_Field_Markup.php} | 50 +- .../Helper/Fields/Test_Field_Multi_Choice.php | 94 +- .../Helper/Fields/Test_Field_Multiselect.php | 96 + .../Helper/Fields/Test_Field_Name.php | 76 + .../Helper/Fields/Test_Field_Number.php | 68 + .../Helper/Fields/Test_Field_Option.php | 21 +- .../Helper/Fields/Test_Field_Page.php | 56 + .../Helper/Fields/Test_Field_Phone.php | 50 + .../Helper/Fields/Test_Field_Poll.php | 24 +- .../Fields/Test_Field_Post_Category.php | 24 +- .../Helper/Fields/Test_Field_Post_Content.php | 43 + .../Fields/Test_Field_Post_Custom_Field.php | 24 +- .../Helper/Fields/Test_Field_Post_Excerpt.php | 49 + .../Helper/Fields/Test_Field_Post_Image.php | 27 +- .../Helper/Fields/Test_Field_Post_Tags.php | 53 + .../Helper/Fields/Test_Field_Post_Title.php | 49 + .../Helper/Fields/Test_Field_Product.php | 30 +- .../Helper/Fields/Test_Field_Products.php | 23 +- .../Helper/Fields/Test_Field_Quantity.php | 103 + .../Helper/Fields/Test_Field_Quiz.php | 67 + .../Helper/Fields/Test_Field_Radio.php | 22 +- .../Helper/Fields/Test_Field_Rank.php | 62 + .../Helper/Fields/Test_Field_Rating.php | 61 + .../Helper/Fields/Test_Field_Repeater.php | 16 +- .../Helper/Fields/Test_Field_Section.php | 21 +- .../Helper/Fields/Test_Field_Select.php | 22 +- .../Helper/Fields/Test_Field_Shipping.php | 86 + .../Helper/Fields/Test_Field_Signature.php | 27 +- .../Helper/Fields/Test_Field_Slim.php | 52 + .../Helper/Fields/Test_Field_Slim_Post.php | 70 + .../Helper/Fields/Test_Field_Subtotal.php | 56 + .../Helper/Fields/Test_Field_Tax.php | 82 + .../Helper/Fields/Test_Field_Text.php | 47 + .../Helper/Fields/Test_Field_Textarea.php | 4 +- .../Helper/Fields/Test_Field_Time.php | 47 + .../Helper/Fields/Test_Field_Tos.php | 58 + .../Helper/Fields/Test_Field_Total.php | 87 + .../Helper/Fields/Test_Field_V3_List.php | 63 + .../Helper/Fields/Test_Field_V3_Products.php | 45 + .../Helper/Fields/Test_Field_V3_Section.php | 60 + .../Helper/Fields/Test_Field_Website.php | 61 + .../Helper/Fonts/Test_FlushCache.php | 6 +- .../Helper/Fonts/Test_LocalFile.php | 115 + .../Helper/Fonts/Test_LocalFilesystem.php | 67 + .../Helper/Fonts/Test_SupportsOtl.php | 44 + .../Helper/Fonts/Test_TtfFontValidation.php | 57 + .../Licensing/Test_EDD_SL_Plugin_Updater.php | 325 +- .../integration/Helper/Log/Test_Logger.php | 131 + .../Helper/Log/Test_MonoLoggerPsrLog2And3.php | 96 + .../Helper/Log/Test_Redact_Processor.php | 42 +- .../Helper/Mpdf/Test_Cache.php | 6 +- .../integration/Helper/Mpdf/Test_Mpdf.php | 131 + .../Helper/Mpdf/Test_Request.php | 38 +- .../phpunit/integration/Helper/Test_Addon.php | 853 +++++ .../Helper/Test_Field_Container.php} | 69 +- .../integration/Helper/Test_Form_Data.php | 1692 ++++++++++ .../Helper/Test_Gravity_Forms.php} | 178 +- .../Helper/Test_Helper_Data.php} | 22 +- .../Test_Helper_Field_Container_Gf25.php | 10 +- .../integration/Helper/Test_Helper_Form.php | 118 + .../Helper/Test_Helper_Misc_Colors.php | 86 + .../Helper/Test_Helper_Misc_Config.php | 105 + .../Helper/Test_Helper_Misc_Forms.php | 334 ++ .../Helper/Test_Helper_Misc_Pages.php | 122 + .../integration/Helper/Test_Helper_PDF.php | 179 ++ .../Helper/Test_Helper_PDF_List_Table.php | 94 + .../Helper/Test_Helper_Pdf_Queue.php | 115 + .../Helper/Test_Helper_Sha256_Url_Signer.php | 84 + .../Helper/Test_Helper_Templates.php} | 118 +- .../Helper/Test_MVC_Abstracts.php} | 44 +- .../Helper/Test_Notices.php} | 11 +- .../Helper/Test_Options_API.php} | 238 +- .../Helper/Test_QueryPath.php} | 11 +- .../Helper/Test_Settings.php} | 66 +- .../Helper/Test_Singleton.php} | 21 +- .../Helper/Test_Url_Signer.php} | 18 +- .../Model/Test_Actions.php} | 32 +- .../Model/Test_Form_Settings.php} | 103 +- .../Model/Test_Installer.php} | 57 +- .../Model/Test_Model_Custom_Fonts.php | 8 +- .../Model/Test_Model_Custom_Fonts_Ajax.php | 58 + .../Model/Test_Model_Form_Settings_Ajax.php | 225 ++ .../Model/Test_Model_Mergetags.php | 55 +- .../integration/Model/Test_Model_Pdf.php | 340 ++ .../Model/Test_Model_Pdf_Meta_Box.php | 33 +- .../Model/Test_Model_Settings.php | 76 +- .../Model/Test_Model_Settings_Ajax.php | 35 + .../Model/Test_Model_System_Report.php | 8 +- .../Model/Test_Model_Templates_Ajax.php | 93 + .../Model/Test_PDF.php} | 672 ++-- .../Model/Test_Shortcodes.php} | 116 +- .../Model/Test_Templates.php} | 180 +- .../Model/Test_Uninstaller.php} | 83 +- tests/phpunit/integration/Rest/Test_Rest.php | 102 + .../Rest/Test_Rest_Form_Settings.php | 63 +- .../Rest/Test_Rest_Pdf_Preview.php | 42 +- .../Statics/Test_Cache.php | 30 +- .../integration/Statics/Test_Debug.php | 49 + .../Statics/Test_Kses.php} | 5 +- .../Statics/Test_Queue_Callbacks.php | 81 + tests/phpunit/integration/TestCase.php | 35 + .../test-api.php => integration/Test_Api.php} | 158 +- .../Test_Autoloader.php} | 12 +- .../Test_Bootstrap.php} | 119 +- tests/phpunit/integration/Test_Deprecated.php | 197 ++ .../integration/Test_Fixtures_Loader.php | 57 + .../Test_Pre_Checks.php} | 53 +- .../integration/View/Test_View_Actions.php | 49 + .../View/Test_View_Form_Settings.php | 88 + .../Test_View_GravityForm_Settings_Markup.php | 120 + .../integration/View/Test_View_PDF.php | 171 + .../integration/View/Test_View_Settings.php | 160 + .../integration/View/Test_View_Shortcodes.php | 83 + .../View/Test_View_System_Report.php | 6 +- .../View/Test_View_Uninstaller.php | 33 + .../Test_Controller_Upgrade_Routines.php | 50 - .../Helper/Fields/Test_Field_Form.php | 73 - .../Helper/Fields/Test_Field_Survey.php | 59 - .../unit-tests/Model/Test_Model_Pdf.php | 177 -- tests/phpunit/unit-tests/Rest/Test_Rest.php | 86 - tests/phpunit/unit-tests/test-addon.php | 532 ---- tests/phpunit/unit-tests/test-ajax.php | 552 ---- tests/phpunit/unit-tests/test-deprecated.php | 141 - tests/phpunit/unit-tests/test-form-data.php | 1656 ---------- tests/phpunit/unit-tests/test-helper-misc.php | 822 ----- tests/phpunit/unit-tests/test-helper-mpdf.php | 63 - tests/phpunit/unit-tests/test-interfaces.php | 45 - tests/phpunit/unit-tests/test-logger.php | 111 - .../unit-tests/test-slow-pdf-processes.php | 533 ---- tools/phpcs/config-php-compatibility.xml | 4 +- .../Mocks/gf-chained-field-select-mock.php | 9 + .../Mocks/gp-field-nested-form-mock.php | 9 + tools/phpunit/bootstrap.php | 100 +- tools/phpunit/config-multisite.xml | 2 +- tools/phpunit/config.xml | 2 +- tools/phpunit/coverage-baseline.php | 114 + tools/phpunit/coverage-merge-lib.php | 63 + tools/phpunit/coverage-merge.php | 85 + tools/phpunit/data/forms/gravityform-2.json | 2757 ----------------- tools/phpunit/gravityforms-factory.php | 40 +- tools/phpunit/retry.sh | 59 + tools/phpunit/stubs/gravity-forms-addons.php | 40 + tools/phpunit/wp-tests-config.php | 1 + tools/wp-env/development.json | 2 +- tools/wp-env/e2e.json | 8 +- tools/wp-env/integration.json | 5 +- 204 files changed, 14956 insertions(+), 9889 deletions(-) create mode 100644 .claude/memory/2026-05-28-pr-description-format.md create mode 100644 .claude/memory/2026-05-28-pr-link-fixed-issues.md create mode 100644 .claude/memory/MEMORY.md delete mode 100644 .github/workflows/javascript-tests.yml delete mode 100644 .github/workflows/phpunit.tests.yml delete mode 100644 .github/workflows/playwright-e2e.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .github/workflows/update-dependencies.yml delete mode 100644 .github/workflows/wp-core-version-update.yml create mode 100644 tests/phpunit/Concerns/HasGfpdfFixtures.php create mode 100644 tests/phpunit/Concerns/UsesFactory.php create mode 100644 tests/phpunit/README.md create mode 100644 tests/phpunit/integration/AjaxTestCase.php create mode 100644 tests/phpunit/integration/Controller/Test_Controller_Actions.php create mode 100644 tests/phpunit/integration/Controller/Test_Controller_Activation.php rename tests/phpunit/{unit-tests => integration}/Controller/Test_Controller_Custom_Fonts.php (91%) create mode 100644 tests/phpunit/integration/Controller/Test_Controller_Debug.php rename tests/phpunit/{unit-tests => integration}/Controller/Test_Controller_Export_Entries.php (72%) create mode 100644 tests/phpunit/integration/Controller/Test_Controller_Form_Settings.php create mode 100644 tests/phpunit/integration/Controller/Test_Controller_Install.php create mode 100644 tests/phpunit/integration/Controller/Test_Controller_Mergetags.php create mode 100644 tests/phpunit/integration/Controller/Test_Controller_PDF.php rename tests/phpunit/{unit-tests => integration}/Controller/Test_Controller_Pdf_Queue.php (77%) create mode 100644 tests/phpunit/integration/Controller/Test_Controller_Save_Core_Fonts.php rename tests/phpunit/{unit-tests => integration}/Controller/Test_Controller_Settings.php (91%) create mode 100644 tests/phpunit/integration/Controller/Test_Controller_Shortcodes.php rename tests/phpunit/{unit-tests => integration}/Controller/Test_Controller_System_Report.php (94%) create mode 100644 tests/phpunit/integration/Controller/Test_Controller_Templates.php create mode 100644 tests/phpunit/integration/Controller/Test_Controller_Uninstaller.php create mode 100644 tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php rename tests/phpunit/{unit-tests => integration}/Controller/Test_Controller_Webhooks.php (79%) rename tests/phpunit/{unit-tests => integration}/Controller/Test_Controller_Zapier.php (88%) create mode 100644 tests/phpunit/integration/Exceptions/Test_Exception_Hierarchy.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Address.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Chainedselect.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Checkbox.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Consent.php (84%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Coupon.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Creditcard.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Date.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Default.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Discount.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Email.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Fg_Ls_Consent.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Fg_Ls_Signature.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Fileupload.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Form.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Hidden.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Html.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Image_Choice.php (80%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Likert.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_List.php (77%) rename tests/phpunit/{unit-tests/test-field-markup.php => integration/Helper/Fields/Test_Field_Markup.php} (56%) rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Multi_Choice.php (78%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Multiselect.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Name.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Number.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Option.php (56%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Page.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Phone.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Poll.php (66%) rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Post_Category.php (67%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Post_Content.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Post_Custom_Field.php (66%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Post_Excerpt.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Post_Image.php (84%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Post_Tags.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Post_Title.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Product.php (78%) rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Products.php (81%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Quantity.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Quiz.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Radio.php (78%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Rank.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Rating.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Repeater.php (89%) rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Section.php (75%) rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Select.php (86%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Shipping.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Signature.php (83%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Slim.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Slim_Post.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Subtotal.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Tax.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Text.php rename tests/phpunit/{unit-tests => integration}/Helper/Fields/Test_Field_Textarea.php (93%) create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Time.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Tos.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Total.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_V3_List.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_V3_Products.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_V3_Section.php create mode 100644 tests/phpunit/integration/Helper/Fields/Test_Field_Website.php rename tests/phpunit/{unit-tests => integration}/Helper/Fonts/Test_FlushCache.php (82%) create mode 100644 tests/phpunit/integration/Helper/Fonts/Test_LocalFile.php create mode 100644 tests/phpunit/integration/Helper/Fonts/Test_LocalFilesystem.php create mode 100644 tests/phpunit/integration/Helper/Fonts/Test_SupportsOtl.php create mode 100644 tests/phpunit/integration/Helper/Fonts/Test_TtfFontValidation.php rename tests/phpunit/{unit-tests => integration}/Helper/Licensing/Test_EDD_SL_Plugin_Updater.php (70%) create mode 100644 tests/phpunit/integration/Helper/Log/Test_Logger.php create mode 100644 tests/phpunit/integration/Helper/Log/Test_MonoLoggerPsrLog2And3.php rename tests/phpunit/{unit-tests => integration}/Helper/Log/Test_Redact_Processor.php (87%) rename tests/phpunit/{unit-tests => integration}/Helper/Mpdf/Test_Cache.php (89%) create mode 100644 tests/phpunit/integration/Helper/Mpdf/Test_Mpdf.php rename tests/phpunit/{unit-tests => integration}/Helper/Mpdf/Test_Request.php (65%) create mode 100644 tests/phpunit/integration/Helper/Test_Addon.php rename tests/phpunit/{unit-tests/test-field-container.php => integration/Helper/Test_Field_Container.php} (74%) create mode 100644 tests/phpunit/integration/Helper/Test_Form_Data.php rename tests/phpunit/{unit-tests/test-gravity-forms.php => integration/Helper/Test_Gravity_Forms.php} (67%) rename tests/phpunit/{unit-tests/test-helper-data.php => integration/Helper/Test_Helper_Data.php} (88%) rename tests/phpunit/{unit-tests => integration}/Helper/Test_Helper_Field_Container_Gf25.php (94%) create mode 100644 tests/phpunit/integration/Helper/Test_Helper_Form.php create mode 100644 tests/phpunit/integration/Helper/Test_Helper_Misc_Colors.php create mode 100644 tests/phpunit/integration/Helper/Test_Helper_Misc_Config.php create mode 100644 tests/phpunit/integration/Helper/Test_Helper_Misc_Forms.php create mode 100644 tests/phpunit/integration/Helper/Test_Helper_Misc_Pages.php create mode 100644 tests/phpunit/integration/Helper/Test_Helper_PDF.php create mode 100644 tests/phpunit/integration/Helper/Test_Helper_PDF_List_Table.php create mode 100644 tests/phpunit/integration/Helper/Test_Helper_Pdf_Queue.php create mode 100644 tests/phpunit/integration/Helper/Test_Helper_Sha256_Url_Signer.php rename tests/phpunit/{unit-tests/test-helper-templates.php => integration/Helper/Test_Helper_Templates.php} (78%) rename tests/phpunit/{unit-tests/test-mvc-abstracts.php => integration/Helper/Test_MVC_Abstracts.php} (60%) rename tests/phpunit/{unit-tests/test-notices.php => integration/Helper/Test_Notices.php} (93%) rename tests/phpunit/{unit-tests/test-options-api.php => integration/Helper/Test_Options_API.php} (78%) rename tests/phpunit/{unit-tests/test-query-path.php => integration/Helper/Test_QueryPath.php} (72%) rename tests/phpunit/{unit-tests/test-settings.php => integration/Helper/Test_Settings.php} (87%) rename tests/phpunit/{unit-tests/test-singleton.php => integration/Helper/Test_Singleton.php} (85%) rename tests/phpunit/{unit-tests/test-url-signer.php => integration/Helper/Test_Url_Signer.php} (91%) rename tests/phpunit/{unit-tests/test-actions.php => integration/Model/Test_Actions.php} (87%) rename tests/phpunit/{unit-tests/test-form-settings.php => integration/Model/Test_Form_Settings.php} (87%) rename tests/phpunit/{unit-tests/test-installer.php => integration/Model/Test_Installer.php} (71%) rename tests/phpunit/{unit-tests => integration}/Model/Test_Model_Custom_Fonts.php (96%) create mode 100644 tests/phpunit/integration/Model/Test_Model_Custom_Fonts_Ajax.php create mode 100644 tests/phpunit/integration/Model/Test_Model_Form_Settings_Ajax.php rename tests/phpunit/{unit-tests => integration}/Model/Test_Model_Mergetags.php (89%) create mode 100644 tests/phpunit/integration/Model/Test_Model_Pdf.php rename tests/phpunit/{unit-tests => integration}/Model/Test_Model_Pdf_Meta_Box.php (82%) rename tests/phpunit/{unit-tests => integration}/Model/Test_Model_Settings.php (72%) create mode 100644 tests/phpunit/integration/Model/Test_Model_Settings_Ajax.php rename tests/phpunit/{unit-tests => integration}/Model/Test_Model_System_Report.php (94%) create mode 100644 tests/phpunit/integration/Model/Test_Model_Templates_Ajax.php rename tests/phpunit/{unit-tests/test-pdf.php => integration/Model/Test_PDF.php} (70%) rename tests/phpunit/{unit-tests/test-shortcodes.php => integration/Model/Test_Shortcodes.php} (77%) rename tests/phpunit/{unit-tests/test-templates.php => integration/Model/Test_Templates.php} (56%) rename tests/phpunit/{unit-tests/test-uninstaller.php => integration/Model/Test_Uninstaller.php} (61%) create mode 100644 tests/phpunit/integration/Rest/Test_Rest.php rename tests/phpunit/{unit-tests => integration}/Rest/Test_Rest_Form_Settings.php (95%) rename tests/phpunit/{unit-tests => integration}/Rest/Test_Rest_Pdf_Preview.php (74%) rename tests/phpunit/{unit-tests => integration}/Statics/Test_Cache.php (69%) create mode 100644 tests/phpunit/integration/Statics/Test_Debug.php rename tests/phpunit/{unit-tests/Statics/Test_kses.php => integration/Statics/Test_Kses.php} (98%) create mode 100644 tests/phpunit/integration/Statics/Test_Queue_Callbacks.php create mode 100644 tests/phpunit/integration/TestCase.php rename tests/phpunit/{unit-tests/test-api.php => integration/Test_Api.php} (56%) rename tests/phpunit/{unit-tests/test-autoloader.php => integration/Test_Autoloader.php} (92%) rename tests/phpunit/{unit-tests/test-bootstrap.php => integration/Test_Bootstrap.php} (55%) create mode 100644 tests/phpunit/integration/Test_Deprecated.php create mode 100644 tests/phpunit/integration/Test_Fixtures_Loader.php rename tests/phpunit/{unit-tests/test-pre-checks.php => integration/Test_Pre_Checks.php} (80%) create mode 100644 tests/phpunit/integration/View/Test_View_Actions.php create mode 100644 tests/phpunit/integration/View/Test_View_Form_Settings.php create mode 100644 tests/phpunit/integration/View/Test_View_GravityForm_Settings_Markup.php create mode 100644 tests/phpunit/integration/View/Test_View_PDF.php create mode 100644 tests/phpunit/integration/View/Test_View_Settings.php create mode 100644 tests/phpunit/integration/View/Test_View_Shortcodes.php rename tests/phpunit/{unit-tests => integration}/View/Test_View_System_Report.php (93%) create mode 100644 tests/phpunit/integration/View/Test_View_Uninstaller.php delete mode 100644 tests/phpunit/unit-tests/Controller/Test_Controller_Upgrade_Routines.php delete mode 100644 tests/phpunit/unit-tests/Helper/Fields/Test_Field_Form.php delete mode 100644 tests/phpunit/unit-tests/Helper/Fields/Test_Field_Survey.php delete mode 100644 tests/phpunit/unit-tests/Model/Test_Model_Pdf.php delete mode 100644 tests/phpunit/unit-tests/Rest/Test_Rest.php delete mode 100644 tests/phpunit/unit-tests/test-addon.php delete mode 100644 tests/phpunit/unit-tests/test-ajax.php delete mode 100644 tests/phpunit/unit-tests/test-deprecated.php delete mode 100644 tests/phpunit/unit-tests/test-form-data.php delete mode 100644 tests/phpunit/unit-tests/test-helper-misc.php delete mode 100644 tests/phpunit/unit-tests/test-helper-mpdf.php delete mode 100644 tests/phpunit/unit-tests/test-interfaces.php delete mode 100644 tests/phpunit/unit-tests/test-logger.php delete mode 100644 tests/phpunit/unit-tests/test-slow-pdf-processes.php create mode 100644 tools/phpunit/Mocks/gf-chained-field-select-mock.php create mode 100644 tools/phpunit/Mocks/gp-field-nested-form-mock.php create mode 100644 tools/phpunit/coverage-baseline.php create mode 100644 tools/phpunit/coverage-merge-lib.php create mode 100644 tools/phpunit/coverage-merge.php delete mode 100644 tools/phpunit/data/forms/gravityform-2.json create mode 100755 tools/phpunit/retry.sh create mode 100644 tools/phpunit/stubs/gravity-forms-addons.php diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e99f0d40a..89e2df46a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,94 +1,9 @@ -# CLAUDE.md +## Working agreements -## Core Principles +- Plans → `.claude/plans/YYYY-MM-DD-.md` +- Project memory → `.claude/memory/YYYY-MM-DD-.md` (also indexed in `.claude/memory/MEMORY.md`) -### Skills-First Workflow - -**EVERY user request follows this sequence:** - -Request → Load Skills → Gather Context → Execute - -Skills contain critical workflows and protocols not in base context. -Loading them first prevents missing key instructions. - -### Planning - -When creating plans, always save them to: ./claude/plans/YYYY-MM-DD-.md -Never use the default plan location. Use the ./claude/plans/ directory. - -### Context Management Strategy - -**Central AI should conserve context to extend pre-compaction capacity**: - -- Delegate file explorations and low-lift tasks to sub-agents -- Reserve context for coordination, user communication, and strategic decisions -- For straightforward tasks with clear scope: skip heavy orchestration, execute directly - -**Sub-agents should maximize context collection**: - -- Sub-agent context windows are temporary -- After execution, unused capacity = wasted opportunity -- Instruct sub-agents to read all relevant files, load skills, and gather examples - -### Routing Decision - -**Direct Execution**: - -- Simple/bounded task with clear scope -- Single-component changes -- Quick fixes and trivial modifications - -**Sub-Agent Delegation**: - -- Complex/multi-phase implementations -- Tasks requiring specialized domain expertise -- Work that benefits from isolated context - -**Master Orchestrator**: - -- Ambiguous requirements needing research -- Architectural decisions with wide impact -- Multi-day features requiring session management - -### Operational Protocols - -#### Agent Coordination - -**Parallel** (REQUIRED when applicable): - -- Multiple Task tool invocations in single message -- Independent tasks execute simultaneously -- Bash commands run in parallel - -**Sequential** (ENFORCE for dependencies): - -- Database → API → Frontend -- Research → Planning → Implementation -- Implementation → Testing → Security - -#### Quality Self-Checks - -Before finalizing code, verify: - -- All inputs have validation -- Authentication/authorization checks exist -- All external calls have error handling -- Import paths verified against existing codebase examples - -### Coding Best Practices - -**Priority Order** (when trade-offs arise): -Correctness > Maintainability > Performance > Brevity - -#### Task Complexity Assessment - -Before starting, classify: - -- **Trivial** (single file, obvious fix) → execute directly -- **Moderate** (2-5 files, clear scope) → brief planning then execute -- **Complex** (architectural impact, ambiguous requirements) → full research first - -Match effort to complexity. Don't over-engineer trivial tasks or under-plan complex ones. +@.claude/memory/MEMORY.md ## Project Overview @@ -114,17 +29,17 @@ yarn format # Auto-fix JS/CSS/PHP formatting ### PHP +PHP tests run inside a Docker container via `wp-env` — you cannot run PHPUnit directly. Start the environment first with `yarn wp-env:integration start`. + ```bash -yarn test:php # Run PHPUnit in Docker (`yarn wp-env:integration start` required) +yarn test:php # Run PHPUnit in Docker yarn test:php -- --filter TestClassName # Run single test class yarn test:php -- --filter testMethod # Run single test method yarn test:php:multisite # Run multisite PHPUnit tests -composer lint # PHPCS check -composer lint:fix # PHPCS auto-fix +composer lint # PHPCS check +composer lint:fix # PHPCS auto-fix ``` -PHP tests run inside a Docker container via `wp-env` — you cannot run PHPUnit directly. Start the environment first with `yarn wp-env:integration start`. - ### E2E Tests (Playwright) ```bash @@ -144,11 +59,11 @@ Artifacts (screenshots, traces) are written to `tmp/artifacts/`. ### Environment ```bash -yarn wp-env start # Start dev environment (port 8700) -yarn wp-env:integration start # Start PHP test environment (port 8701) -yarn wp-env:e2e start # Start E2E test environment (port 8702) +yarn wp-env start # Dev environment (port 8700) +yarn wp-env:integration start # PHP test environment (port 8701) +yarn wp-env:e2e start # E2E test environment (port 8702) yarn wp-env stop # Stop the default dev environment -yarn start # Start dev environment + hot reload dev server +yarn start # Dev environment + hot reload dev server ``` ## Architecture @@ -157,10 +72,10 @@ yarn start # Start dev environment + hot reload dev serve The plugin follows an MVC pattern bootstrapped by the `Router` class in `src/bootstrap.php`, which acts as the dependency injection container. Entry point is `pdf.php` → `src/bootstrap.php`. -- **`src/Controller/`** — Request handlers (18+ controllers: forms, PDF generation, settings, fonts, templates, activation, etc.) +- **`src/Controller/`** — Request handlers (forms, PDF generation, settings, fonts, templates, activation, etc.) - **`src/Model/`** — Business logic (PDF rendering via mPDF, settings management, merge tags, templates) - **`src/View/`** — Admin UI rendering; HTML templates live in `src/templates/` -- **`src/Helper/`** — 47+ helpers including abstract base classes for options, fields, forms, logging, and fonts +- **`src/Helper/`** — Abstract base classes for options, fields, forms, logging, and fonts - **`vendor_prefixed/`** — Composer dependencies namespaced via `php-scoper` to avoid conflicts with other plugins Namespacing: all plugin code is under the `GFPDF\` namespace with PSR-4 autoloading. @@ -188,10 +103,10 @@ Legacy jQuery code coexists with the React app; they are separate bundles and do ### Testing - **PHP tests**: `tests/phpunit/` mirrors `src/` structure. Extends `WP_UnitTestCase`. Mock data in `tests/phpunit/unit-tests/Mocks/`. -- **JS tests**: `tests/js-unit/` mirrors React source structure. Uses Jest + Enzyme. Coverage threshold: 75%. -- **E2E tests (Playwright)**: `yarn test:e2e` — config at `tools/playwright/config.ts`. Use `yarn test:e2e:playwright` for the interactive UI mode. +- **JS tests**: `tests/js-unit/` mirrors React source structure. Uses Jest + Enzyme. Coverage threshold: 75% (branches/functions/lines/statements). +- **E2E tests (Playwright)**: `yarn test:e2e` — config at `tools/playwright/config.ts`. Use `yarn test:e2e:debug` for the interactive UI mode. -### Key Constraints +## Key Constraints - PRs must target the `development` branch (not `main`) - Each PR should contain a single commit diff --git a/.claude/memory/2026-05-28-pr-description-format.md b/.claude/memory/2026-05-28-pr-description-format.md new file mode 100644 index 000000000..df618db65 --- /dev/null +++ b/.claude/memory/2026-05-28-pr-description-format.md @@ -0,0 +1,32 @@ +--- +name: PR descriptions stay human-friendly +description: Lead with prose Summary paragraphs (not bulleted Highlights with bold prefixes), "Test it", and a Test plan. Push technical details and the Claude Code attribution into a collapsed `
More info` block. Avoid em dashes and stock LLM filler in the Summary. +type: feedback +--- + +When generating or updating a PR description, the visible body should read like a human wrote it for a human reviewer: + +1. **Summary** as plain prose paragraphs explaining what the PR does and why it matters. Not a bulleted "Highlights:" list with `**Bold Label**` prefixes — that pattern reads as AI-written. No nested matrices, no per-file hook lists. +2. **Try it** — a copy-pasteable code block showing how to exercise the change locally. +3. **Test plan** — a short checklist a reviewer can tick off. + +Everything else — coverage tables, technical hook breakdowns, architecture notes, the `🤖 Generated with AI` attribution line — goes inside a `
` toggle: + +```markdown +
+More info + +...all the AI-dense detail here... + +🤖 Generated with AI + +
+``` + +**Why:** PR reviewers should be able to skim the body and know what's going on without wading through an LLM-style breakdown of every file touched. The detail is useful to *have* (especially when triaging later) but not useful *on the surface*. Hiding it inside `
` keeps both audiences happy. + +**How to apply:** +- Applies to any `gh pr create` / `gh pr edit --body` output you generate, both at PR creation time and when updating the description later. +- Re-flow the existing body when a PR description is already AI-dense — collapse the technical content into the toggle and write a human-friendly opener above it. +- Don't move the Test Plan checklist into the toggle — reviewers tick it off without expanding the section. +- In Summary text, prefer periods/colons over em dashes (`—`) and use direct verbs ("adds", "fixes", "replaces") instead of LLM filler like "proper support for" or "in lock-step with". Em dashes and richer prose are fine inside the `
` toggle. diff --git a/.claude/memory/2026-05-28-pr-link-fixed-issues.md b/.claude/memory/2026-05-28-pr-link-fixed-issues.md new file mode 100644 index 000000000..0c222da10 --- /dev/null +++ b/.claude/memory/2026-05-28-pr-link-fixed-issues.md @@ -0,0 +1,16 @@ +--- +name: Link fixed issues when opening a PR +description: Before running `gh pr create`, search the repo's issues for any the PR fixes/closes and add `Closes #N` / `Fixes #N` / `Resolves #N` lines so GitHub auto-closes them on merge. +type: feedback +--- + +When opening a PR, first check whether any open issues in the repo are resolved by the change. If so, link them with GitHub's closing keywords (`Closes #N`, `Fixes #N`, `Resolves #N`) in the PR body so the issues auto-close on merge. + +**Why:** Issues that get fixed quietly in a PR stay open after merge, clog the backlog, and need a manual cleanup pass later. Linking at open-time also tells reviewers what user-visible problem the PR is solving, which often shapes their review. + +**How to apply:** +- Before `gh pr create`, run `gh issue list --repo / --state open --search ""` (or filter by label) using terms drawn from the diff / commit messages / PR topic to surface candidates. Read the candidate issue bodies to confirm the PR genuinely resolves them (not just touches the same code area). +- Add the closing line at the **end of the Summary section**, before "Try it" / "Test plan" / the `
` toggle. Format: `Closes #N — ` (or `Fixes` / `Resolves`). One per line if there are several. +- If the PR *relates to* but doesn't *close* an issue, use `Refs #N` (no closing keyword) so it shows up cross-linked without being auto-closed. +- Applies to every new PR regardless of branch or scope. For existing PRs that missed this at open-time, retro-add via `gh pr edit --body` rather than dropping a separate comment — the closing keyword only works from the PR body or commit messages, not from comments. +- This is purely about GitHub issues. Don't auto-close sibling PRs or external trackers. diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 000000000..f1b6a38dc --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -0,0 +1,4 @@ +# Project Memory Index + +- [PR Description Format](2026-05-28-pr-description-format.md) — Human-friendly Summary/Try it/Test plan up top; collapse AI-dense detail and attribution into `
More info
` +- [Link Fixed Issues in PRs](2026-05-28-pr-link-fixed-issues.md) — Before `gh pr create`, find issues the PR resolves and add `Closes #N` lines to the Summary diff --git a/.github/README.md b/.github/README.md index 6d8d0b4d9..79c42f5a3 100644 --- a/.github/README.md +++ b/.github/README.md @@ -1,8 +1,6 @@ Gravity PDF ========================== -[![codecov](https://codecov.io/gh/GravityPDF/gravity-pdf/branch/development/graph/badge.svg)](https://codecov.io/gh/GravityPDF/gravity-pdf) - Gravity PDF is a GPLv2-licensed WordPress plugin that allows you to automatically generate, email and download PDF documents using the popular form-builder plugin, [Gravity Forms](https://gpdf.us/gf) (affiliate link). Find out more about Gravity PDF at [https://gravitypdf.com](https://gravitypdf.com/). # About @@ -64,8 +62,8 @@ The automated test suite can only be run using Docker. Useful commands include: ``` -yarn test:e2e -yarn test:e2e:headless +yarn test:e2e # headless run +yarn test:e2e:debug # interactive Playwright UI ``` ### PHPUnit diff --git a/.github/workflows/javascript-tests.yml b/.github/workflows/javascript-tests.yml deleted file mode 100644 index 5499bbd7f..000000000 --- a/.github/workflows/javascript-tests.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: JavaScript Tests - -on: - pull_request: - -# Cancels all previous workflow runs for pull requests that have not completed. -concurrency: - # The concurrency group contains the workflow name and the branch name for pull requests - # or the commit hash for any other events. - group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} - cancel-in-progress: true - -jobs: - test-js: - name: JavaScript Test - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Node JS - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - - - name: Log debug information - run: | - node --version - yarn --version - - - name: Install Dependencies - run: yarn install --frozen-lockfile - - - name: Run Jest tests - run: yarn test:js:coverage - - - name: Code Coverage Upload - uses: codecov/codecov-action@v6 - with: - directory: ./tmp/jest-coverage - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/phpunit.tests.yml b/.github/workflows/phpunit.tests.yml deleted file mode 100644 index 9424ecab3..000000000 --- a/.github/workflows/phpunit.tests.yml +++ /dev/null @@ -1,139 +0,0 @@ -name: PHPUnit Tests - -on: - pull_request: - types: [ opened, reopened, synchronize, labeled ] - push: - branches: [ development ] - schedule: - - cron: "15 19 * * 0,3" # Runs early on Monday and Thursday Sydney time - -# Cancels all previous workflow runs for pull requests that have not completed. -concurrency: - # The concurrency group contains the workflow name and the branch name for pull requests - # or the commit hash for any other events. - group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} - cancel-in-progress: true - -env: - GF_LICENSE: ${{ secrets.GF_LICENSE }} - -jobs: - # Only run this action if a Pull Request contains the label 'run-tests' - check_for_string_in_pull_request_comment: - name: Should workflow run? - runs-on: ubuntu-latest - steps: - - if: ${{ github.event_name == 'pull_request' && ! contains(github.event.pull_request.labels.*.name, 'run-tests') }} - uses: action-pack/cancel@v1 - - test-php: - name: ${{ matrix.php }}${{ matrix.report && ' coverage' || '' }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - # PRs run a tightened matrix (floor / coverage / ceiling) for fast feedback. - # Push to development/main and the scheduled cron run the full 7-version matrix. - php: ${{ fromJSON(github.event_name == 'pull_request' && '["7.4","8.3","8.5"]' || '["7.4","8.0","8.1","8.2","8.3","8.4","8.5"]') }} - os: [ ubuntu-latest ] - include: - - php: '8.3' - os: ubuntu-latest - report: true - env: - WP_ENV_PHP_VERSION: ${{ matrix.php }} - - steps: - - name: Configure environment variables - run: | - echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV - echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV - - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 7.4 - - - name: Setup Node JS - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: Log debug information - run: | - npm --version - yarn --version - node --version - curl --version - git --version - php --version - php -i - locale -a - - - name: Add auth details for private composer packages - run: composer config http-basic.composer.gravity.io ${{ secrets.GF_LICENSE }} http://localhost - - - name: Cache Composer downloads - uses: actions/cache@v5 - with: - path: ~/.cache/composer/files - key: composer-${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('composer.lock') }} - restore-keys: | - composer-${{ runner.os }}-php${{ matrix.php }}- - - - name: Install Composer dependencies - run: | - composer install --no-progress --no-ansi --no-interaction - echo "${PWD}/vendor/bin" >> $GITHUB_PATH - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Cache wp-env work directory - uses: actions/cache@v5 - with: - path: | - ~/wp-env - ~/.wp-env - key: wp-env-integration-${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('tools/wp-env/integration.json', 'composer.lock') }} - restore-keys: | - wp-env-integration-${{ runner.os }}-php${{ matrix.php }}- - - - name: Install / Setup Gravity PDF + WordPress - if: ${{ ! matrix.report }} - run: yarn wp-env:integration start - - - name: Install / Setup Gravity PDF + WordPress - if: ${{ matrix.report }} - run: yarn wp-env:integration start --xdebug=debug - - - name: Run PHPUnit tests - if: ${{ ! matrix.report }} - run: | - yarn test:php --do-not-cache-result --verbose - - # Multisite suite reuses the already-running php 8.3 container instead of - # spinning up its own matrix cell + cold wp-env start. - - name: Run Multisite PHPUnit tests - if: ${{ matrix.php == '8.3' && ! matrix.report }} - run: | - yarn test:php:multisite --verbose - - - name: Generate Code Coverage Report for PHP - if: ${{ matrix.report }} - run: | - yarn test:php --do-not-cache-result --verbose --coverage-clover=/var/www/html/wp-content/plugins/gravity-pdf/tmp/coverage/report-xml/php-coverage1.xml - - - name: Code Coverage Upload - uses: codecov/codecov-action@v6 - if: ${{ matrix.report }} - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - with: - directory: tmp/coverage/report-xml - diff --git a/.github/workflows/playwright-e2e.yml b/.github/workflows/playwright-e2e.yml deleted file mode 100644 index df34824d6..000000000 --- a/.github/workflows/playwright-e2e.yml +++ /dev/null @@ -1,231 +0,0 @@ -name: Playwright End-to-End Tests - -on: - schedule: - - cron: "15 19 * * 0,3" # Runs early on Monday and Thursday Sydney time - - pull_request: - types: [ opened, reopened, synchronize, labeled ] - -# Cancels all previous workflow runs for pull requests that have not completed. -concurrency: - # The concurrency group contains the workflow name and the branch name for pull requests - # or the commit hash for any other events. - group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} - cancel-in-progress: true - -jobs: - # Only run this action if a Pull Request contains the label 'run-tests' - check_for_string_in_pull_request_comment: - name: Should workflow run? - runs-on: ubuntu-latest - steps: - - if: ${{ github.event_name == 'pull_request' && ! contains(github.event.pull_request.labels.*.name, 'run-tests') }} - uses: action-pack/cancel@v1 - - e2e-playwright: - name: Playwright E2E Tests (shard ${{ matrix.shard }}/${{ strategy.job-total }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [ 1, 2, 3, 4 ] - - steps: - - name: Configure environment variables - run: | - echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV - echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV - - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - env: - CHROMATIC_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }} - CHROMATIC_SHA: ${{ github.event.pull_request.head.sha || github.ref }} - CHROMATIC_SLUG: ${{ github.repository }} - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 7.4 - - - name: Setup Node JS - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: Log debug information - run: | - npm --version - yarn --version - node --version - curl --version - git --version - php --version - php -i - locale -a - - - name: Add auth details for private composer packages - run: composer config http-basic.composer.gravity.io ${{ secrets.GF_LICENSE }} http://localhost - - - name: Cache Composer downloads - uses: actions/cache@v5 - with: - path: ~/.cache/composer/files - key: composer-${{ runner.os }}-e2e-${{ hashFiles('composer.lock') }} - restore-keys: | - composer-${{ runner.os }}-e2e- - - - name: Install Composer dependencies - run: | - time composer install --no-progress --no-ansi --no-interaction - echo "${PWD}/vendor/bin" >> $GITHUB_PATH - - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('yarn.lock') }} - restore-keys: | - playwright-${{ runner.os }}- - - - name: Install playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: time npx playwright install --with-deps chromium - - - name: Install playwright system deps (cache hit) - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: time npx playwright install-deps chromium - - - name: Cache wp-env work directory - uses: actions/cache@v5 - with: - path: | - ~/wp-env - ~/.wp-env - key: wp-env-e2e-${{ runner.os }}-${{ hashFiles('tools/wp-env/e2e.json', 'composer.lock') }} - restore-keys: | - wp-env-e2e-${{ runner.os }}- - - - name: Install / Setup Gravity PDF + WordPress - run: time yarn wp-env:e2e start - - # wp-env caches its work directory on disk but Docker volumes (MySQL - # data) aren't cached, so a restored work-dir can pair with a fresh - # empty DB. wp-env's lazy start then sees the work-dir as "already - # configured" and skips `wp core install`, leaving Playwright tests - # to fail with "The site you have requested is not installed." - - name: Ensure WordPress is installed (wp-env cache fallback) - run: | - time yarn wp-env:e2e run cli bash -c ' - if ! wp core is-installed >/dev/null 2>&1; then - echo "wp-env work-dir cache hit a fresh DB volume — running wp core install." - wp core install \ - --url=http://localhost:8702 \ - --title=Test \ - --admin_user=admin \ - --admin_password=password \ - --admin_email=admin@test.local \ - --skip-email - else - echo "WordPress already installed." - fi - ' - - - name: Run the tests - run: time yarn test:e2e -- --shard=${{ matrix.shard }}/${{ strategy.job-total }} - env: - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true - - - uses: actions/upload-artifact@v6 - if: success() - with: - name: test-results-shard-${{ matrix.shard }} - path: ./tmp/artifacts/test-results - - - name: Dump log files on failure - if: failure() - run: yarn wp-env:e2e run wordpress "cp" "-r" "/var/www/html/wp-content/uploads/gravity_forms/logs/" "/var/www/html/wp-content/plugins/gravity-pdf/tmp/artifacts/logs" - - - name: Upload artifacts on failure - uses: actions/upload-artifact@v6 - if: failure() - with: - name: failure-artifacts-shard-${{ matrix.shard }} - path: tmp/artifacts - - notify: - name: Send weekly run notification - needs: e2e-playwright - if: ${{ always() && github.event_name != 'pull_request' }} - runs-on: ubuntu-latest - steps: - - name: Send Success Email for weekly runner - uses: dawidd6/action-send-mail@v16 - if: ${{ needs.e2e-playwright.result == 'success' }} - with: - connection_url: ${{secrets.MAIL_CONNECTION}} - subject: Gravity PDF E2E Tests Completed - to: ${{secrets.MAIL_TO}} - from: ${{secrets.MAIL_FROM}} - body: View results at https://github.com/GravityPDF/gravity-pdf/actions/runs/${{github.run_id}} - - - name: Send Failure Email for weekly runner - uses: dawidd6/action-send-mail@v16 - if: ${{ needs.e2e-playwright.result != 'success' }} - with: - connection_url: ${{secrets.MAIL_CONNECTION}} - subject: Gravity PDF E2E Tests Failed - to: ${{secrets.MAIL_TO}} - from: ${{secrets.MAIL_FROM}} - body: View results at https://github.com/GravityPDF/gravity-pdf/actions/runs/${{github.run_id}} - - chromatic: - name: Run Chromatic - needs: e2e-playwright - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: Install JS Dependencies - run: yarn install --frozen-lockfile - - # `merge-multiple: true` extracts shard artifacts in parallel into the - # same directory. Each shard writes the same `chromatic-archives/**` - # filenames (Playwright runs the full `core` project on every shard - # because it's listed in `setup-core-with-permalinks.dependencies`), so - # the parallel writes race and leave large snapshot.json files with - # tail bytes from the longer writer after a shorter writer's truncate. - # Chromatic then fails the affected snapshots with "Unexpected - # non-whitespace character after JSON at position …". Download each - # shard to its own subdirectory and merge serially to avoid the race. - - name: Download Playwright test results from all shards - uses: actions/download-artifact@v8 - with: - pattern: test-results-shard-* - path: ./shard-artifacts - - - name: Merge shard artifacts serially - run: | - mkdir -p ./test-results - for shard_dir in ./shard-artifacts/test-results-shard-*/; do - cp -R "${shard_dir}." ./test-results/ - done - - - name: Run Chromatic - uses: chromaui/action@latest - with: - playwright: true - autoAcceptChanges: "development" - projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..1c101e7ac --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,581 @@ +name: Tests + +on: + pull_request: + types: [ opened, reopened, synchronize, labeled ] + schedule: + - cron: "0 20 * * 0,2,4" # Mon/Wed/Fri 6am Sydney AEST (7am during AEDT) + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} + cancel-in-progress: true + +env: + GF_LICENSE: ${{ secrets.GF_LICENSE }} + +jobs: + check_for_run_tests_label_in_pr: + name: Should workflow run? + runs-on: ubuntu-latest + steps: + # pull_request:opened can fire before labels are applied (empty webhook payload labels[]) — + # read live labels via gh-api; runner spin-up absorbs the propagation window. + - name: Check for run-tests label + if: ${{ github.event_name == 'pull_request' }} + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json labels --jq '.labels[].name' | grep -qx 'run-tests' + + # composer.json's post-install-cmd chains `composer prefix` + `yarn install && yarn build`, so one composer install + # here primes vendor/, vendor_prefixed/, node_modules/, and build/assets/ for every downstream matrix. + prebuild: + name: Prebuild shared caches + needs: check_for_run_tests_label_in_pr + runs-on: ubuntu-latest + outputs: + chromatic-enabled: ${{ steps.chromatic-gate.outputs.enabled }} + steps: + - name: Decide whether Chromatic runs this push + id: chromatic-gate + env: + EVENT_NAME: ${{ github.event_name }} + PR_DRAFT: ${{ github.event.pull_request.draft }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + enabled=true + reason=enabled + if [ "$EVENT_NAME" = "pull_request" ]; then + if [ "$PR_DRAFT" = "true" ]; then + enabled=false; reason=draft + elif printf '%s' "$PR_TITLE" | grep -qiwE 'wip'; then + enabled=false; reason='WIP in title' + elif printf '%s' "$PR_BODY" | grep -qiwE 'wip'; then + enabled=false; reason='WIP in body' + fi + fi + echo "[chromatic-gate] enabled=$enabled ($reason)" + echo "enabled=$enabled" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@v6 + + # Bundled — composer install + the php-scoper post step produce them together; they must invalidate together. + - name: Check Composer vendor cache + id: vendor-cache + uses: actions/cache/restore@v5 + with: + path: | + vendor + vendor_prefixed + key: vendor-${{ runner.os }}-${{ hashFiles('composer.lock', 'tools/php-scoper/**') }} + lookup-only: true + + - name: Check node_modules cache + id: nm-cache + uses: actions/cache/restore@v5 + with: + path: node_modules + key: nm-${{ runner.os }}-${{ hashFiles('yarn.lock', '.nvmrc') }} + lookup-only: true + + # Lets Playwright shards skip the webpack rebuild. + - name: Check build assets cache + id: build-cache + uses: actions/cache/restore@v5 + with: + path: build/assets + key: build-${{ runner.os }}-${{ hashFiles('src/assets/**', 'yarn.lock', '.nvmrc', 'package.json', 'babel.config.js', '.browserslistrc') }} + lookup-only: true + + - name: Setup PHP + if: ${{ steps.vendor-cache.outputs.cache-hit != 'true' || steps.nm-cache.outputs.cache-hit != 'true' || steps.build-cache.outputs.cache-hit != 'true' }} + uses: shivammathur/setup-php@v2 + with: + php-version: 7.4 + + - name: Setup Node JS + if: ${{ steps.vendor-cache.outputs.cache-hit != 'true' || steps.nm-cache.outputs.cache-hit != 'true' || steps.build-cache.outputs.cache-hit != 'true' }} + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: Add auth details for private composer packages + if: ${{ steps.vendor-cache.outputs.cache-hit != 'true' || steps.nm-cache.outputs.cache-hit != 'true' || steps.build-cache.outputs.cache-hit != 'true' }} + run: composer config http-basic.composer.gravity.io ${{ secrets.GF_LICENSE }} http://localhost + + - name: Install Composer dependencies (post-install-cmd also runs php-scoper + yarn install + yarn build) + if: ${{ steps.vendor-cache.outputs.cache-hit != 'true' || steps.nm-cache.outputs.cache-hit != 'true' || steps.build-cache.outputs.cache-hit != 'true' }} + run: composer install --no-progress --no-ansi --no-interaction + + # Save only on success() — one composer install produces all three artifacts, so a partway failure + # could cache incomplete state. + - name: Save Composer vendor + if: ${{ steps.vendor-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v5 + with: + path: | + vendor + vendor_prefixed + key: ${{ steps.vendor-cache.outputs.cache-primary-key }} + + - name: Save node_modules + if: ${{ steps.nm-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v5 + with: + path: node_modules + key: ${{ steps.nm-cache.outputs.cache-primary-key }} + + - name: Save build assets + if: ${{ steps.build-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v5 + with: + path: build/assets + key: ${{ steps.build-cache.outputs.cache-primary-key }} + + test-js: + name: JavaScript Tests + needs: prebuild + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node JS + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - name: Log debug information + run: | + node --version + yarn --version + + # Primed by prebuild — a miss here means prebuild itself broke, so fail loudly rather than silently re-installing. + - name: Restore node_modules + uses: actions/cache/restore@v5 + with: + path: node_modules + key: nm-${{ runner.os }}-${{ hashFiles('yarn.lock', '.nvmrc') }} + fail-on-cache-miss: true + + - name: Run Jest tests + run: yarn test:js:coverage + + - name: Post coverage PR comment + if: ${{ github.event_name == 'pull_request' }} + uses: lucassabreu/comment-coverage-clover@v0.17.0 + with: + file: ./tmp/jest-coverage/clover.xml + signature: ":robot: Jest coverage report" + + test-php: + name: PHPUnit ${{ matrix.php }}${{ matrix.report && ' coverage' || '' }} on ${{ matrix.os }} + needs: prebuild + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # PRs run a tightened floor/ceiling matrix for fast feedback; cron runs the full matrix down to the + # composer.json floor. The run-tests-php-all label forces the full matrix on a PR — applied post-open, + # so the labeled event re-runs this workflow with a payload that carries the label. + php: ${{ fromJSON((github.event_name == 'pull_request' && ! contains(github.event.pull_request.labels.*.name, 'run-tests-php-all')) && '["7.4","8.5"]' || '["7.4","8.0","8.1","8.2","8.3","8.4","8.5"]') }} + os: [ ubuntu-latest ] + include: + # These flags merge into the matching base cells (no extra cells spawned) and drive the + # conditional steps below: coverage runs once on 8.3, multisite on the floor + ceiling. + - php: '8.3' + os: ubuntu-latest + report: true + - php: '7.4' + os: ubuntu-latest + multisite: true + - php: '8.5' + os: ubuntu-latest + multisite: true + env: + WP_ENV_PHP_VERSION: ${{ matrix.php }} + + steps: + - name: Configure environment variables + run: | + echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV + echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV + + # Route Docker Hub pulls through Google's unauthenticated mirror — parallel matrix cells got silently + # rate-limited on docker.io (manifested as wp-env "Done in 13s" with no containers actually running). + - name: Use Google's Docker Hub mirror to avoid rate limits + run: | + echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json + sudo systemctl restart docker + + - name: Checkout repository + uses: actions/checkout@v6 + + # Locks in the static::load_fixtures() contract — the shared $GLOBALS['GFPDF_Test'] global + # and direct GFAPI form/entry creates have been removed; this gate stops them returning. + - name: Fail on banned fixture patterns + run: | + if grep -rnE "\\\$GLOBALS\\[['\\\"]GFPDF_Test['\\\"]\\]|GFAPI::add_(form|entry)\\b" tests/phpunit/integration/ --include="*.php"; then + echo "::error::Banned fixture pattern found in tests/phpunit/integration/. Use static::load_fixtures() instead." + exit 1 + fi + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + + - name: Setup Node JS + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - name: Log debug information + run: | + npm --version + yarn --version + node --version + curl --version + git --version + php --version + php -i + locale -a + + # Primed by prebuild — a miss here means prebuild itself broke, so fail loudly rather than silently + # re-installing per matrix cell. + - name: Restore Composer vendor + uses: actions/cache/restore@v5 + with: + path: | + vendor + vendor_prefixed + key: vendor-${{ runner.os }}-${{ hashFiles('composer.lock', 'tools/php-scoper/**') }} + fail-on-cache-miss: true + + - name: Restore node_modules + uses: actions/cache/restore@v5 + with: + path: node_modules + key: nm-${{ runner.os }}-${{ hashFiles('yarn.lock', '.nvmrc') }} + fail-on-cache-miss: true + + # No restore-keys: the work-dir embeds the downloaded WordPress core, so restoring it across a + # core-version bump (the key hashes the config) leaves wp-env with a stale WP that silently fails + # to start. A clean miss re-downloads the pinned version instead. + - name: Cache wp-env work directory + uses: actions/cache@v5 + with: + path: | + ~/wp-env + ~/.wp-env + key: wp-env-integration-v2-${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('tools/wp-env/integration.json', 'composer.lock') }} + + # Pre-fetch the pinned WordPress core so wp-env's start has nothing to download. Its in-start core + # fetch silently aborts provisioning on the CI runners (only the DB container comes up); pointing + # WP_ENV_CORE at a local directory sidesteps that. URL is read from the config to stay single-source. + - name: Pre-download WordPress core + run: | + curl -fsSL "$(jq -r '.core' tools/wp-env/integration.json)" -o "$RUNNER_TEMP/wp-core.zip" + unzip -q "$RUNNER_TEMP/wp-core.zip" -d "$RUNNER_TEMP/wp-core" + echo "WP_ENV_CORE=$RUNNER_TEMP/wp-core/wordpress" >> "$GITHUB_ENV" + + - name: Install / Setup Gravity PDF + WordPress + if: ${{ ! matrix.report }} + run: yarn wp-env:integration start + + - name: Install / Setup Gravity PDF + WordPress + if: ${{ matrix.report }} + run: yarn wp-env:integration start --xdebug=coverage + + # retry.sh re-runs failed tests once (--filter built from the JUnit XML) so a single transient flake + # doesn't burn a matrix rerun; retries log as ::warning:: so flakes stay visible. + - name: Run PHPUnit tests + if: ${{ ! matrix.report }} + run: | + tools/phpunit/retry.sh test:php \ + /var/www/html/wp-content/plugins/gravity-pdf/tmp/junit/phpunit-integration-php${{ matrix.php }}.xml \ + --do-not-cache-result --verbose + + # Multisite-tagged cells (floor + ceiling) reuse their already-running single-site wp-env rather than + # spinning a dedicated cell. The 8.3 coverage cell runs multisite separately, for the merged report. + - name: Run Multisite PHPUnit tests + if: ${{ matrix.multisite }} + run: | + tools/phpunit/retry.sh test:php:multisite \ + /var/www/html/wp-content/plugins/gravity-pdf/tmp/junit/phpunit-multisite-php${{ matrix.php }}.xml \ + --verbose + + - name: Generate Code Coverage Report for PHP (single-site) + if: ${{ matrix.report }} + run: | + tools/phpunit/retry.sh test:php \ + /var/www/html/wp-content/plugins/gravity-pdf/tmp/junit/phpunit-coverage-php${{ matrix.php }}.xml \ + --do-not-cache-result --verbose \ + --coverage-clover=/var/www/html/wp-content/plugins/gravity-pdf/tmp/coverage/report-xml/php-coverage1.xml + + - name: Generate Code Coverage Report for PHP (multisite) + if: ${{ matrix.report }} + run: | + tools/phpunit/retry.sh test:php:multisite \ + /var/www/html/wp-content/plugins/gravity-pdf/tmp/junit/phpunit-coverage-multisite-php${{ matrix.php }}.xml \ + --do-not-cache-result --verbose \ + --coverage-clover=/var/www/html/wp-content/plugins/gravity-pdf/tmp/coverage/report-xml/php-coverage-multisite.xml + + - name: Upload PHPUnit JUnit timings + uses: actions/upload-artifact@v6 + if: always() + with: + name: phpunit-junit-php${{ matrix.php }}${{ matrix.report && '-coverage' || '' }} + path: tmp/junit/*.xml + + - name: Merge single-site + multisite Clover + if: ${{ matrix.report && github.event_name == 'pull_request' }} + run: | + php tools/phpunit/coverage-merge.php \ + tmp/coverage/report-xml/merged.xml \ + tmp/coverage/report-xml/php-coverage1.xml \ + tmp/coverage/report-xml/php-coverage-multisite.xml + + # Custom signature so this comment doesn't collide with test-js' coverage comment — the action keys + # de-duplication off the signature string, so identical defaults would overwrite each other. + - name: Post coverage PR comment + if: ${{ matrix.report && github.event_name == 'pull_request' }} + uses: lucassabreu/comment-coverage-clover@v0.17.0 + with: + file: tmp/coverage/report-xml/merged.xml + signature: ":robot: PHPUnit coverage report" + + e2e-playwright: + name: Playwright E2E Tests (shard ${{ matrix.shard }}/${{ strategy.job-total }}) + needs: prebuild + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [ 1, 2, 3, 4 ] + + steps: + - name: Configure environment variables + run: | + echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV + echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV + + # Route Docker Hub pulls through Google's unauthenticated mirror — the 4 parallel shards got silently + # rate-limited on docker.io (manifested as wp-env "Done in 13s" with no containers actually running). + - name: Use Google's Docker Hub mirror to avoid rate limits + run: | + echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json + sudo systemctl restart docker + + - name: Checkout repository + uses: actions/checkout@v6 + env: + CHROMATIC_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }} + CHROMATIC_SHA: ${{ github.event.pull_request.head.sha || github.ref }} + CHROMATIC_SLUG: ${{ github.repository }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 7.4 + + - name: Setup Node JS + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - name: Log debug information + run: | + npm --version + yarn --version + node --version + curl --version + git --version + php --version + php -i + locale -a + + # Primed by prebuild — a miss here means prebuild itself broke, so fail loudly rather than silently + # re-installing per shard. + - name: Restore Composer vendor + uses: actions/cache/restore@v5 + with: + path: | + vendor + vendor_prefixed + key: vendor-${{ runner.os }}-${{ hashFiles('composer.lock', 'tools/php-scoper/**') }} + fail-on-cache-miss: true + + - name: Restore node_modules + uses: actions/cache/restore@v5 + with: + path: node_modules + key: nm-${{ runner.os }}-${{ hashFiles('yarn.lock', '.nvmrc') }} + fail-on-cache-miss: true + + - name: Restore build assets + uses: actions/cache/restore@v5 + with: + path: build/assets + key: build-${{ runner.os }}-${{ hashFiles('src/assets/**', 'yarn.lock', '.nvmrc', 'package.json', 'babel.config.js', '.browserslistrc') }} + fail-on-cache-miss: true + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: | + playwright-${{ runner.os }}- + + - name: Install playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + - name: Install playwright system deps (cache hit) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + # No restore-keys: the work-dir embeds the downloaded WordPress core, so restoring it across a + # core-version bump (the key hashes the config) leaves wp-env with a stale WP that silently fails + # to start. A clean miss re-downloads the pinned version instead. + - name: Cache wp-env work directory + uses: actions/cache@v5 + with: + path: | + ~/wp-env + ~/.wp-env + key: wp-env-e2e-v2-${{ runner.os }}-${{ hashFiles('tools/wp-env/e2e.json', 'composer.lock') }} + + # See the integration job: pre-fetch core so wp-env's start has nothing to download. + - name: Pre-download WordPress core + run: | + curl -fsSL "$(jq -r '.core' tools/wp-env/e2e.json)" -o "$RUNNER_TEMP/wp-core.zip" + unzip -q "$RUNNER_TEMP/wp-core.zip" -d "$RUNNER_TEMP/wp-core" + echo "WP_ENV_CORE=$RUNNER_TEMP/wp-core/wordpress" >> "$GITHUB_ENV" + + - name: Install / Setup Gravity PDF + WordPress + run: yarn wp-env:e2e start + + # wp-env's work-dir is cached but its MySQL volume isn't, so a restored work-dir can pair with a fresh empty DB + # — wp-env then skips `wp core install` and Playwright fails with "The site you have requested is not installed." + - name: Ensure WordPress is installed (wp-env cache fallback) + run: | + yarn wp-env:e2e run cli bash -c ' + if ! wp core is-installed >/dev/null 2>&1; then + echo "wp-env work-dir cache hit a fresh DB volume — running wp core install." + wp core install \ + --url=http://localhost:8702 \ + --title=Test \ + --admin_user=admin \ + --admin_password=password \ + --admin_email=admin@test.local \ + --skip-email + else + echo "WordPress already installed." + fi + ' + + - name: Run the tests + run: yarn test:e2e -- --shard=${{ matrix.shard }}/${{ strategy.job-total }} + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true + # '0' no-ops @chromatic-com/playwright's takeSnapshot — saves ~20–40% wall-clock when Chromatic is off. + CHROMATIC: ${{ needs.prebuild.outputs.chromatic-enabled == 'true' && '1' || '0' }} + + - uses: actions/upload-artifact@v6 + if: success() && needs.prebuild.outputs.chromatic-enabled == 'true' + with: + name: test-results-shard-${{ matrix.shard }} + path: ./tmp/artifacts/test-results + + - name: Dump log files on failure + if: failure() + run: yarn wp-env:e2e run wordpress "cp" "-r" "/var/www/html/wp-content/uploads/gravity_forms/logs/" "/var/www/html/wp-content/plugins/gravity-pdf/tmp/artifacts/logs" + + - name: Upload artifacts on failure + uses: actions/upload-artifact@v6 + if: failure() + with: + name: failure-artifacts-shard-${{ matrix.shard }} + path: tmp/artifacts + + notify: + name: Notify on weekly run + needs: [ test-js, test-php, e2e-playwright ] + if: ${{ always() && github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + env: + HEADLINE: ${{ (needs.test-js.result == 'success' && needs.test-php.result == 'success' && needs.e2e-playwright.result == 'success') && 'Gravity PDF Tests Completed' || 'Gravity PDF Tests Failed' }} + steps: + - name: Post Slack notification for weekly run + uses: slackapi/slack-github-action@v2 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + payload: | + text: "${{ env.HEADLINE }}" + blocks: + - type: section + text: + type: mrkdwn + text: | + *${{ env.HEADLINE }}* + + JavaScript: ${{ needs.test-js.result }} + PHPUnit: ${{ needs.test-php.result }} + E2E (Playwright): ${{ needs.e2e-playwright.result }} + + + + chromatic: + name: Run Chromatic + needs: [ prebuild, e2e-playwright ] + if: needs.prebuild.outputs.chromatic-enabled == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: Restore node_modules + id: nm-cache + uses: actions/cache/restore@v5 + with: + path: node_modules + key: nm-${{ runner.os }}-${{ hashFiles('yarn.lock', '.nvmrc') }} + + - name: Install JS Dependencies + if: steps.nm-cache.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + + # Every shard writes the same chromatic-archives/** filenames, so `merge-multiple: true` races parallel writes + # and Chromatic fails with "Unexpected non-whitespace character after JSON…". Per-shard subdirs + serial merge. + - name: Download Playwright test results from all shards + uses: actions/download-artifact@v8 + with: + pattern: test-results-shard-* + path: ./shard-artifacts + + - name: Merge shard artifacts serially + run: | + mkdir -p ./test-results + for shard_dir in ./shard-artifacts/test-results-shard-*/; do + cp -R "${shard_dir}." ./test-results/ + done + + - name: Run Chromatic + uses: chromaui/action@latest + with: + playwright: true + autoAcceptChanges: "development" + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} diff --git a/.github/workflows/update-dependencies.yml b/.github/workflows/update-dependencies.yml new file mode 100644 index 000000000..94feba259 --- /dev/null +++ b/.github/workflows/update-dependencies.yml @@ -0,0 +1,125 @@ +name: Update Dependencies + +on: + schedule: + - cron: "0 6 * * 1" # Weekly on Monday at 06:00 UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-deps: + name: Bump WordPress core + Composer packages + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 7.4 + tools: composer + + - name: Add auth details for private composer packages + run: composer config http-basic.composer.gravity.io ${{ secrets.GF_LICENSE }} http://localhost + + - name: Resolve latest stable WordPress version + id: wp + run: | + latest=$(curl -fsSL https://api.wordpress.org/core/version-check/1.7/ \ + | python3 -c "import sys, json; print(json.load(sys.stdin)['offers'][0]['version'])") + if [ -z "$latest" ]; then + echo "Failed to resolve latest WordPress version" >&2 + exit 1 + fi + # integration.json is the canonical source for the current WP pin; + # the three wp-env configs are kept in lockstep. + current=$(grep -oE 'wordpress-[0-9]+\.[0-9]+(\.[0-9]+)?\.zip' tools/wp-env/integration.json \ + | head -n1 | sed -E 's/wordpress-([0-9.]+)\.zip/\1/') + echo "latest=$latest" >> "$GITHUB_OUTPUT" + echo "current=$current" >> "$GITHUB_OUTPUT" + [ "$latest" != "$current" ] && echo "changed=true" >> "$GITHUB_OUTPUT" || echo "changed=false" >> "$GITHUB_OUTPUT" + + - name: Update wp-env configs + if: steps.wp.outputs.changed == 'true' + run: | + for file in tools/wp-env/development.json \ + tools/wp-env/integration.json \ + tools/wp-env/e2e.json; do + sed -i -E "s|wordpress-[0-9]+\.[0-9]+(\.[0-9]+)?\.zip|wordpress-${{ steps.wp.outputs.latest }}.zip|g" "$file" + done + + # Snapshot before/after so the PR body can render a Before/After table. + - name: Snapshot current Composer versions + run: | + jq -r ' + (.packages + .["packages-dev"]) + | map(select(.name == "gravity/gravityforms")) + | map("\(.name) \(.version)") + | sort + | .[] + ' composer.lock > /tmp/composer-before.txt + cat /tmp/composer-before.txt + + # --no-scripts skips post-update-cmd (php-scoper prefix); vendor_prefixed/ isn't committed so the rebuild is wasted work. + - name: Update target Composer packages + run: | + composer update \ + gravity/gravityforms \ + --with-all-dependencies \ + --no-progress --no-ansi --no-interaction --no-scripts + + - name: Snapshot updated Composer versions + run: | + jq -r ' + (.packages + .["packages-dev"]) + | map(select(.name == "gravity/gravityforms")) + | map("\(.name) \(.version)") + | sort + | .[] + ' composer.lock > /tmp/composer-after.txt + cat /tmp/composer-after.txt + + - name: Build PR body + id: body + run: | + { + echo "text<> "$GITHUB_OUTPUT" + + # WORKFLOW_PAT (not GITHUB_TOKEN) so the PR's events fire tests.yml — GITHUB_TOKEN-attributed events are suppressed to prevent recursion. + # add-paths restricts the commit to lock + wp-env configs so a dirty vendor/ on the runner can't leak in. + - name: Open pull request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.WORKFLOW_PAT }} + branch: chore/weekly-deps-update + base: development + add-paths: | + composer.lock + tools/wp-env/development.json + tools/wp-env/integration.json + tools/wp-env/e2e.json + commit-message: "chore(deps): weekly bump of WordPress core + Gravity Forms" + title: "chore(deps): weekly bump of WordPress core + Gravity Forms" + body: ${{ steps.body.outputs.text }} + labels: | + dependencies + run-tests + delete-branch: true diff --git a/.github/workflows/wp-core-version-update.yml b/.github/workflows/wp-core-version-update.yml deleted file mode 100644 index ca41a39b3..000000000 --- a/.github/workflows/wp-core-version-update.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Update WordPress Core Version - -on: - schedule: - - cron: "0 6 * * 1" # Weekly on Monday at 06:00 UTC - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - update-wp-core: - name: Check for new WordPress release - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Resolve latest stable WordPress version - id: wp - run: | - latest=$(curl -fsSL https://api.wordpress.org/core/version-check/1.7/ \ - | python3 -c "import sys, json; print(json.load(sys.stdin)['offers'][0]['version'])") - if [ -z "$latest" ]; then - echo "Failed to resolve latest WordPress version" >&2 - exit 1 - fi - current=$(grep -oE 'wordpress-[0-9]+\.[0-9]+(\.[0-9]+)?\.zip' tools/wp-env/integration.json \ - | head -n1 | sed -E 's/wordpress-([0-9.]+)\.zip/\1/') - echo "latest=$latest" >> "$GITHUB_OUTPUT" - echo "current=$current" >> "$GITHUB_OUTPUT" - if [ "$latest" = "$current" ]; then - echo "needs_update=false" >> "$GITHUB_OUTPUT" - else - echo "needs_update=true" >> "$GITHUB_OUTPUT" - fi - - - name: Update wp-env configs - if: steps.wp.outputs.needs_update == 'true' - run: | - for file in tools/wp-env/development.json tools/wp-env/integration.json tools/wp-env/e2e.json; do - sed -i -E "s|wordpress-[0-9]+\.[0-9]+(\.[0-9]+)?\.zip|wordpress-${{ steps.wp.outputs.latest }}.zip|g" "$file" - done - - - name: Open pull request - if: steps.wp.outputs.needs_update == 'true' - uses: peter-evans/create-pull-request@v7 - with: - branch: chore/wp-core-${{ steps.wp.outputs.latest }} - base: development - commit-message: "chore(wp-env): bump WordPress core to ${{ steps.wp.outputs.latest }}" - title: "chore(wp-env): bump WordPress core to ${{ steps.wp.outputs.latest }}" - body: | - Automated bump of the pinned WordPress core version used by all wp-env environments. - - - Previous: `${{ steps.wp.outputs.current }}` - - Latest: `${{ steps.wp.outputs.latest }}` - - Source: https://api.wordpress.org/core/version-check/1.7/ - labels: | - dependencies - run-tests - delete-branch: true diff --git a/composer.json b/composer.json index b19eb7fdb..f134d54e3 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ } ], "require": { - "php": ">=7.3", + "php": ">=7.4", "mpdf/mpdf": "^8.2.4", "monolog/monolog": "^2.1.0", "spatie/url-signer": "^1.1", @@ -48,10 +48,7 @@ "yoast/phpunit-polyfills": "^4.0", "wp-phpunit/wp-phpunit": "^6.4", "humbug/php-scoper": "^0.15.0", - "gravity/gravityforms": "*", - "gravity/gravityformspolls": "*", - "gravity/gravityformssurvey": "*", - "gravity/gravityformsquiz": "*" + "gravity/gravityforms": "*" }, "suggest": { "roots/wordpress-full": "Install to do step debugging on the WordPress codebase." @@ -64,11 +61,20 @@ "vendor_prefixed/" ] }, + "autoload-dev": { + "psr-4": { + "GFPDF\\Tests\\Concerns\\": "tests/phpunit/Concerns/" + }, + "classmap": [ + "tests/phpunit/integration/TestCase.php", + "tests/phpunit/integration/AjaxTestCase.php" + ] + }, "config": { "preferred-install": "dist", "autoloader-suffix": "GravityPDFPlugin", "platform": { - "php": "7.3.0" + "php": "7.4" }, "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, diff --git a/composer.lock b/composer.lock index b2eb27d17..6083fd6f4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a39414df586126fa227d1c6ef031a423", + "content-hash": "0fdf1becc8ce0fbf99679b0909d49d7f", "packages": [ { "name": "gravitypdf/querypath", @@ -133,33 +133,36 @@ }, { "name": "league/uri", - "version": "6.5.0", + "version": "6.7.2", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "c68ca445abb04817d740ddd6d0b3551826ef0c5a" + "reference": "d3b50812dd51f3fbf176344cc2981db03d10fe06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/c68ca445abb04817d740ddd6d0b3551826ef0c5a", - "reference": "c68ca445abb04817d740ddd6d0b3551826ef0c5a", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/d3b50812dd51f3fbf176344cc2981db03d10fe06", + "reference": "d3b50812dd51f3fbf176344cc2981db03d10fe06", "shasum": "" }, "require": { "ext-json": "*", "league/uri-interfaces": "^2.3", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "psr/http-message": "^1.0" }, "conflict": { "league/uri-schemes": "^1.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.19 || ^3.0", - "phpstan/phpstan": "^0.12.90", - "phpstan/phpstan-phpunit": "^0.12.22", - "phpstan/phpstan-strict-rules": "^0.12.11", - "phpunit/phpunit": "^8.0 || ^9.0", + "friendsofphp/php-cs-fixer": "^v3.3.2", + "nyholm/psr7": "^1.5", + "php-http/psr7-integration-tests": "^1.1", + "phpstan/phpstan": "^1.2.0", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0.0", + "phpstan/phpstan-strict-rules": "^1.1.0", + "phpunit/phpunit": "^9.5.10", "psr/http-factory": "^1.0" }, "suggest": { @@ -191,7 +194,7 @@ } ], "description": "URI manipulation library", - "homepage": "http://uri.thephpleague.com", + "homepage": "https://uri.thephpleague.com", "keywords": [ "data-uri", "file-uri", @@ -217,7 +220,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri/issues", - "source": "https://github.com/thephpleague/uri/tree/6.5.0" + "source": "https://github.com/thephpleague/uri/tree/6.7.2" }, "funding": [ { @@ -225,37 +228,38 @@ "type": "github" } ], - "time": "2021-08-27T09:54:07+00:00" + "time": "2022-09-13T19:50:42+00:00" }, { "name": "league/uri-components", - "version": "2.4.0", + "version": "2.4.2", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-components.git", - "reference": "c97c83e7d915cdb0163f0322a87a07df1d0d9fe1" + "reference": "c93837294fe9021d518fd3ea4c5f3fbba8b8ddeb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/c97c83e7d915cdb0163f0322a87a07df1d0d9fe1", - "reference": "c97c83e7d915cdb0163f0322a87a07df1d0d9fe1", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/c93837294fe9021d518fd3ea4c5f3fbba8b8ddeb", + "reference": "c93837294fe9021d518fd3ea4c5f3fbba8b8ddeb", "shasum": "" }, "require": { "ext-json": "*", "league/uri-interfaces": "^2.3", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "psr/http-message": "^1.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.19", - "guzzlehttp/psr7": "^1.4.2", - "laminas/laminas-diactoros": "^2.6.0", + "friendsofphp/php-cs-fixer": "^v3.22.0", + "guzzlehttp/psr7": "^2.2", + "laminas/laminas-diactoros": "^2.11", "league/uri": "^6.0", - "phpstan/phpstan": "^0.12.94", - "phpstan/phpstan-phpunit": "^0.12.21", - "phpstan/phpstan-strict-rules": "^0.12.10", - "phpunit/phpunit": "^9.5.8" + "phpstan/phpstan": "^1.10.28", + "phpstan/phpstan-deprecation-rules": "^1.1.4", + "phpstan/phpstan-phpunit": "^1.3.13", + "phpstan/phpstan-strict-rules": "^1.5.1", + "phpunit/phpunit": "^9.6.10" }, "suggest": { "ext-fileinfo": "Needed to create Data URI from a filepath", @@ -305,8 +309,7 @@ "userinfo" ], "support": { - "issues": "https://github.com/thephpleague/uri-components/issues", - "source": "https://github.com/thephpleague/uri-components/tree/2.4.0" + "source": "https://github.com/thephpleague/uri-components/tree/2.4.2" }, "funding": [ { @@ -314,7 +317,7 @@ "type": "github" } ], - "time": "2021-08-02T20:31:29+00:00" + "time": "2023-08-13T19:53:57+00:00" }, { "name": "league/uri-interfaces", @@ -1545,42 +1548,6 @@ }, "type": "wordpress-plugin" }, - { - "name": "gravity/gravityformspolls", - "version": "4.4.0", - "dist": { - "type": "zip", - "url": "https://composer.gravity.io/downloads/?plugin=gravityformspolls&version=4.4.0" - }, - "require": { - "composer/installers": "^1.0 || ^2.0" - }, - "type": "wordpress-plugin" - }, - { - "name": "gravity/gravityformsquiz", - "version": "4.3.0", - "dist": { - "type": "zip", - "url": "https://composer.gravity.io/downloads/?plugin=gravityformsquiz&version=4.3.0" - }, - "require": { - "composer/installers": "^1.0 || ^2.0" - }, - "type": "wordpress-plugin" - }, - { - "name": "gravity/gravityformssurvey", - "version": "4.2.1", - "dist": { - "type": "zip", - "url": "https://composer.gravity.io/downloads/?plugin=gravityformssurvey&version=4.2.1" - }, - "require": { - "composer/installers": "^1.0 || ^2.0" - }, - "type": "wordpress-plugin" - }, { "name": "humbug/php-scoper", "version": "0.15.0", @@ -2699,20 +2666,20 @@ }, { "name": "psr/container", - "version": "1.1.1", + "version": "1.1.2", "source": { "type": "git", "url": "https://github.com/php-fig/container.git", - "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf" + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf", - "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf", + "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": ">=7.4.0" }, "type": "library", "autoload": { @@ -2741,9 +2708,9 @@ ], "support": { "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.1" + "source": "https://github.com/php-fig/container/tree/1.1.2" }, - "time": "2021-03-05T17:36:06+00:00" + "time": "2021-11-05T16:50:12+00:00" }, { "name": "roave/security-advisories", @@ -2751,12 +2718,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "16706d82a6f250e56047a9e95791a92a8a29f791" + "reference": "b7bf7ec4104a3c4699d234d38b6ede3f6195c88a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/16706d82a6f250e56047a9e95791a92a8a29f791", - "reference": "16706d82a6f250e56047a9e95791a92a8a29f791", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/b7bf7ec4104a3c4699d234d38b6ede3f6195c88a", + "reference": "b7bf7ec4104a3c4699d234d38b6ede3f6195c88a", "shasum": "" }, "conflict": { @@ -2871,13 +2838,13 @@ "cesnet/simplesamlphp-module-proxystatistics": "<3.1", "chriskacerguis/codeigniter-restserver": "<=2.7.1", "chrome-php/chrome": "<1.14", - "ci4-cms-erp/ci4ms": "<=0.31.7", + "ci4-cms-erp/ci4ms": "<=0.31.8", "civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3", "ckeditor/ckeditor": "<4.25", "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", - "cockpit-hq/cockpit": "<2.14", - "code16/sharp": "<9.20", + "cockpit-hq/cockpit": "<=2.14", + "code16/sharp": "<9.22", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.6.2", @@ -3002,6 +2969,7 @@ "erusev/parsedown": "<1.7.2", "ether/logs": "<3.0.4", "evolutioncms/evolution": "<=3.2.3", + "evoweb/sf-register": "<13.2.4|>=14,<14.0.2", "exceedone/exment": "<4.4.3|>=5,<5.0.3", "exceedone/laravel-admin": "<2.2.3|==3", "ezsystems/demobundle": ">=5.4,<5.4.6.1-dev", @@ -3066,6 +3034,7 @@ "friendsofsymfony1/symfony1": ">=1.1,<1.5.19", "friendsoftypo3/mediace": ">=7.6.2,<7.6.5", "friendsoftypo3/openid": ">=4.5,<4.5.31|>=4.7,<4.7.16|>=6,<6.0.11|>=6.1,<6.1.6", + "friendsoftypo3/tt-address": "<8.1.2|>=9,<9.1.1|>=10,<10.0.1", "froala/wysiwyg-editor": "<=4.3", "frosh/adminer-platform": "<2.2.1", "froxlor/froxlor": "<2.3.6", @@ -3074,13 +3043,13 @@ "funadmin/funadmin": "<=7.1.0.0-RC6", "gaoming13/wechat-php-sdk": "<=1.10.2", "genix/cms": "<=1.1.11", - "georgringer/news": "<1.3.3", + "georgringer/news": "<11.4.4|>=12,<12.3.2|>=13,<13.0.2|>=14,<14.0.3", "geshi/geshi": "<=1.0.9.1", "getformwork/formwork": "<=2.3.3", "getgrav/grav": "<=2.0.0.0-RC1", "getgrav/grav-plugin-api": "<1.0.0.0-beta15", "getgrav/grav-plugin-form": "<9.1", - "getkirby/cms": "<4.9|>=5,<5.4", + "getkirby/cms": "<=4.9|>=5,<=5.4", "getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1", "getkirby/panel": "<2.5.14", "getkirby/starterkit": "<=3.7.0.2", @@ -3182,13 +3151,14 @@ "kimai/kimai": "<=2.55", "kitodo/presentation": "<3.2.3|>=3.3,<3.3.4", "klaviyo/magento2-extension": ">=1,<3", - "knplabs/knp-snappy": "<=1.4.2", + "knplabs/knp-snappy": "<=1.7", "kohana/core": "<3.3.3", "koillection/koillection": "<1.6.12", "krayin/laravel-crm": "<=2.2", "kreait/firebase-php": ">=3.2,<3.8.1", "kumbiaphp/kumbiapp": "<=1.1.1", "la-haute-societe/tcpdf": "<6.2.22", + "laktak/hjson": "<2.3", "laminas/laminas-diactoros": "<2.18.1|==2.19|==2.20|==2.21|==2.22|==2.23|>=2.24,<2.24.2|>=2.25,<2.25.2", "laminas/laminas-form": "<2.17.1|>=3,<3.0.2|>=3.1,<3.1.1", "laminas/laminas-http": "<2.14.2", @@ -3273,6 +3243,7 @@ "miraheze/ts-portal": "<=33", "mittwald/typo3_forum": "<1.2.1", "mix/mix": ">=2,<=2.2.17", + "mmc/ceselector": "<3.0.3|>=4,<4.0.2|>=5,<5.0.1|>=6,<6.0.1", "mobiledetect/mobiledetectlib": "<2.8.32", "modx/revolution": "<=3.1", "mojo42/jirafeau": "<4.4", @@ -3376,7 +3347,7 @@ "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", "phpmyadmin/phpmyadmin": "<5.2.2", - "phpmyfaq/phpmyfaq": "<=4.1.1", + "phpmyfaq/phpmyfaq": "<4.1.3", "phpoffice/common": "<0.2.9", "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", @@ -3391,14 +3362,14 @@ "phpxmlrpc/phpxmlrpc": "<4.9.2", "phraseanet/phraseanet": "==4.0.3", "pi/pi": "<=2.5", - "pimcore/admin-ui-classic-bundle": "<=1.7.15|>=2.0.0.0-RC1-dev,<=2.2.2", + "pimcore/admin-ui-classic-bundle": "<=2.3.5", "pimcore/customer-management-framework-bundle": "<4.2.1", "pimcore/data-hub": "<1.2.4", "pimcore/data-importer": "<1.8.9|>=1.9,<1.9.3", "pimcore/demo": "<10.3", "pimcore/ecommerce-framework-bundle": "<1.0.10", "pimcore/perspective-editor": "<1.5.1", - "pimcore/pimcore": "<=11.5.14.1|>=12,<12.3.3|==12.3.3", + "pimcore/pimcore": "<=12.3.5", "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1", "piwik/piwik": "<1.11", "pixelfed/pixelfed": "<0.12.5", @@ -3424,7 +3395,7 @@ "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7", "propel/propel1": ">=1,<=1.7.1", "psy/psysh": "<=0.11.22|>=0.12,<=0.12.18", - "pterodactyl/panel": "<1.12.1", + "pterodactyl/panel": "<1.12.3", "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", "ptrofimov/beanstalk_console": "<1.7.14", "pubnub/pubnub": "<6.1", @@ -3467,9 +3438,11 @@ "scheb/two-factor-bundle": "<3.26|>=4,<4.11", "sensiolabs/connect": "<4.2.3", "serluck/phpwhois": "<=4.2.6", - "setasign/fpdi": "<2.6.4", + "setasign/fpdi": "<2.6.7", "sfroemken/url_redirect": "<=1.2.1", "sheng/yiicms": "<1.2.1", + "shopper/cart": "<2.8", + "shopper/framework": "<2.8", "shopware/core": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev", "shopware/platform": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev", "shopware/production": "<=6.3.5.2", @@ -3501,6 +3474,7 @@ "simplesamlphp/saml2": "<=4.16.15|>=5.0.0.0-alpha1,<=5.0.0.0-alpha19", "simplesamlphp/saml2-legacy": "<=4.16.15", "simplesamlphp/simplesamlphp": "<1.18.6", + "simplesamlphp/simplesamlphp-module-casserver": "<=7.0.2", "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", "simplesamlphp/simplesamlphp-module-openid": "<1", "simplesamlphp/simplesamlphp-module-openidprovider": "<0.9", @@ -3522,6 +3496,7 @@ "soosyze/soosyze": "<=2", "spatie/browsershot": "<5.0.5", "spatie/image-optimizer": "<1.7.3", + "spatie/schema-org": ">=3.23.1,<4.0.2", "spencer14420/sp-php-email-handler": "<1", "spipu/html2pdf": "<5.2.8", "spiral/roadrunner": "<2025.1", @@ -3533,14 +3508,14 @@ "starcitizentools/short-description": ">=4,<4.0.1", "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", "starcitizenwiki/embedvideo": "<=4", - "statamic/cms": "<5.73.21|>=6,<6.15", + "statamic/cms": "<5.73.22|>=6,<6.18.1", "stormpath/sdk": "<9.9.99", "studio-42/elfinder": "<=2.1.67", "studiomitte/friendlycaptcha": "<0.1.4", "subhh/libconnect": "<7.0.8|>=8,<8.1", "sukohi/surpass": "<1", "sulu/form-bundle": ">=2,<2.5.3", - "sulu/sulu": "<2.6.22|>=3,<3.0.5", + "sulu/sulu": "<=2.6.22|>=3,<=3.0.5", "sumocoders/framework-user-bundle": "<1.4", "superbig/craft-audit": "<3.0.2", "svewap/a21glossary": "<=0.4.10", @@ -3558,42 +3533,53 @@ "symbiote/silverstripe-seed": "<6.0.3", "symbiote/silverstripe-versionedfiles": "<=2.0.3", "symfont/process": ">=0", - "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", + "symfony/cache": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/dom-crawler": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4", "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1", "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=5.3.14,<5.3.15|>=5.4.3,<5.4.4|>=6.0.3,<6.0.4", - "symfony/http-client": ">=4.3,<5.4.47|>=6,<6.4.15|>=7,<7.1.8", - "symfony/http-foundation": "<5.4.50|>=6,<6.4.29|>=7,<7.3.7", - "symfony/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6", + "symfony/html-sanitizer": ">=6.1,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", + "symfony/http-client": ">=4.3,<5.4.53|>=6,<6.4.15|>=7,<7.1.8", + "symfony/http-foundation": "<5.4.50|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", + "symfony/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6|>=7.4,<7.4.12|>=8,<8.0.12", "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", + "symfony/json-path": ">=7.3,<7.4.12|>=8,<8.0.12", + "symfony/lox24-notifier": ">=7.1,<7.4.12|>=8,<8.0.12", + "symfony/mailer": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/mailjet-mailer": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/mailomat-mailer": ">=7.2,<7.4.13|>=8,<8.0.13", + "symfony/mailtrap-mailer": ">=7.2,<7.4.12|>=8,<8.0.12", "symfony/maker-bundle": ">=1.27,<1.29.2|>=1.30,<1.31.1", - "symfony/mime": ">=4.3,<4.3.8", + "symfony/mime": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/monolog-bridge": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", - "symfony/polyfill": ">=1,<1.10", + "symfony/polyfill": ">=1,<1.10|>=1.17.1,<1.38.1", + "symfony/polyfill-intl-idn": ">=1.17.1,<1.38.1", "symfony/polyfill-php55": ">=1,<1.10", "symfony/process": "<5.4.51|>=6,<6.4.33|>=7,<7.1.7|>=7.3,<7.3.11|>=7.4,<7.4.5|>=8,<8.0.5", "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", - "symfony/routing": ">=2,<2.0.19", - "symfony/runtime": ">=5.3,<5.4.46|>=6,<6.4.14|>=7,<7.1.7", + "symfony/routing": ">=2,<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", + "symfony/runtime": ">=5.3,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/security": ">=2,<2.7.51|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.8", "symfony/security-bundle": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.4.10|>=7,<7.0.10|>=7.1,<7.1.3", "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.9", "symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", "symfony/security-guard": ">=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", - "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7|>=5.1,<5.2.8|>=5.3,<5.4.47|>=6,<6.4.15|>=7,<7.1.8", + "symfony/security-http": ">=2,<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", "symfony/serializer": ">=2,<2.0.11|>=4.1,<4.4.35|>=5,<5.3.12", - "symfony/symfony": "<5.4.51|>=6,<6.4.33|>=7,<7.3.11|>=7.4,<7.4.5|>=8,<8.0.5", + "symfony/symfony": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", "symfony/translation": ">=2,<2.0.17", - "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8", + "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8|>=6.4.24,<6.4.40", + "symfony/twilio-notifier": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/ux-autocomplete": "<2.11.2", "symfony/ux-live-component": "<2.25.1", "symfony/ux-twig-component": "<2.25.1", "symfony/validator": "<5.4.43|>=6,<6.4.11|>=7,<7.1.4", "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", - "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4", + "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4|>=7.2.9,<7.4.12|>=8,<8.0.12", "symfony/webhook": ">=6.3,<6.3.8", - "symfony/yaml": ">=2,<2.0.22|>=2.1,<2.1.7|>=2.2.0.0-beta1,<2.2.0.0-beta2", + "symfony/yaml": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symphonycms/symphony-2": "<2.6.4", "t3/dce": "<0.11.5|>=2.2,<2.6.2", "t3g/svg-sanitizer": "<1.0.3", @@ -3607,7 +3593,7 @@ "thelia/thelia": ">=2.1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<6.0.8", - "thorsten/phpmyfaq": "<=4.1.1", + "thorsten/phpmyfaq": "<4.1.3", "tikiwiki/tiki-manager": "<=17.1", "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", "tinymce/tinymce": "<7.2", @@ -3615,16 +3601,20 @@ "titon/framework": "<9.9.99", "tltneon/lgsl": "<7", "tobiasbg/tablepress": "<=2.0.0.0-RC1", + "tomasnorre/crawler": "<11.0.13|>=12,<12.0.11", "topthink/framework": "<6.0.17|>=6.1,<=8.0.4", "topthink/think": "<=6.1.1", "topthink/thinkphp": "<=3.2.3|>=6.1.3,<=8.0.4", "torrentpier/torrentpier": "<=2.8.8", - "tpwd/ke_search": "<4.0.3|>=4.1,<4.6.6|>=5,<5.0.2", + "tpwd/ke_search": "<5.6.2|>=6,<6.6.1|>=7,<7.0.1", "tribalsystems/zenario": "<=9.7.61188", "truckersmp/phpwhois": "<=4.3.1", "ttskch/pagination-service-provider": "<1", "twbs/bootstrap": "<3.4.1|>=4,<4.3.1", - "twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19", + "twig/cssinliner-extra": "<3.26", + "twig/intl-extra": "<3.26", + "twig/markdown-extra": "<3.26", + "twig/twig": "<3.26", "typicms/core": "<16.1.7", "typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1|==14.2", @@ -3666,7 +3656,7 @@ "uvdesk/core-framework": "<=1.1.1", "vanilla/safecurl": "<0.9.2", "verbb/comments": "<1.5.5", - "verbb/formie": "<=2.1.43", + "verbb/formie": "<2.2.20|>=3.0.0.0-beta1,<3.1.24", "verbb/image-resizer": "<2.0.9", "verbb/knock-knock": "<1.2.8", "verot/class.upload.php": "<=2.1.6", @@ -3714,7 +3704,7 @@ "xpressengine/xpressengine": "<3.0.15", "yab/quarx": "<2.4.5", "yansongda/pay": "<=3.7.19", - "yeswiki/yeswiki": "<=4.6", + "yeswiki/yeswiki": "<4.6.4", "yetiforce/yetiforce-crm": "<6.5", "yidashi/yii2cmf": "<=2", "yii2mod/yii2-cms": "<1.9.2", @@ -3808,7 +3798,7 @@ "type": "tidelift" } ], - "time": "2026-05-14T13:38:25+00:00" + "time": "2026-05-27T11:16:21+00:00" }, { "name": "sebastian/cli-parser", @@ -3920,6 +3910,7 @@ "type": "github" } ], + "abandoned": true, "time": "2020-10-26T13:08:54+00:00" }, { @@ -3975,6 +3966,7 @@ "type": "github" } ], + "abandoned": true, "time": "2020-09-28T05:30:19+00:00" }, { @@ -5267,16 +5259,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { @@ -5328,7 +5320,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -5348,7 +5340,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php73", @@ -5833,11 +5825,11 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=7.3" + "php": ">=7.4" }, "platform-dev": {}, "platform-overrides": { - "php": "7.3.0" + "php": "7.4" }, "plugin-api-version": "2.9.0" } diff --git a/pdf.php b/pdf.php index 5770e4f3e..ac5987bd9 100644 --- a/pdf.php +++ b/pdf.php @@ -10,7 +10,7 @@ Text Domain: gravity-pdf Domain Path: /languages Requires at least: 5.3 -Requires PHP: 7.3 +Requires PHP: 7.4 License: GPL-2.0 License URI: https://opensource.org/licenses/gpl-2.0.php */ @@ -113,7 +113,7 @@ class GFPDF_Major_Compatibility_Checks { * * @since 4.0 */ - public $required_php_version = '7.3'; + public $required_php_version = '7.4'; /** * Set our required variables for a fallback and attempt to initialise diff --git a/src/View/View_Form_Settings.php b/src/View/View_Form_Settings.php index e379cba2f..a1dbda5b7 100644 --- a/src/View/View_Form_Settings.php +++ b/src/View/View_Form_Settings.php @@ -33,9 +33,32 @@ class View_Form_Settings extends Helper_Abstract_View { public function add_edit( $vars ) { - $markup = new View_GravityForm_Settings_Markup(); + $markup = new View_GravityForm_Settings_Markup(); + $sections = $this->get_sections( $markup ); - $sections = [ + $vars = array_merge( + $vars, + [ + 'callback' => static function () use ( $markup, $sections ) { + $markup->do_settings_sections( $sections, true ); + }, + ] + ); + + $this->load( 'add_edit', $vars ); + } + + /** + * Build the form-settings sections rendered inside the add/edit page. + * + * @param View_GravityForm_Settings_Markup $markup The markup helper bound to each section callback. + * + * @return array> + * + * @since 7.0 + */ + public function get_sections( View_GravityForm_Settings_Markup $markup ): array { + return [ [ 'id' => 'gfpdf_form_settings_general', 'width' => 'full', @@ -84,16 +107,5 @@ public function add_edit( $vars ) { 'collapsible-open' => true, ], ]; - - $vars = array_merge( - $vars, - [ - 'callback' => static function () use ( $markup, $sections ) { - $markup->do_settings_sections( $sections, true ); - }, - ] - ); - - $this->load( 'add_edit', $vars ); } } diff --git a/tests/phpunit/Concerns/HasGfpdfFixtures.php b/tests/phpunit/Concerns/HasGfpdfFixtures.php new file mode 100644 index 000000000..547ea2450 --- /dev/null +++ b/tests/phpunit/Concerns/HasGfpdfFixtures.php @@ -0,0 +1,238 @@ +form() / $this->entry() / $this->entries(), + * and inherits cleanup via tear_down_after_class() (wired in TestCase / AjaxTestCase). + */ +trait HasGfpdfFixtures { + + /** + * Form key → entry-fixture filename. The original bootstrap mixed + * -entries.json and -entry.json suffixes, so a map is the source of truth. + */ + private static $entry_filenames = [ + 'all-form-fields' => 'all-form-fields-entries.json', + 'gravityform-1' => 'gravityform-1-entries.json', + 'repeater-empty-form' => 'repeater-empty-entry.json', + 'repeater-consent-form' => 'repeater-consent-entry.json', + 'non-group-products-form' => 'non-group-products-form-entries.json', + ]; + + /** + * Per-class fixture cache, keyed by class name (late static binding). + * + * Shape: [ 'Test_Foo' => [ 'forms' => [ key => array ], 'entries' => [ key => array[] ] ] ]. + * + * Protected (not private) so subclasses can patch entries after load_fixtures + * — e.g. Test_Form_Data rewrites file-upload URLs to match the per-class form's + * upload directory before tests run. + */ + protected static $fixture_caches = []; + + /** + * Shared GF_UnitTest_Factory used by load_fixtures(). The factory + * constructor reads tools/phpunit/data/forms/standard.json from disk; caching + * the instance avoids repeating that for every class that loads fixtures. + */ + private static $shared_gf_factory; + + /** + * Class-scoped fixture loader. Call from set_up_before_class(). + * + * @param string[] $forms Form keys; each loads tools/phpunit/data/forms/.json. + * @param string[] $entries Entry-set keys; the parent form must be in $forms + * (entries are created against the just-loaded form's ID). + */ + protected static function load_fixtures( array $forms = [], array $entries = [] ) { + if ( null === self::$shared_gf_factory ) { + self::$shared_gf_factory = new \GF_UnitTest_Factory(); + } + $factory = self::$shared_gf_factory; + $class = static::class; + $cache = self::$fixture_caches[ $class ] ?? [ 'forms' => [], 'entries' => [] ]; + + foreach ( $forms as $key ) { + $cache['forms'][ $key ] = $factory->form->import_fixture_and_get( "$key.json" ); + } + + foreach ( $entries as $key ) { + if ( ! isset( $cache['forms'][ $key ] ) ) { + throw new \LogicException( "Cannot load entry set '$key' before its parent form." ); + } + if ( ! isset( self::$entry_filenames[ $key ] ) ) { + throw new \LogicException( "No entry fixture mapping for '$key'." ); + } + + $cache['entries'][ $key ] = $factory->entry->import_many_and_get( + self::$entry_filenames[ $key ], + $cache['forms'][ $key ]['id'] + ); + } + + self::$fixture_caches[ $class ] = $cache; + } + + /** + * Deletes class-scoped fixtures from the database. Call from tear_down_after_class(). + * + * Forms+entries created by load_fixtures() outlive WP's per-test transaction + * (GFAPI writes go to non-transactional tables), so without this each class + * leaks its fixtures into subsequent classes. + */ + protected static function cleanup_class_fixtures() { + $class = static::class; + if ( ! isset( self::$fixture_caches[ $class ] ) ) { + return; + } + $cache = self::$fixture_caches[ $class ]; + + foreach ( $cache['entries'] as $entries ) { + foreach ( $entries as $entry ) { + \GFAPI::delete_entry( $entry['id'] ); + } + } + foreach ( $cache['forms'] as $form ) { + \GFAPI::delete_form( $form['id'] ); + } + unset( self::$fixture_caches[ $class ] ); + } + + /** + * Returns the form fixture stored under $key (declared via load_fixtures). + * + * @param string $key Form key. + * + * @return array + */ + protected function form( $key ) { + $cache = self::$fixture_caches[ static::class ]['forms'] ?? []; + if ( ! isset( $cache[ $key ] ) ) { + $available = implode( ', ', array_keys( $cache ) ) ?: '(none)'; + $this->fail( "Form fixture '$key' is not loaded. Available in " . static::class . ": $available" ); + } + + return $cache[ $key ]; + } + + /** + * Returns one of the entry fixtures stored under $key. + * + * @param string $key Entry-set key (same key as the parent form). + * @param int $index Zero-based index into the entry list. + * + * @return array + */ + protected function entry( $key, $index = 0 ) { + $cache = self::$fixture_caches[ static::class ]['entries'] ?? []; + if ( ! isset( $cache[ $key ][ $index ] ) ) { + $this->fail( "Entry fixture '$key'[$index] is not loaded in " . static::class . '.' ); + } + + return $cache[ $key ][ $index ]; + } + + /** + * Returns the full entry list for $key (for foreach/array_column use). + * + * @param string $key Entry-set key. + * + * @return array[] + */ + protected function entries( $key ) { + $cache = self::$fixture_caches[ static::class ]['entries'] ?? []; + if ( ! isset( $cache[ $key ] ) ) { + $this->fail( "Entry fixture set '$key' is not loaded in " . static::class . '.' ); + } + + return $cache[ $key ]; + } + + /** Chewy.ttf is owned by font-admin tests (Test_Api) and must not be pre-copied. */ + private static $render_fonts = [ + 'DejaVuSans.ttf', + 'DejaVuSans-Bold.ttf', + 'DejaVuSansCondensed.ttf', + 'DejaVuSerifCondensed.ttf', + ]; + + /** Call from set_up_before_class(); pair with remove_test_fonts() in tear_down_after_class(). */ + protected static function copy_test_fonts() { + global $gfpdf; + foreach ( self::$render_fonts as $font ) { + @copy( + PDF_PLUGIN_DIR . '/tools/phpunit/data/fonts/' . $font, + $gfpdf->data->template_font_location . $font + ); + } + } + + protected static function remove_test_fonts() { + global $gfpdf; + foreach ( self::$render_fonts as $font ) { + @unlink( $gfpdf->data->template_font_location . $font ); + } + } + + /** + * Returns the Gravity PDF Router (DI container). + * + * @return \GFPDF\Router + */ + protected function gfpdf() { + global $gfpdf; + + return $gfpdf; + } + + /** + * Returns the first field with the given type from a fixture form. + * + * For predicates beyond a plain type match (Likert's inputType/inputs, + * Fileupload's multipleFiles, lookup-by-id) keep the loop inline — this + * helper deliberately only covers the type-only case. + * + * @param string $type Field type, e.g. 'phone', 'address'. + * @param string $key Fixture key (must be declared via load_fixtures()). + * + * @return \GF_Field + */ + protected function field_from_fixture( $type, $key = 'all-form-fields' ) { + foreach ( $this->form( $key )['fields'] as $field ) { + if ( $field->type === $type ) { + return $field; + } + } + + $this->fail( "No field of type '$type' found in fixture '$key'." ); + } + + /** + * Loads a fixture form + first entry and wires the form's PDF settings into + * $gfpdf->data->form_settings. Pre-clears form_settings so prior tests in the + * class can't bleed in. + * + * @param string $key Fixture key (must be declared via load_fixtures()). + * + * @return array{form: array, entry: array} + */ + protected function form_and_entry( $key = 'all-form-fields' ) { + $form = $this->form( $key ); + $entry = $this->entry( $key ); + + $gfpdf = $this->gfpdf(); + $gfpdf->data->form_settings = []; + $gfpdf->data->form_settings[ $form['id'] ] = $form['gfpdf_form_settings']; + + return [ + 'form' => $form, + 'entry' => $entry, + ]; + } +} diff --git a/tests/phpunit/Concerns/UsesFactory.php b/tests/phpunit/Concerns/UsesFactory.php new file mode 100644 index 000000000..40f73b45f --- /dev/null +++ b/tests/phpunit/Concerns/UsesFactory.php @@ -0,0 +1,25 @@ +gfpdf_factory ) { + $this->gfpdf_factory = new GF_UnitTest_Factory(); + } + + return $this->gfpdf_factory; + } +} diff --git a/tests/phpunit/README.md b/tests/phpunit/README.md new file mode 100644 index 000000000..1cf19a51b --- /dev/null +++ b/tests/phpunit/README.md @@ -0,0 +1,230 @@ +# Gravity PDF — PHPUnit Test Suite + +This directory holds the plugin's PHPUnit integration tests, run inside the +`wp-env` Docker container via `yarn test:php` / `yarn test:php:multisite`. + +## Layout + +``` +tests/phpunit/ +├── README.md ← you are here +├── Concerns/ ← shared traits (NOT discovered by PHPUnit) +│ ├── HasGfpdfFixtures.php +│ └── UsesFactory.php +└── integration/ ← mirrors src/ 1:1 + ├── TestCase.php + ├── AjaxTestCase.php + ├── Controller/ + ├── Exceptions/ + ├── Helper/ ← nests Fields/, Fonts/, Licensing/, Log/, Mpdf/ + ├── Model/ + ├── Rest/ + ├── Statics/ + └── View/ +``` + +## Naming convention + +| Source file | Test file | +| :--- | :--- | +| `src/Statics/Cache.php` | `tests/phpunit/integration/Statics/Test_Cache.php` | +| `src/Model/Model_PDF.php` | `tests/phpunit/integration/Model/Test_Model_PDF.php` | +| `src/Controller/Controller_Settings.php` | `tests/phpunit/integration/Controller/Test_Controller_Settings.php` | + +One `Test_.php` per non-trivial `src/` class, at the matching path. +Class name = `Test_`; method names use `test_` snake_case to keep +`phpunit --filter test_something` searches predictable. + +## Base class + +| Need | Base class | +| :--- | :--- | +| Standard integration test | `\GFPDF\Tests\Integration\TestCase` | +| Test that dispatches a `wp_ajax_*` action via `_handleAjax()` | `\GFPDF\Tests\Integration\AjaxTestCase` | + +Both extend the WordPress stock test cases and `use` two traits: + +`\GFPDF\Tests\Concerns\HasGfpdfFixtures` provides: +- `static::load_fixtures( [ 'all-form-fields' ], [ 'all-form-fields' ] )` — class-scoped + loader. Call from `set_up_before_class()` to declare the forms/entries this + test class needs. Forms+entries are created via the factory once per class + and cleaned up in `tear_down_after_class()`. +- `$this->form( 'all-form-fields' )` — form fixture (per-class cache). +- `$this->entry( 'all-form-fields', 0 )` — entry fixture. +- `$this->gfpdf()` — the `GFPDF\Router` DI container (same as the `$gfpdf` global). + +`\GFPDF\Tests\Concerns\UsesFactory` provides: +- `$this->gf_factory()` — returns the `GF_UnitTest_Factory` (`tools/phpunit/gravityforms-factory.php`). + Use this for per-test forms/entries. Named `gf_factory()` to avoid colliding + with `WP_UnitTestCase::factory()` (the static WP factory accessed via + `self::factory()->user->create()` etc.). + +## Writing a new test + +```php +form( 'all-form-fields' ); + $entry = $this->entry( 'all-form-fields' ); + + $this->assertSame( + Cache::get_hash( $form, $entry, [] ), + Cache::get_hash( $form, $entry, [] ) + ); + } +} +``` + +Conventions: + +- `declare(strict_types=1);` — required. +- Namespace matches the class under test (`GFPDF\Statics`, `GFPDF\Model`, etc.). +- `@group` annotation — every test class should have at least one (e.g. `controller`, + `model`, `helper`, `ajax`) so contributors can run a slice. + +## Fixtures + +A *fixture* is a JSON snapshot of a Gravity Forms form (plus optional sample +entries) that the suite imports into the database for the lifetime of a test +class. + +### What a fixture file contains + +**Form JSON** (`tools/phpunit/data/forms/.json`) — a single Gravity Forms +form object: top-level `id`, `title`, `fields[]`, `notifications`, +`confirmations`, plus the Gravity PDF-specific `gfpdf_form_settings` map +keyed by PDF ID (this is where `template`, `filename`, `conditional` etc. +live for each PDF the form publishes). Imported via the factory's +`import_fixture_and_get( "$key.json" )`. The DB gets a fresh row with a new +auto-increment `id` — your test reads the live form back from +`$this->form( $key )`. + +**Entry JSON** (`tools/phpunit/data/entries/.json`) — a JSON array of +entry rows. Each row is a flat associative array keyed by field-input ID +(`'1.3' => 'Jane'`, `'4' => 'jane@example.org'`) plus the meta columns +Gravity Forms expects (`form_id`, `date_created`, `currency`, `ip`, +`payment_status`, etc.). `form_id` is overwritten at import time with the +just-created form's ID, so the file's stored `form_id` is irrelevant. + +### Where the data lives + +``` +tools/phpunit/data/ +├── forms/ ← .json — one form per file +├── entries/ ← .json — JSON array of entries for one form +├── fonts/ ← TTF/OTF used by render tests +├── images/ ← image attachments referenced from fixture entries +└── pdf/ ← reference PDFs for byte-compare tests +``` + +Form-key → entry-filename mapping lives in `HasGfpdfFixtures::$entry_filenames` +(the historical bootstrap mixed `-entries.json` and `-entry.json` suffixes, so +the map is the source of truth — your form key and entry filename do not need +to match). + +### Available keys + +| Key | Entries | What it represents | +| :--- | :---: | :--- | +| `all-form-fields` | 7 | Every field type GF and its add-ons ship (56 fields: address, checkbox, fileupload, list, poll, quiz, signature, survey, product, …) with 4 PDFs configured. The default fixture for render / field-output coverage. | +| `form-settings` | — | Small form (7 fields) with 3 PDFs configured but no entries; for PDF form-settings CRUD and UI tests where entry data is irrelevant. | +| `gravityform-1` | 3 | Plain contact-style form (name / email / phone / address / textarea, no PDFs configured) with 3 sample submissions; for tests that need form+entry data without PDF config. | +| `non-group-products-form` | 1 | Products form whose Product fields sit outside an Option group (regression fixture for issue #1418). | +| `repeater-empty-form` | 1 | Form with a Repeater field whose sample entry has zero child rows; exercises the empty-repeater render path. | +| `repeater-consent-form` | 1 | Form with a Consent field nested inside a Repeater; exercises consent-in-repeater output. | + +### Reading fixtures from a test + +After `static::load_fixtures( [ 'foo' ], [ 'foo' ] )` runs in +`set_up_before_class()` (see [Writing a new test](#writing-a-new-test) for the +full skeleton), any test method can access them via: + +```php +$this->form( 'foo' ); // form array +$this->entry( 'foo' ); // first entry (index 0) +$this->entry( 'foo', 2 ); // entry by index +$this->entries( 'foo' ); // all entries +``` + +`load_fixtures()` caches per-class and is cleaned up by the base +`tear_down_after_class()`. An entry key must have its parent form key in the +same call — loading `entries: [ 'foo' ]` without `forms: [ 'foo' ]` throws. + +### Adding a new fixture + +1. **Export the form from Gravity Forms** (Forms → Import/Export → Export Forms, + single form). Drop the JSON into `tools/phpunit/data/forms/.json` — + strip the outer array wrapper if present so the file is a single form + object, not `[ { ... } ]`. +2. **(Optional) add entries**: write or export a JSON array of entry rows into + `tools/phpunit/data/entries/-entries.json`. If the filename doesn't + match `-entries.json` or `-entry.json`, add a row to + `HasGfpdfFixtures::$entry_filenames` mapping form key → filename. +3. **Reference it** from `set_up_before_class()`: + `static::load_fixtures( [ 'mykey' ], [ 'mykey' ] )`. +4. Update the "Available keys" table above. + +Sanity-check the wiring with `yarn test:php -- --filter Test_Fixtures_Loader` — +that's the infrastructure self-test for `load_fixtures`. + +### Per-test forms/entries + +For one-off forms or entries that only one test needs, use the factory directly +— **never call `GFAPI::add_form()` / `GFAPI::add_entry()` from a test body**: + +```php +$form_id = $this->gf_factory()->form->create( [], $form ); +$entry['form_id'] = $form_id; +$entry_id = $this->gf_factory()->entry->create( $entry ); +``` + +Prefer class-scoped via `load_fixtures()` when more than one test in the class +needs the same data; per-test only when the test mutates the fixture. + +### Font fixtures for real mPDF renders + +Render tests that exercise mPDF need the test TTFs copied into the active fonts +directory. Use `HasGfpdfFixtures::copy_test_fonts()` / `remove_test_fonts()` +from `set_up_before_class()` / `tear_down_after_class()`. See `Model/Test_PDF.php` +and `Controller/Test_Controller_PDF.php` for usage. + +## The "test if non-trivial" rule + +Skip a `Test_*.php` for a class that is: +1. Under 30 lines of code, **and** +2. Has no methods of its own (only inherited), **and** +3. Has no constructor logic. + +The 11 classes under `src/Exceptions/` are covered by a single +`integration/Exceptions/Test_Exception_Hierarchy.php` smoke test, not 11 +individual files. + +## Running + +```bash +yarn wp-env:integration start # one-time per session +yarn test:php # full suite +yarn test:php -- --filter Test_Cache # single class +yarn test:php -- --group statics # group +yarn test:php:multisite # WP multisite mode +``` diff --git a/tests/phpunit/integration/AjaxTestCase.php b/tests/phpunit/integration/AjaxTestCase.php new file mode 100644 index 000000000..829c5ecd4 --- /dev/null +++ b/tests/phpunit/integration/AjaxTestCase.php @@ -0,0 +1,37 @@ +gfpdf()->data->form_settings = []; + delete_transient( 'gfpdf_settings_user_data' ); + } + + public static function tear_down_after_class(): void { + static::cleanup_class_fixtures(); + parent::tear_down_after_class(); + } +} diff --git a/tests/phpunit/integration/Controller/Test_Controller_Actions.php b/tests/phpunit/integration/Controller/Test_Controller_Actions.php new file mode 100644 index 000000000..beb4cddcb --- /dev/null +++ b/tests/phpunit/integration/Controller/Test_Controller_Actions.php @@ -0,0 +1,119 @@ +controller = $gfpdf->singleton->get_class( 'Controller_Actions' ); + } + + public function tear_down(): void { + unset( $_POST['gfpdf_action'], $_POST['gfpdf-dismiss-notice'], $_GET['page'] ); + + parent::tear_down(); + } + + public function test_init_registers_admin_init_hooks() { + remove_all_actions( 'admin_init' ); + + $this->controller->init(); + + $this->assertNotFalse( has_action( 'admin_init', [ $this->controller, 'route' ] ) ); + $this->assertNotFalse( has_action( 'admin_init', [ $this->controller, 'route_notices' ] ) ); + } + + public function test_get_routes_includes_default_core_fonts_route() { + $routes = $this->controller->get_routes(); + + $this->assertCount( 1, $routes ); + $this->assertSame( 'install_core_fonts', $routes[0]['action'] ); + $this->assertSame( 'gravityforms_edit_settings', $routes[0]['capability'] ); + $this->assertIsCallable( $routes[0]['condition'] ); + $this->assertIsCallable( $routes[0]['process'] ); + $this->assertIsCallable( $routes[0]['view'] ); + } + + public function test_get_routes_is_filterable() { + add_filter( + 'gfpdf_one_time_action_routes', + static function ( $routes ) { + $routes[] = [ 'action' => 'custom' ]; + + return $routes; + } + ); + + $routes = $this->controller->get_routes(); + remove_all_filters( 'gfpdf_one_time_action_routes' ); + + $this->assertCount( 2, $routes ); + $this->assertSame( 'custom', $routes[1]['action'] ); + } + + public function test_route_notices_short_circuits_on_getting_started_page() { + global $gfpdf; + + $gfpdf->notices->clear(); + $_GET['page'] = 'gfpdf-getting-started'; + set_current_screen( 'gf_settings' ); + + $this->controller->route_notices(); + + $this->assertFalse( $gfpdf->notices->has_notice() ); + } + + public function test_route_dismisses_notice_when_dismiss_flag_set() { + global $gfpdf; + + $admin = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin ); + set_current_screen( 'edit.php' ); + + add_filter( + 'gfpdf_one_time_action_routes', + static function () { + return [ + [ + 'action' => 'always_true', + 'action_text' => 'Always', + 'condition' => '__return_true', + 'process' => static function () {}, + 'view' => static function () { return ''; }, + 'capability' => 'gravityforms_edit_settings', + ], + ]; + } + ); + + $_POST['gfpdf_action'] = 'gfpdf_always_true'; + $_POST['gfpdf_action_always_true'] = wp_create_nonce( 'gfpdf_action_always_true' ); + $_POST['gfpdf-dismiss-notice'] = '1'; + + $model = $gfpdf->singleton->get_class( 'Model_Actions' ); + $this->controller->route(); + + remove_all_filters( 'gfpdf_one_time_action_routes' ); + + $this->assertTrue( $model->is_notice_already_dismissed( 'always_true' ) ); + } +} diff --git a/tests/phpunit/integration/Controller/Test_Controller_Activation.php b/tests/phpunit/integration/Controller/Test_Controller_Activation.php new file mode 100644 index 000000000..9f2b5274f --- /dev/null +++ b/tests/phpunit/integration/Controller/Test_Controller_Activation.php @@ -0,0 +1,79 @@ +assertFalse( wp_next_scheduled( 'gfpdf_cleanup_tmp_dir' ) ); + $this->assertFalse( wp_next_scheduled( 'gfpdf_network_update_check' ) ); + $this->assertFalse( wp_next_scheduled( 'gfpdf_bulk_license_check' ) ); + } + + public function test_deactivation_removes_plugin_rewrite_rules() { + global $gfpdf; + + $rules = [ + '^' . $gfpdf->data->permalink => 'index.php?gpdf=1', + '^some/other/rule' => 'index.php?other=1', + ]; + update_option( 'rewrite_rules', $rules ); + + Controller_Activation::deactivation(); + + $updated = get_option( 'rewrite_rules' ); + $this->assertArrayNotHasKey( '^' . $gfpdf->data->permalink, $updated ); + $this->assertArrayHasKey( '^some/other/rule', $updated ); + } + + public function test_deactivation_leaves_rewrite_rules_when_no_plugin_rules_present() { + $rules = [ '^some/other/rule' => 'index.php?other=1' ]; + update_option( 'rewrite_rules', $rules ); + + Controller_Activation::deactivation(); + + $this->assertSame( $rules, get_option( 'rewrite_rules' ) ); + } + + public function test_deactivation_flushes_template_transient_cache() { + global $gfpdf; + + set_transient( $gfpdf->data->template_transient_cache, 'cached', HOUR_IN_SECONDS ); + + Controller_Activation::deactivation(); + + $this->assertFalse( get_transient( $gfpdf->data->template_transient_cache ) ); + } +} diff --git a/tests/phpunit/unit-tests/Controller/Test_Controller_Custom_Fonts.php b/tests/phpunit/integration/Controller/Test_Controller_Custom_Fonts.php similarity index 91% rename from tests/phpunit/unit-tests/Controller/Test_Controller_Custom_Fonts.php rename to tests/phpunit/integration/Controller/Test_Controller_Custom_Fonts.php index 0fc151d97..cb2bb18fe 100644 --- a/tests/phpunit/unit-tests/Controller/Test_Controller_Custom_Fonts.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Custom_Fonts.php @@ -8,7 +8,7 @@ use GFPDF\Model\Model_Custom_Fonts; use GPDFAPI; use WP_REST_Request; -use WP_UnitTestCase; +use GFPDF\Tests\Integration\TestCase; /** * @package Gravity PDF @@ -24,7 +24,7 @@ * @group controller * @group fonts */ -class Test_Controller_Custom_Fonts extends WP_UnitTestCase { +class Test_Controller_Custom_Fonts extends TestCase { /** * @var Controller_Custom_Fonts @@ -41,13 +41,42 @@ class Test_Controller_Custom_Fonts extends WP_UnitTestCase { */ protected $tmp_font_location; - protected $test_fonts = []; + /** Shared across the class; LocalFilesystem copies these into the plugin font dir, never mutating the source. */ + protected static $test_fonts = []; protected $admin_user; protected $editor_user; - public function set_up() { + public static function set_up_before_class(): void { + parent::set_up_before_class(); + + $fonts = [ + 'DejaVuSans.ttf', + 'DejaVuSans-Bold.ttf', + 'DejaVuSansCondensed.ttf', + 'DejaVuSerifCondensed.ttf', + ]; + + foreach ( $fonts as $font ) { + $tmp_font = get_temp_dir() . $font; + copy( PDF_PLUGIN_DIR . '/tools/phpunit/data/fonts/' . $font, $tmp_font ); + self::$test_fonts[] = $tmp_font; + } + } + + public static function tear_down_after_class(): void { + foreach ( self::$test_fonts as $font ) { + if ( is_file( $font ) ) { + unlink( $font ); + } + } + self::$test_fonts = []; + + parent::tear_down_after_class(); + } + + public function set_up(): void { global $gfpdf; parent::set_up(); @@ -78,35 +107,16 @@ public function set_up() { ] ); - $fonts = [ - 'DejaVuSans.ttf', - 'DejaVuSans-Bold.ttf', - 'DejaVuSansCondensed.ttf', - 'DejaVuSerifCondensed.ttf', - ]; - - foreach ( $fonts as $font ) { - $tmp_font = get_temp_dir() . $font; - copy( PDF_PLUGIN_DIR . '/tools/phpunit/data/fonts/' . $font, $tmp_font ); - $this->test_fonts[] = $tmp_font; - } - error_reporting( E_ALL & ~E_NOTICE ); } - public function tear_down() { + public function tear_down(): void { global $gfpdf; $_FILES = []; $gfpdf->misc->cleanup_dir( $this->tmp_font_location ); - foreach ( $this->test_fonts as $font ) { - if ( is_file( $font ) ) { - unlink( $font ); - } - } - $gfpdf->options->update_option( 'custom_fonts', [] ); parent::tear_down(); @@ -239,7 +249,7 @@ public function test_update_item_success() { GPDFAPI::add_pdf_font( [ 'font_name' => 'Lato', - 'regular' => $this->test_fonts[0], + 'regular' => self::$test_fonts[0], ] ); @@ -421,7 +431,7 @@ public function test_delete_item_with_font_reference_gone_success() { GPDFAPI::add_pdf_font( [ 'font_name' => 'Lato', - 'regular' => $this->test_fonts[0], + 'regular' => self::$test_fonts[0], ] ); @@ -450,34 +460,34 @@ public function test_delete_item_invalid_font_id() { protected function set_all_file_params( WP_REST_Request $request ) { $_FILES = [ 'regular' => [ - 'file' => file_get_contents( $this->test_fonts[0] ), + 'file' => file_get_contents( self::$test_fonts[0] ), 'name' => 'DejaVuSans.ttf', - 'size' => filesize( $this->test_fonts[0] ), - 'tmp_name' => $this->test_fonts[0], + 'size' => filesize( self::$test_fonts[0] ), + 'tmp_name' => self::$test_fonts[0], 'error' => UPLOAD_ERR_OK, ], 'bold' => [ - 'file' => file_get_contents( $this->test_fonts[1] ), + 'file' => file_get_contents( self::$test_fonts[1] ), 'name' => 'DejaVuSans-Bold.ttf', - 'size' => filesize( $this->test_fonts[1] ), - 'tmp_name' => $this->test_fonts[1], + 'size' => filesize( self::$test_fonts[1] ), + 'tmp_name' => self::$test_fonts[1], 'error' => UPLOAD_ERR_OK, ], 'italics' => [ - 'file' => file_get_contents( $this->test_fonts[2] ), + 'file' => file_get_contents( self::$test_fonts[2] ), 'name' => 'DejaVuSansCondensed.ttf', - 'size' => filesize( $this->test_fonts[2] ), - 'tmp_name' => $this->test_fonts[2], + 'size' => filesize( self::$test_fonts[2] ), + 'tmp_name' => self::$test_fonts[2], 'error' => UPLOAD_ERR_OK, ], 'bolditalics' => [ - 'file' => file_get_contents( $this->test_fonts[3] ), + 'file' => file_get_contents( self::$test_fonts[3] ), 'name' => 'DejaVuSerifCondensed.ttf', - 'size' => filesize( $this->test_fonts[3] ), - 'tmp_name' => $this->test_fonts[3], + 'size' => filesize( self::$test_fonts[3] ), + 'tmp_name' => self::$test_fonts[3], 'error' => UPLOAD_ERR_OK, ], ]; diff --git a/tests/phpunit/integration/Controller/Test_Controller_Debug.php b/tests/phpunit/integration/Controller/Test_Controller_Debug.php new file mode 100644 index 000000000..f1f291071 --- /dev/null +++ b/tests/phpunit/integration/Controller/Test_Controller_Debug.php @@ -0,0 +1,96 @@ +controller = new Controller_Debug( $gfpdf->data, $gfpdf->options, $gfpdf->templates ); + } + + public function test_init_registers_hooks() { + remove_all_actions( 'update_option_gfpdf_settings' ); + remove_all_filters( 'gfpdf_mpdf_class' ); + + $this->controller->init(); + + $this->assertNotFalse( has_action( 'update_option_gfpdf_settings', [ $this->controller, 'maybe_flush_transient_cache' ] ) ); + $this->assertNotFalse( has_filter( 'gfpdf_mpdf_class', [ $this->controller, 'maybe_add_pdf_stats' ] ) ); + } + + public function test_maybe_flush_transient_cache_flushes_when_debug_mode_toggled_on() { + global $gfpdf; + + set_transient( $gfpdf->data->template_transient_cache, 'cached', HOUR_IN_SECONDS ); + + $this->controller->maybe_flush_transient_cache( [ 'debug_mode' => 'No' ], [ 'debug_mode' => 'Yes' ] ); + + $this->assertFalse( get_transient( $gfpdf->data->template_transient_cache ) ); + } + + public function test_maybe_flush_transient_cache_skips_when_already_enabled() { + global $gfpdf; + + set_transient( $gfpdf->data->template_transient_cache, 'cached', HOUR_IN_SECONDS ); + + $this->controller->maybe_flush_transient_cache( [ 'debug_mode' => 'Yes' ], [ 'debug_mode' => 'Yes' ] ); + + $this->assertSame( 'cached', get_transient( $gfpdf->data->template_transient_cache ) ); + } + + public function test_maybe_flush_transient_cache_skips_when_debug_mode_absent() { + global $gfpdf; + + set_transient( $gfpdf->data->template_transient_cache, 'cached', HOUR_IN_SECONDS ); + + $this->controller->maybe_flush_transient_cache( [], [] ); + + $this->assertSame( 'cached', get_transient( $gfpdf->data->template_transient_cache ) ); + } + + /** + * @group slow + */ + public function test_maybe_add_pdf_stats_appends_stats_when_debug_mode_on() { + global $gfpdf; + + $gfpdf->options->update_option( 'debug_mode', 'Yes' ); + + $mpdf = new Helper_Mpdf( [ 'mode' => 'c', 'tempDir' => sys_get_temp_dir() ] ); + $this->controller->maybe_add_pdf_stats( $mpdf ); + + $output = $mpdf->Output( '', 'S' ); + $this->assertNotEmpty( $output ); + } + + public function test_maybe_add_pdf_stats_returns_mpdf_unchanged_when_debug_mode_off() { + global $gfpdf; + + $gfpdf->options->update_option( 'debug_mode', 'No' ); + + $mpdf = new Helper_Mpdf( [ 'mode' => 'c', 'tempDir' => sys_get_temp_dir() ] ); + $result = $this->controller->maybe_add_pdf_stats( $mpdf ); + + $this->assertSame( $mpdf, $result ); + } +} diff --git a/tests/phpunit/unit-tests/Controller/Test_Controller_Export_Entries.php b/tests/phpunit/integration/Controller/Test_Controller_Export_Entries.php similarity index 72% rename from tests/phpunit/unit-tests/Controller/Test_Controller_Export_Entries.php rename to tests/phpunit/integration/Controller/Test_Controller_Export_Entries.php index b31be3c56..88a5d6c7f 100644 --- a/tests/phpunit/unit-tests/Controller/Test_Controller_Export_Entries.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Export_Entries.php @@ -1,8 +1,10 @@ form['all-form-fields'] ); + $form = apply_filters( 'gform_export_fields', $this->form( 'all-form-fields' ) ); $field_ids = array_column( $form['fields'], 'id' ); @@ -41,21 +49,21 @@ public function test_get_export_field_unrelated_value() { } public function test_get_export_field_empty_pdf_value_if_failed_conditional_logic() { - $form_id = $GLOBALS['GFPDF_Test']->form['all-form-fields']['id']; - $entry = $GLOBALS['GFPDF_Test']->entries['all-form-fields'][0]; + $form_id = $this->form( 'all-form-fields' )['id']; + $entry = $this->entry( 'all-form-fields' ); $field_id = 'gpdf_555ad84787d7e'; $this->assertEmpty( apply_filters( 'gform_export_field_value', 'item', $form_id, $field_id, $entry ) ); } public function test_get_export_field_pdf_value() { - $form_id = $GLOBALS['GFPDF_Test']->form['all-form-fields']['id']; - $entry = $GLOBALS['GFPDF_Test']->entries['all-form-fields'][0]; + $form_id = $this->form( 'all-form-fields' )['id']; + $entry = $this->entry( 'all-form-fields' ); $field_id = 'gpdf_556690c67856b'; $this->assertStringContainsString( 'http://example.org/?gpdf=1', apply_filters( 'gform_export_field_value', 'item', $form_id, $field_id, $entry ) ); } public function test_get_export_field_empty_value() { - $form_id = $GLOBALS['GFPDF_Test']->form['all-form-fields']['id']; + $form_id = $this->form( 'all-form-fields' )['id']; $field_id = 'gpdf_555ad84787d7e'; $value = 'item'; $this->assertSame( $value, apply_filters( 'gform_export_field_value', $value, $form_id, $field_id, [] ) ); diff --git a/tests/phpunit/integration/Controller/Test_Controller_Form_Settings.php b/tests/phpunit/integration/Controller/Test_Controller_Form_Settings.php new file mode 100644 index 000000000..9eb1b9c9c --- /dev/null +++ b/tests/phpunit/integration/Controller/Test_Controller_Form_Settings.php @@ -0,0 +1,158 @@ +controller = $gfpdf->singleton->get_class( 'Controller_Form_Settings' ); + } + + public function tear_down(): void { + unset( $_GET['id'], $_GET['pid'], $_POST['action'], $_POST['gfpdf_save_pdf'], $_POST['gforms_update_form'] ); + + parent::tear_down(); + } + + public function test_init_registers_action_and_filter_hooks() { + global $gfpdf; + + foreach ( + [ + 'admin_init', + 'gform_form_settings_menu', + 'gform_form_settings_page_' . $gfpdf->data->slug, + 'wp_ajax_gfpdf_list_delete', + 'wp_ajax_gfpdf_list_duplicate', + 'wp_ajax_gfpdf_change_state', + 'wp_ajax_gfpdf_get_template_fields', + ] as $hook + ) { + remove_all_actions( $hook ); + } + + foreach ( + [ + 'gfpdf_form_settings_custom_appearance', + 'gfpdf_form_settings', + 'gfpdf_form_settings_appearance', + 'gfpdf_form_settings_sanitize', + 'tiny_mce_before_init', + 'gform_form_update_meta', + 'gform_rule_source_value', + 'gform_is_value_match', + ] as $hook + ) { + remove_all_filters( $hook ); + } + + $this->controller->init(); + + $this->assertNotFalse( has_action( 'admin_init', [ $this->controller, 'maybe_save_pdf_settings' ] ) ); + $this->assertNotFalse( has_action( 'wp_ajax_gfpdf_list_delete' ) ); + $this->assertNotFalse( has_filter( 'gfpdf_form_settings' ) ); + $this->assertNotFalse( has_filter( 'gform_form_update_meta', [ $this->controller, 'clear_cached_pdf_settings' ] ) ); + $this->assertNotFalse( has_filter( 'tiny_mce_before_init', [ $this->controller, 'store_tinymce_settings' ] ) ); + } + + public function test_store_tinymce_settings_caches_first_call_only() { + global $gfpdf; + + $gfpdf->data->tiny_mce_editor_settings = []; + + $result = $this->controller->store_tinymce_settings( [ 'foo' => 'bar' ] ); + $this->assertSame( [ 'foo' => 'bar' ], $result ); + $this->assertSame( [ 'foo' => 'bar' ], $gfpdf->data->tiny_mce_editor_settings ); + + $second = $this->controller->store_tinymce_settings( [ 'baz' => 'qux' ] ); + $this->assertSame( [ 'baz' => 'qux' ], $second, 'Returns whatever is passed in' ); + $this->assertSame( [ 'foo' => 'bar' ], $gfpdf->data->tiny_mce_editor_settings, 'Cache stays sticky once populated' ); + } + + public function test_clear_cached_pdf_settings_ignores_unrelated_meta() { + $form = [ 'gfpdf_form_settings' => [ 'unchanged' => true ] ]; + + $result = $this->controller->clear_cached_pdf_settings( $form, 1, 'something_else' ); + + $this->assertSame( $form, $result ); + } + + public function test_clear_cached_pdf_settings_ignores_when_no_save_action_posted() { + set_current_screen( 'edit.php' ); + $form = [ 'gfpdf_form_settings' => [ 'unchanged' => true ] ]; + + $result = $this->controller->clear_cached_pdf_settings( $form, 1, 'display_meta' ); + + $this->assertSame( $form, $result ); + } + + public function test_conditional_logic_is_value_match_returns_original_for_unrelated_fields() { + $result = $this->controller->conditional_logic_is_value_match( + false, + '2026-01-01', + '2025-01-01', + '>', + null, + [ 'fieldId' => 'unrelated' ] + ); + + $this->assertFalse( $result, 'unrelated field passes through original $is_match' ); + } + + public function test_conditional_logic_is_value_match_compares_date_created_with_greater_than() { + $result = $this->controller->conditional_logic_is_value_match( + false, + '2026-06-01', + '2026-01-01', + '>', + null, + [ 'fieldId' => 'date_created' ] + ); + + $this->assertTrue( $result ); + } + + public function test_conditional_logic_is_value_match_compares_payment_date_with_less_than() { + $result = $this->controller->conditional_logic_is_value_match( + true, + '2026-01-01', + '2026-06-01', + '<', + null, + [ 'fieldId' => 'payment_date' ] + ); + + $this->assertTrue( $result ); + } + + public function test_conditional_logic_set_rule_source_value_passes_through_when_no_entry() { + $result = $this->controller->conditional_logic_set_rule_source_value( + 'original', + [ 'fieldId' => 'date_created' ], + [ 'id' => 1 ], + [], + null + ); + + $this->assertSame( 'original', $result ); + } +} diff --git a/tests/phpunit/integration/Controller/Test_Controller_Install.php b/tests/phpunit/integration/Controller/Test_Controller_Install.php new file mode 100644 index 000000000..ab801e558 --- /dev/null +++ b/tests/phpunit/integration/Controller/Test_Controller_Install.php @@ -0,0 +1,102 @@ +controller = $gfpdf->singleton->get_class( 'Controller_Install' ); + } + + public function test_init_registers_action_and_filter_hooks() { + remove_all_actions( 'wp_loaded' ); + remove_all_actions( 'init' ); + remove_all_filters( 'query_vars' ); + + $this->controller->init(); + + $this->assertNotFalse( has_action( 'wp_loaded', [ $this->controller, 'check_install_status' ] ) ); + $this->assertNotFalse( has_action( 'init' ) ); + $this->assertNotFalse( has_filter( 'query_vars' ) ); + } + + public function test_setup_defaults_populates_data_object() { + global $gfpdf; + + $gfpdf->data->is_installed = null; + $gfpdf->data->permalink = null; + $gfpdf->data->working_folder = null; + $gfpdf->data->upload_dir = null; + + $this->controller->setup_defaults(); + + $this->assertIsBool( $gfpdf->data->is_installed ); + $this->assertSame( 'pdf/([A-Za-z0-9]+)/([0-9]+)/?(download)?/?', $gfpdf->data->permalink ); + $this->assertNotEmpty( $gfpdf->data->working_folder ); + $this->assertNotEmpty( $gfpdf->data->upload_dir ); + $this->assertSame( 'gfpdf_template_info', $gfpdf->data->template_transient_cache ); + } + + public function test_check_install_status_short_circuits_for_unauthenticated_request() { + wp_set_current_user( 0 ); + set_current_screen( 'edit.php' ); + + $before = get_option( 'gfpdf_current_version' ); + $this->controller->check_install_status(); + + $this->assertSame( $before, get_option( 'gfpdf_current_version' ) ); + } + + public function test_check_install_status_syncs_version_for_admin_when_version_mismatched() { + set_current_screen( 'edit.php' ); + $admin = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin ); + + /* Multisite gates activate_plugins behind super admin; without the promotion + check_install_status() short-circuits on the capability check. No-op on + single-site. */ + grant_super_admin( $admin ); + + update_option( 'gfpdf_current_version', '0.0.1' ); + + $captured = []; + add_action( + 'gfpdf_version_changed', + static function ( $old, $new ) use ( &$captured ) { + $captured = [ $old, $new ]; + }, + 10, + 2 + ); + + $this->controller->check_install_status(); + + $this->assertSame( PDF_EXTENDED_VERSION, get_option( 'gfpdf_current_version' ) ); + $this->assertSame( [ '0.0.1', PDF_EXTENDED_VERSION ], $captured ); + } + + public function test_maybe_uninstall_emits_doing_it_wrong_notice() { + $this->setExpectedIncorrectUsage( 'GFPDF\Controller\Controller_Install::maybe_uninstall' ); + + $this->controller->maybe_uninstall(); + } +} diff --git a/tests/phpunit/integration/Controller/Test_Controller_Mergetags.php b/tests/phpunit/integration/Controller/Test_Controller_Mergetags.php new file mode 100644 index 000000000..e545b6513 --- /dev/null +++ b/tests/phpunit/integration/Controller/Test_Controller_Mergetags.php @@ -0,0 +1,52 @@ +singleton->get_class( 'Model_Mergetags' ) ); + $controller->init(); + + $this->assertNotFalse( has_filter( 'gform_replace_merge_tags' ) ); + $this->assertNotFalse( has_filter( 'gform_custom_merge_tags' ) ); + $this->assertNotFalse( has_filter( 'gform_field_map_choices' ) ); + $this->assertNotFalse( has_filter( 'gform_addon_field_value' ) ); + $this->assertNotFalse( has_filter( 'gform_mailchimp_field_value' ) ); + $this->assertNotFalse( has_filter( 'gpgs_row_value' ) ); + } + + public function test_constructor_wires_model_back_to_controller() { + global $gfpdf; + + $model = $gfpdf->singleton->get_class( 'Model_Mergetags' ); + $controller = new Controller_Mergetags( $model ); + + $this->assertSame( $controller, $model->getController() ); + } +} diff --git a/tests/phpunit/integration/Controller/Test_Controller_PDF.php b/tests/phpunit/integration/Controller/Test_Controller_PDF.php new file mode 100644 index 000000000..68f9f41ab --- /dev/null +++ b/tests/phpunit/integration/Controller/Test_Controller_PDF.php @@ -0,0 +1,262 @@ +gform, $gfpdf->log, $gfpdf->options, $gfpdf->data, $gfpdf->misc, $gfpdf->notices, $gfpdf->templates, new Helper_Url_Signer() ); + $view = new View_PDF( [], $gfpdf->gform, $gfpdf->log, $gfpdf->options, $gfpdf->data, $gfpdf->misc, $gfpdf->templates ); + + $this->controller = new Controller_PDF( $model, $view, $gfpdf->gform, $gfpdf->log, $gfpdf->misc ); + } + + public function tear_down(): void { + unset( + $GLOBALS['wp']->query_vars['gpdf'], + $GLOBALS['wp']->query_vars['pid'], + $GLOBALS['wp']->query_vars['lid'], + $_GET['gf_pdf'], + $_GET['fid'], + $_GET['lid'], + $_GET['template'], + $_GET['html'], + $_GET['raw'] + ); + + parent::tear_down(); + } + + public function test_init_schedules_cleanup_cron_when_missing() { + wp_clear_scheduled_hook( 'gfpdf_cleanup_tmp_dir' ); + + $this->controller->init(); + + $this->assertNotFalse( wp_next_scheduled( 'gfpdf_cleanup_tmp_dir' ) ); + + wp_clear_scheduled_hook( 'gfpdf_cleanup_tmp_dir' ); + } + + public function test_init_does_not_double_schedule_when_already_present() { + wp_clear_scheduled_hook( 'gfpdf_cleanup_tmp_dir' ); + wp_schedule_event( 1000, 'hourly', 'gfpdf_cleanup_tmp_dir' ); + $existing = wp_next_scheduled( 'gfpdf_cleanup_tmp_dir' ); + + $this->controller->init(); + + $this->assertSame( $existing, wp_next_scheduled( 'gfpdf_cleanup_tmp_dir' ) ); + + wp_clear_scheduled_hook( 'gfpdf_cleanup_tmp_dir' ); + } + + public function test_init_registers_pdf_endpoint_and_middleware_hooks() { + remove_all_actions( 'parse_request' ); + remove_all_filters( 'gfpdf_pdf_middleware' ); + remove_all_filters( 'gfpdf_pdf_html_output' ); + + $this->controller->init(); + + $this->assertNotFalse( has_action( 'parse_request', [ $this->controller, 'process_pdf_endpoint' ] ) ); + $this->assertNotFalse( has_action( 'parse_request', [ $this->controller, 'process_legacy_pdf_endpoint' ] ) ); + $this->assertNotFalse( has_filter( 'gfpdf_pdf_middleware' ) ); + $this->assertNotFalse( has_filter( 'gfpdf_pdf_html_output' ) ); + } + + public function test_add_pre_pdf_hooks_registers_kses_filters() { + remove_all_filters( 'wp_kses_allowed_html' ); + remove_all_filters( 'safe_style_css' ); + + $this->controller->add_pre_pdf_hooks(); + + $this->assertNotFalse( has_filter( 'wp_kses_allowed_html' ) ); + $this->assertNotFalse( has_filter( 'safe_style_css' ) ); + } + + public function test_remove_pre_pdf_hooks_unregisters_kses_filters() { + $this->controller->add_pre_pdf_hooks(); + $this->controller->remove_pre_pdf_hooks(); + + $this->assertFalse( has_filter( 'wp_kses_allowed_html', [ $this->controller->view, 'allow_pdf_html' ] ) ); + $this->assertFalse( has_filter( 'safe_style_css', [ $this->controller->view, 'allow_pdf_css' ] ) ); + } + + public function test_prevent_index_defines_donotcachepage_constant() { + $this->controller->prevent_index(); + + $this->assertTrue( defined( 'DONOTCACHEPAGE' ) ); + $this->assertTrue( DONOTCACHEPAGE ); + } + + public function test_sgoptimizer_html_minification_fix_emits_doing_it_wrong() { + $this->setExpectedIncorrectUsage( 'GFPDF\Controller\Controller_PDF::sgoptimizer_html_minification_fix' ); + + $this->controller->sgoptimizer_html_minification_fix(); + } + + public function test_add_view_html_debugger_passes_through_non_string_input() { + $result = $this->invoke_protected( 'add_view_html_debugger', [ null, [], [], [], null ] ); + + $this->assertNull( $result ); + } + + public function test_add_view_html_debugger_passes_through_when_html_param_absent() { + unset( $_GET['html'] ); + + $result = $this->invoke_protected( 'add_view_html_debugger', [ '

original

', [], [], [], null ] ); + + $this->assertSame( '

original

', $result ); + } + + public function test_included_nested_forms_in_cache_hash_returns_data_when_entry_id_missing() { + $result = $this->invoke_protected( 'included_nested_forms_in_cache_hash', [ [ 'foo' => 'bar' ], [], [], [] ] ); + + $this->assertSame( [ 'foo' => 'bar' ], $result ); + } + + public function test_included_nested_forms_in_cache_hash_returns_data_when_gpnf_entry_class_missing() { + if ( class_exists( '\GPNF_Entry' ) ) { + $this->markTestSkipped( 'GPNF_Entry available, cannot exercise the missing-class branch.' ); + } + + $result = $this->invoke_protected( 'included_nested_forms_in_cache_hash', [ [ 'foo' => 'bar' ], [], [ 'id' => 1 ], [] ] ); + + $this->assertSame( [ 'foo' => 'bar' ], $result ); + } + + public function test_add_current_form_object_hooks_returns_form_unchanged_when_id_missing() { + $result = $this->invoke_protected( 'add_current_form_object_hooks', [ [ 'fields' => [] ], [], 'source' ] ); + + $this->assertSame( [ 'fields' => [] ], $result ); + } + + /** + * Test the deprecated legacy PDF endpoint is secured and will generate a PDF successfully + * + * @group slow + */ + public function test_process_legacy_pdf_endpoint() { + $this->setExpectedIncorrectUsage( 'GFPDF\Controller\Controller_PDF::process_legacy_pdf_endpoint' ); + $this->setExpectedIncorrectUsage( 'GFPDF\Model\Model_PDF::get_legacy_config' ); + + /* Test our endpoint is firing correctly */ + $results = $this->form_and_entry(); + + $_GET['gf_pdf'] = 1; + $_GET['fid'] = $results['form']['id']; + $_GET['lid'] = $results['entry']['id']; + $_GET['template'] = 'zadani.php'; + + /* Check middleware security is applied */ + try { + wp_set_current_user( 0 ); + $this->controller->process_legacy_pdf_endpoint(); + $this->fail( 'Expected Exception on middleware redirect was not thrown.' ); + } catch ( Exception $e ) { + $this->assertSame( 'Redirecting', $e->getMessage() ); + } + + /* Check pdf successfully generated */ + try { + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $user_id ); + + add_action( 'gfpdf_post_view_or_download_pdf', function () { + wp_die( 'PDF generated successfully' ); + } ); + + $this->controller->process_legacy_pdf_endpoint(); + $this->fail( 'Expected Exception on successful PDF generation was not thrown.' ); + } catch ( Exception $e ) { + $this->assertSame( 'PDF generated successfully', $e->getMessage() ); + + return; + } + } + + /** + * Test the PDF endpoint is secured and will generate a PDF successfully + * + * @group slow + */ + public function test_process_pdf_endpoint() { + + /* Test our endpoint is firing correctly */ + $results = $this->form_and_entry(); + + $GLOBALS['wp']->query_vars['gpdf'] = 1; + $GLOBALS['wp']->query_vars['lid'] = $results['entry']['id']; + $GLOBALS['wp']->query_vars['pid'] = '556690c67856b'; + + /* Check middleware security is applied */ + try { + wp_set_current_user( 0 ); + $this->controller->process_pdf_endpoint(); + $this->fail( 'Expected Exception on middleware redirect was not thrown.' ); + } catch ( Exception $e ) { + $this->assertSame( 'Redirecting', $e->getMessage() ); + } + + /* Check pdf successfully generated */ + try { + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $user_id ); + + add_action( 'gfpdf_post_view_or_download_pdf', function () { + wp_die( 'PDF generated successfully' ); + } ); + + $this->controller->process_pdf_endpoint(); + $this->fail( 'Expected Exception on successful PDF generation was not thrown.' ); + } catch ( Exception $e ) { + $this->assertSame( 'PDF generated successfully', $e->getMessage() ); + + return; + } + } + + private function invoke_protected( string $method, array $args ) { + $ref = new ReflectionMethod( $this->controller, $method ); + if ( PHP_VERSION_ID < 80100 ) { + $ref->setAccessible( true ); + } + return $ref->invokeArgs( $this->controller, $args ); + } +} diff --git a/tests/phpunit/unit-tests/Controller/Test_Controller_Pdf_Queue.php b/tests/phpunit/integration/Controller/Test_Controller_Pdf_Queue.php similarity index 77% rename from tests/phpunit/unit-tests/Controller/Test_Controller_Pdf_Queue.php rename to tests/phpunit/integration/Controller/Test_Controller_Pdf_Queue.php index dd2b006ce..e2ec21ce8 100644 --- a/tests/phpunit/unit-tests/Controller/Test_Controller_Pdf_Queue.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Pdf_Queue.php @@ -1,6 +1,8 @@ controller = new Controller_Pdf_Queue( $this->queue_mock, $model_pdf, $gfpdf->log, $gfpdf->gform ); } - /** - * Create our testing data - * - * @since 4.0 - */ - private function create_form_and_entries() { - global $gfpdf; - - $form = $GLOBALS['GFPDF_Test']->form['all-form-fields']; - $entry = $GLOBALS['GFPDF_Test']->entries['all-form-fields'][0]; - - $gfpdf->data->form_settings = []; - $gfpdf->data->form_settings[ $form['id'] ] = $form['gfpdf_form_settings']; + public function tear_down(): void { + /* + * Background-process batches live in wp_options under the + * gfpdf_background_process_batch_* keys. WP rolls back wp_options on + * single-site, but if a test fails mid-flight (or an object cache is + * active) the rows can leak into later queue tests. + */ + if ( $this->queue ) { + $this->queue->delete_all(); + } - return [ - 'form' => $form, - 'entry' => $entry, - ]; + parent::tear_down(); } /** @@ -175,7 +176,7 @@ public function test_arguments_queue_tasks() { * @since 5.0 */ public function test_maybe_disable_notifications() { - $results = $this->create_form_and_entries(); + $results = $this->form_and_entry(); $entry = $results['entry']; $form = $results['form']; @@ -277,7 +278,7 @@ public function test_maybe_disable_notifications() { * @since 5.0 */ public function test_queue_async_form_submission_tasks() { - $results = $this->create_form_and_entries(); + $results = $this->form_and_entry(); $form = $results['form']; $entry = $results['entry']; $form['notifications']['1254123223'] = $form['notifications']['54bca349732b8']; @@ -293,10 +294,11 @@ public function test_queue_async_form_submission_tasks() { $this->assertCount( 4, $queue ); - $this->assertStringContainsString( 'create-pdf-1-1', $queue[0][0]['id'] ); - $this->assertStringContainsString( 'create-pdf-1-1', $queue[1][0]['id'] ); - $this->assertStringContainsString( 'send-notification-1-1', $queue[2][0]['id'] ); - $this->assertStringContainsString( 'send-notification-1-1', $queue[3][0]['id'] ); + $prefix = "{$form['id']}-{$entry['id']}"; + $this->assertStringContainsString( "create-pdf-$prefix", $queue[0][0]['id'] ); + $this->assertStringContainsString( "create-pdf-$prefix", $queue[1][0]['id'] ); + $this->assertStringContainsString( "send-notification-$prefix", $queue[2][0]['id'] ); + $this->assertStringContainsString( "send-notification-$prefix", $queue[3][0]['id'] ); } /** @@ -305,11 +307,11 @@ public function test_queue_async_form_submission_tasks() { * @since 5.0 */ public function test_queue_async_resend_notification_tasks() { - $results = $this->create_form_and_entries(); + $results = $this->form_and_entry(); $form = $results['form']; $form['notifications']['54bca349732b8']['isActive'] = true; - foreach( $GLOBALS['GFPDF_Test']->entries['all-form-fields'] as $entry ) { + foreach ( $this->entries( 'all-form-fields' ) as $entry ) { foreach ( $form['notifications'] as $notification ) { $this->controller->maybe_disable_submission_notifications( false, $notification, $form, $entry ); } @@ -321,13 +323,17 @@ public function test_queue_async_resend_notification_tasks() { $this->assertCount( 21, $queue ); - $this->assertStringContainsString( 'create-pdf-1-1', $queue[0][0]['id'] ); - $this->assertStringContainsString( 'create-pdf-1-1', $queue[1][0]['id'] ); - $this->assertStringContainsString( 'send-notification-1-1', $queue[2][0]['id'] ); + $entries = $this->entries( 'all-form-fields' ); + $first_entry = "{$form['id']}-{$entries[0]['id']}"; + $last_entry = "{$form['id']}-{$entries[6]['id']}"; + + $this->assertStringContainsString( "create-pdf-$first_entry", $queue[0][0]['id'] ); + $this->assertStringContainsString( "create-pdf-$first_entry", $queue[1][0]['id'] ); + $this->assertStringContainsString( "send-notification-$first_entry", $queue[2][0]['id'] ); - $this->assertStringContainsString( 'create-pdf-1-7', $queue[18][0]['id'] ); - $this->assertStringContainsString( 'create-pdf-1-7', $queue[19][0]['id'] ); - $this->assertStringContainsString( 'send-notification-1-7', $queue[20][0]['id'] ); + $this->assertStringContainsString( "create-pdf-$last_entry", $queue[18][0]['id'] ); + $this->assertStringContainsString( "create-pdf-$last_entry", $queue[19][0]['id'] ); + $this->assertStringContainsString( "send-notification-$last_entry", $queue[20][0]['id'] ); } /** @@ -362,7 +368,7 @@ public function test_cleanup_pdfs() { $form_class = \GPDFAPI::get_form_class(); - $results = $this->create_form_and_entries(); + $results = $this->form_and_entry(); $entry = $results['entry']; $form = $form_class->get_form( $results['form']['id'] ); @@ -386,7 +392,20 @@ public function test_cleanup_pdfs() { * @since 6.12.6 */ public function test_queue_cleanup() { - global $gfpdf; + global $gfpdf, $wp_settings_errors; + + /* + * Wipe state that other tests leak and that quietly breaks settings_sanitize: + * - $wp_settings_errors: prior add_settings_error calls flip update_settings into the empty-output branch (line 1188). + * - gfpdf_settings_user_data transient + $_GET keys: trigger get_settings to return transient instead of DB. + */ + $wp_settings_errors = []; + delete_transient( 'gfpdf_settings_user_data' ); + unset( $_GET['page'], $_GET['subview'] ); + + /* Seed gfpdf_settings deterministically and reload the in-memory cache. */ + update_option( 'gfpdf_settings', [ 'background_processing' => 'No' ] ); + $gfpdf->options->set_plugin_settings(); /* setup page */ $_POST['option_page'] = 'gfpdf_settings'; diff --git a/tests/phpunit/integration/Controller/Test_Controller_Save_Core_Fonts.php b/tests/phpunit/integration/Controller/Test_Controller_Save_Core_Fonts.php new file mode 100644 index 000000000..a1596ed9d --- /dev/null +++ b/tests/phpunit/integration/Controller/Test_Controller_Save_Core_Fonts.php @@ -0,0 +1,64 @@ +controller = new Controller_Save_Core_Fonts( $gfpdf->log, $gfpdf->data, $gfpdf->misc ); + } + + public function tear_down(): void { + unset( $_POST['font_name'] ); + + parent::tear_down(); + } + + public function test_init_registers_ajax_endpoint() { + remove_all_actions( 'wp_ajax_gfpdf_save_core_font' ); + + $this->controller->init(); + + $this->assertNotFalse( has_action( 'wp_ajax_gfpdf_save_core_font', [ $this->controller, 'save_core_font' ] ) ); + } + + public function test_download_returns_false_when_font_name_missing() { + unset( $_POST['font_name'] ); + + $this->assertFalse( $this->invoke_download() ); + } + + public function test_download_returns_false_when_font_name_not_on_approved_list() { + $_POST['font_name'] = 'NotARealFont.ttf'; + + $this->assertFalse( $this->invoke_download() ); + } + + private function invoke_download() { + $ref = new ReflectionMethod( $this->controller, 'download_and_save_font' ); + if ( PHP_VERSION_ID < 80100 ) { + $ref->setAccessible( true ); + } + return $ref->invoke( $this->controller ); + } +} diff --git a/tests/phpunit/unit-tests/Controller/Test_Controller_Settings.php b/tests/phpunit/integration/Controller/Test_Controller_Settings.php similarity index 91% rename from tests/phpunit/unit-tests/Controller/Test_Controller_Settings.php rename to tests/phpunit/integration/Controller/Test_Controller_Settings.php index 64b4a62ff..d21138342 100644 --- a/tests/phpunit/unit-tests/Controller/Test_Controller_Settings.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Settings.php @@ -1,12 +1,14 @@ singleton->get_class( 'Model_Shortcodes' ), + $gfpdf->singleton->get_class( 'View_Shortcodes' ), + $gfpdf->log + ); + $controller->init(); + + $this->assertNotFalse( has_filter( 'gform_admin_pre_render' ) ); + $this->assertNotFalse( has_filter( 'gform_confirmation' ) ); + $this->assertNotFalse( has_filter( 'gform_pre_replace_merge_tags' ) ); + $this->assertNotFalse( has_filter( 'gravityview/fields/custom/content_before' ) ); + $this->assertTrue( shortcode_exists( 'gravitypdf' ) ); + } + + public function test_constructor_wires_model_and_view_back_to_controller() { + global $gfpdf; + + $model = $gfpdf->singleton->get_class( 'Model_Shortcodes' ); + $view = $gfpdf->singleton->get_class( 'View_Shortcodes' ); + $controller = new Controller_Shortcodes( $model, $view, $gfpdf->log ); + + $this->assertSame( $controller, $model->getController() ); + $this->assertSame( $controller, $view->getController() ); + } +} diff --git a/tests/phpunit/unit-tests/Controller/Test_Controller_System_Report.php b/tests/phpunit/integration/Controller/Test_Controller_System_Report.php similarity index 94% rename from tests/phpunit/unit-tests/Controller/Test_Controller_System_Report.php rename to tests/phpunit/integration/Controller/Test_Controller_System_Report.php index 33c718921..2dbe45385 100644 --- a/tests/phpunit/unit-tests/Controller/Test_Controller_System_Report.php +++ b/tests/phpunit/integration/Controller/Test_Controller_System_Report.php @@ -1,8 +1,10 @@ singleton->get_class( 'Model_Templates' ) ); + $controller->init(); + + $this->assertNotFalse( has_action( 'wp_ajax_gfpdf_upload_template' ) ); + $this->assertNotFalse( has_action( 'wp_ajax_gfpdf_delete_template' ) ); + $this->assertNotFalse( has_action( 'wp_ajax_gfpdf_get_template_options' ) ); + } + + public function test_constructor_wires_model_back_to_controller() { + global $gfpdf; + + $model = $gfpdf->singleton->get_class( 'Model_Templates' ); + $controller = new Controller_Templates( $model ); + + $this->assertSame( $controller, $model->getController() ); + } +} diff --git a/tests/phpunit/integration/Controller/Test_Controller_Uninstaller.php b/tests/phpunit/integration/Controller/Test_Controller_Uninstaller.php new file mode 100644 index 000000000..ddfba5583 --- /dev/null +++ b/tests/phpunit/integration/Controller/Test_Controller_Uninstaller.php @@ -0,0 +1,82 @@ +controller = Controller_Uninstaller::get_instance(); + } + + public function test_get_instance_returns_singleton() { + $this->assertSame( $this->controller, Controller_Uninstaller::get_instance() ); + } + + public function test_current_user_can_uninstall_grants_admin_on_single_site() { + if ( is_multisite() ) { + $this->markTestSkipped( 'Single-site path only.' ); + } + + $admin = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin ); + + $this->assertTrue( $this->controller->current_user_can_uninstall() ); + } + + public function test_current_user_can_uninstall_denies_subscriber_on_single_site() { + if ( is_multisite() ) { + $this->markTestSkipped( 'Single-site path only.' ); + } + + $subscriber = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + wp_set_current_user( $subscriber ); + + $this->assertFalse( $this->controller->current_user_can_uninstall() ); + } + + public function test_render_uninstall_outputs_nothing_when_capability_denied() { + $subscriber = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + wp_set_current_user( $subscriber ); + + ob_start(); + $this->controller->render_uninstall(); + $output = ob_get_clean(); + + $this->assertSame( '', $output ); + } + + public function test_render_uninstall_outputs_button_markup_for_authorised_user() { + $admin = self::factory()->user->create( [ 'role' => 'administrator' ] ); + if ( is_multisite() ) { + grant_super_admin( $admin ); + } + wp_set_current_user( $admin ); + + ob_start(); + $this->controller->render_uninstall(); + $output = ob_get_clean(); + + $this->assertNotEmpty( $output ); + $this->assertStringContainsString( 'gform-settings-panel__addon-uninstall', $output ); + $this->assertStringContainsString( 'name="uninstall_addon"', $output ); + $this->assertStringContainsString( 'Gravity PDF', $output ); + } + +} diff --git a/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php b/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php new file mode 100644 index 000000000..bd09cef29 --- /dev/null +++ b/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php @@ -0,0 +1,115 @@ +options = \GPDFAPI::get_options_class(); + } + + public function test_6_0_0_background_process_upgrade_routine() { + /* Check for enabled status */ + $this->options->update_option( 'background_processing', 'Enable' ); + + do_action( 'gfpdf_version_changed', '5.3', '6.0.0-beta1' ); + + $this->assertSame( 'Yes', $this->options->get_option( 'background_processing' ) ); + + /* Check for disabled status */ + $this->options->update_option( 'background_processing', 'Disable' ); + + do_action( 'gfpdf_version_changed', '5.3', '6.0.0-beta1' ); + + $this->assertSame( 'No', $this->options->get_option( 'background_processing' ) ); + } + + public function test_6_12_0_clears_legacy_cleanup_tmp_dir_cron(): void { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', 'gfpdf_cleanup_tmp_dir' ); + $this->assertNotFalse( wp_next_scheduled( 'gfpdf_cleanup_tmp_dir' ) ); + + do_action( 'gfpdf_version_changed', '6.11.0', '6.12.0' ); + + $this->assertFalse( wp_next_scheduled( 'gfpdf_cleanup_tmp_dir' ) ); + } + + public function test_6_13_2_fix_tmp_folder_permissions_runs_without_crashing(): void { + global $gfpdf; + + /* Ensure the tmp folder exists so RecursiveDirectoryIterator has a target. */ + wp_mkdir_p( $gfpdf->data->template_tmp_location ); + + $test_subdir = $gfpdf->data->template_tmp_location . 'gpdf-upgrade-test/'; + wp_mkdir_p( $test_subdir ); + chmod( $test_subdir, 0700 ); + + try { + do_action( 'gfpdf_version_changed', '6.13.1', '6.13.2' ); + + $this->assertDirectoryExists( $test_subdir ); + + /* The routine resets permissions to match the parent dir (or 0755). */ + clearstatcache( true, $test_subdir ); + $perms = fileperms( $test_subdir ) & 0007777; + $this->assertNotSame( 0700, $perms, 'fix_tmp_folder_permissions should have changed the directory permissions' ); + } finally { + @rmdir( $test_subdir ); + } + } + + public function test_6_13_2_handles_missing_tmp_folder_gracefully(): void { + global $gfpdf; + + $original_tmp = $gfpdf->data->template_tmp_location; + $gfpdf->data->template_tmp_location = '/tmp/gpdf-nonexistent-' . uniqid() . '/'; + + $this->expectNotToPerformAssertions(); + + try { + do_action( 'gfpdf_version_changed', '6.13.1', '6.13.2' ); + } finally { + $gfpdf->data->template_tmp_location = $original_tmp; + } + } + + public function test_6_15_0_removes_legacy_edd_sl_options_only_for_gravity_pdf(): void { + add_option( 'edd_sl_gravity_pdf_active', 'license-data for gravity-pdf', '', 'no' ); + add_option( 'edd_sl_other_plugin_active', 'license-data for some-other-plugin', '', 'no' ); + add_option( 'gpdf_unrelated_option', 'should-survive', '', 'no' ); + + do_action( 'gfpdf_version_changed', '6.13.2', '6.15.0' ); + + /* The upgrade routine deletes via a raw wpdb query; bust the per-option cache so get_option re-reads the DB. */ + wp_cache_delete( 'edd_sl_gravity_pdf_active', 'options' ); + + $this->assertFalse( get_option( 'edd_sl_gravity_pdf_active', false ) ); + $this->assertSame( 'license-data for some-other-plugin', get_option( 'edd_sl_other_plugin_active' ) ); + $this->assertSame( 'should-survive', get_option( 'gpdf_unrelated_option' ) ); + } + +} diff --git a/tests/phpunit/unit-tests/Controller/Test_Controller_Webhooks.php b/tests/phpunit/integration/Controller/Test_Controller_Webhooks.php similarity index 79% rename from tests/phpunit/unit-tests/Controller/Test_Controller_Webhooks.php rename to tests/phpunit/integration/Controller/Test_Controller_Webhooks.php index 9b6b15a23..bd7d337ba 100644 --- a/tests/phpunit/unit-tests/Controller/Test_Controller_Webhooks.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Webhooks.php @@ -1,8 +1,10 @@ [ 'requestBodyType' => 'all_fields' ] ]; - $entry = $GLOBALS['GFPDF_Test']->entries['all-form-fields'][0]; + $entry = $this->entry( 'all-form-fields' ); $request_data = $entry; $request_data = apply_filters( 'gform_webhooks_request_data', $request_data, $feed, $entry ); @@ -42,7 +49,7 @@ public function test_webhook_request_data_all_fields() { */ public function test_webhook_request_data_select_fields() { $feed = [ 'meta' => [ 'requestBodyType' => 'select_fields' ] ]; - $entry = $GLOBALS['GFPDF_Test']->entries['all-form-fields'][0]; + $entry = $this->entry( 'all-form-fields' ); $request_data = $entry; $request_data = apply_filters( 'gform_webhooks_request_data', $request_data, $feed, $entry ); diff --git a/tests/phpunit/unit-tests/Controller/Test_Controller_Zapier.php b/tests/phpunit/integration/Controller/Test_Controller_Zapier.php similarity index 88% rename from tests/phpunit/unit-tests/Controller/Test_Controller_Zapier.php rename to tests/phpunit/integration/Controller/Test_Controller_Zapier.php index 73163a7e2..6f16d2370 100644 --- a/tests/phpunit/unit-tests/Controller/Test_Controller_Zapier.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Zapier.php @@ -1,8 +1,10 @@ controller = new Controller_Zapier(); } public function test_add_zapier_support_active_pdfs() { - $entry = $GLOBALS['GFPDF_Test']->entries['all-form-fields'][0]; + $entry = $this->entry( 'all-form-fields' ); $body = $this->controller->add_zapier_support( [], [], $entry ); $this->assertCount( 8, $body ); @@ -59,7 +66,7 @@ public function test_add_zapier_support_active_pdfs() { } public function test_add_zapier_support_conditional_logic() { - $entry = $GLOBALS['GFPDF_Test']->entries['all-form-fields'][0]; + $entry = $this->entry( 'all-form-fields' ); $entry[7] = 'Albania'; \GFAPI::update_entry( $entry ); diff --git a/tests/phpunit/integration/Exceptions/Test_Exception_Hierarchy.php b/tests/phpunit/integration/Exceptions/Test_Exception_Hierarchy.php new file mode 100644 index 000000000..f0fd874a6 --- /dev/null +++ b/tests/phpunit/integration/Exceptions/Test_Exception_Hierarchy.php @@ -0,0 +1,65 @@ +assertTrue( + is_subclass_of( $class, $parent ), + "$class must extend $parent" + ); + } + + /** + * @dataProvider provider_hierarchy + */ + public function test_constructor_passes_message_and_code( string $class ) { + $instance = new $class( 'msg', 42 ); + + $this->assertSame( 'msg', $instance->getMessage() ); + $this->assertSame( 42, $instance->getCode() ); + } + + public function provider_hierarchy(): array { + return [ + 'GravityPdfException → Exception' => [ GravityPdfException::class, Exception::class ], + 'GravityPdfRuntimeException → RuntimeException' => [ GravityPdfRuntimeException::class, RuntimeException::class ], + 'GravityPdfDomainException → DomainException' => [ GravityPdfDomainException::class, DomainException::class ], + 'GravityPdfDatabaseUpdateException → GravityPdfRuntimeException' => [ GravityPdfDatabaseUpdateException::class, GravityPdfRuntimeException::class ], + 'GravityPdfFontNotFoundException → GravityPdfDomainException' => [ GravityPdfFontNotFoundException::class, GravityPdfDomainException::class ], + 'GravityPdfIdException → GravityPdfException' => [ GravityPdfIdException::class, GravityPdfException::class ], + 'GravityPdfModelNotUpdatedException → GravityPdfException' => [ GravityPdfModelNotUpdatedException::class, GravityPdfException::class ], + 'GravityPdfShortcodeEntryIdException → GravityPdfException' => [ GravityPdfShortcodeEntryIdException::class, GravityPdfException::class ], + 'GravityPdfShortcodePdfConditionalLogicFailedException → GravityPdfException' => [ GravityPdfShortcodePdfConditionalLogicFailedException::class, GravityPdfException::class ], + 'GravityPdfShortcodePdfConfigNotFoundException → GravityPdfException' => [ GravityPdfShortcodePdfConfigNotFoundException::class, GravityPdfException::class ], + 'GravityPdfShortcodePdfInactiveException → GravityPdfException' => [ GravityPdfShortcodePdfInactiveException::class, GravityPdfException::class ], + ]; + } +} diff --git a/tests/phpunit/integration/Helper/Fields/Test_Field_Address.php b/tests/phpunit/integration/Helper/Fields/Test_Field_Address.php new file mode 100644 index 000000000..1efd4f223 --- /dev/null +++ b/tests/phpunit/integration/Helper/Fields/Test_Field_Address.php @@ -0,0 +1,101 @@ +form( 'all-form-fields' ); + $gf_field = new GF_Field_Address( $this->field_from_fixture( 'address' ) ); + + $entry = [ + 'id' => 0, + 'form_id' => $form['id'], + '15.1' => '12 Address St', + '15.2' => 'Line 2', + '15.3' => 'Cityville', + '15.4' => 'Statesman', + '15.5' => '5000', + '15.6' => 'Chad', + ]; + + $pdf_field = new Field_Address( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + $html = $pdf_field->html(); + $this->assertStringContainsString( '12 Address St', $html ); + $this->assertStringContainsString( 'Cityville', $html ); + $this->assertStringContainsString( 'Chad', $html ); + $this->assertStringContainsString( '
', $html ); + } + + public function test_value_returns_keyed_array() { + $form = $this->form( 'all-form-fields' ); + $gf_field = new GF_Field_Address( $this->field_from_fixture( 'address' ) ); + + $entry = [ + 'id' => 0, + 'form_id' => $form['id'], + '15.1' => '5 Main Rd', + '15.3' => 'Townsville', + '15.6' => 'Australia', + ]; + + $pdf_field = new Field_Address( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $value = $pdf_field->value(); + + $this->assertArrayHasKey( 'street', $value ); + $this->assertArrayHasKey( 'city', $value ); + $this->assertArrayHasKey( 'country', $value ); + $this->assertSame( '5 Main Rd', $value['street'] ); + $this->assertSame( 'Townsville', $value['city'] ); + $this->assertSame( 'Australia', $value['country'] ); + } + + public function test_is_empty_when_all_inputs_blank() { + $form = $this->form( 'all-form-fields' ); + $gf_field = new GF_Field_Address( $this->field_from_fixture( 'address' ) ); + + $entry = [ 'id' => 0, 'form_id' => $form['id'] ]; + $pdf_field = new Field_Address( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + $this->assertTrue( $pdf_field->is_empty() ); + } + + public function test_zip_before_city_format() { + $form = $this->form( 'all-form-fields' ); + $gf_field = new GF_Field_Address( $this->field_from_fixture( 'address' ) ); + + $entry = [ + 'id' => 0, + 'form_id' => $form['id'], + '15.3' => 'Berlin', + '15.5' => '10115', + ]; + + $pdf_field = new Field_Address( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + add_filter( 'gform_address_display_format', fn() => 'zip_before_city' ); + $html = $pdf_field->html(); + remove_all_filters( 'gform_address_display_format' ); + + $this->assertStringContainsString( '10115', $html ); + $this->assertStringContainsString( 'Berlin', $html ); + $this->assertLessThan( strpos( $html, 'Berlin' ), strpos( $html, '10115' ), 'ZIP should appear before city in zip_before_city format' ); + } +} diff --git a/tests/phpunit/integration/Helper/Fields/Test_Field_Chainedselect.php b/tests/phpunit/integration/Helper/Fields/Test_Field_Chainedselect.php new file mode 100644 index 000000000..442dd8f8a --- /dev/null +++ b/tests/phpunit/integration/Helper/Fields/Test_Field_Chainedselect.php @@ -0,0 +1,75 @@ + $id, + 'label' => $label, + 'choices' => [ + [ + 'text' => 'Australia', + 'value' => 'Australia', + 'choices' => [ + [ 'text' => 'NSW', 'value' => 'NSW', 'choices' => [] ], + ], + ], + ], + 'inputs' => [ + [ 'id' => '5.1', 'label' => 'Level 1' ], + [ 'id' => '5.2', 'label' => 'Level 2' ], + ], + ] ); + } + + public function test_form_data_keys_contain_field_id_and_label() { + $gf_field = $this->make_gf_field(); + $entry = [ + 'id' => 0, + 'form_id' => 0, + '5.1' => 'Australia', + '5.2' => 'NSW', + ]; + + $pdf_field = new Field_Chainedselect( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $form_data = $pdf_field->form_data(); + + $this->assertArrayHasKey( 'field', $form_data ); + $this->assertArrayHasKey( 5, $form_data['field'] ); + $this->assertArrayHasKey( 'Location', $form_data['field'] ); + } + + public function test_value_contains_selected_items() { + $gf_field = $this->make_gf_field(); + $entry = [ + 'id' => 0, + 'form_id' => 0, + '5.1' => 'Australia', + '5.2' => 'NSW', + ]; + + $pdf_field = new Field_Chainedselect( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $value = $pdf_field->value(); + + $this->assertIsArray( $value ); + $this->assertContains( 'Australia', $value ); + } + + public function test_is_empty_when_no_selections() { + $gf_field = $this->make_gf_field(); + $entry = [ 'id' => 0, 'form_id' => 0 ]; + $pdf_field = new Field_Chainedselect( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + $this->assertTrue( $pdf_field->is_empty() ); + } +} diff --git a/tests/phpunit/integration/Helper/Fields/Test_Field_Checkbox.php b/tests/phpunit/integration/Helper/Fields/Test_Field_Checkbox.php new file mode 100644 index 000000000..2d9d3a322 --- /dev/null +++ b/tests/phpunit/integration/Helper/Fields/Test_Field_Checkbox.php @@ -0,0 +1,98 @@ +field_from_fixture( 'checkbox' ) ); + } + + public function test_html_renders_checked_choices_as_list() { + $gf_field = $this->make_field(); + $form = $this->form( 'all-form-fields' ); + + $entry = [ + 'id' => 0, + 'form_id' => $form['id'], + '6.2' => 'Checkbox Choice 2', + '6.3' => 'Checkbox Choice 3', + ]; + + $pdf_field = new Field_Checkbox( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + $html = $pdf_field->html(); + $this->assertStringContainsString( '
    ', $html ); + $this->assertStringContainsString( 'Checkbox Choice 2', $html ); + $this->assertStringContainsString( 'Checkbox Choice 3', $html ); + } + + public function test_html_is_empty_wrapper_when_nothing_checked() { + $gf_field = $this->make_field(); + $form = $this->form( 'all-form-fields' ); + + $entry = [ 'id' => 0, 'form_id' => $form['id'] ]; + $pdf_field = new Field_Checkbox( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + $this->assertTrue( $pdf_field->is_empty() ); + } + + public function test_form_data_contains_value_and_name_keys() { + $gf_field = $this->make_field(); + $form = $this->form( 'all-form-fields' ); + + $entry = [ + 'id' => 0, + 'form_id' => $form['id'], + '6.2' => 'Checkbox Choice 2', + ]; + + $pdf_field = new Field_Checkbox( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $form_data = $pdf_field->form_data(); + + $this->assertArrayHasKey( 6, $form_data['field'] ); + $this->assertArrayHasKey( '6_name', $form_data['field'] ); + $this->assertIsArray( $form_data['field'][6] ); + } + + public function test_show_value_filter_uses_value_instead_of_label() { + $gf_field = $this->make_field(); + $form = $this->form( 'all-form-fields' ); + + /* + * Choice 2: label = "Checkbox Choice 2 Text", value = "Checkbox Choice 2". + * When the filter is active the value is rendered, not the label. + * Assert: the value token appears AND the label-only suffix (" Text") is absent. + */ + $entry = [ + 'id' => 0, + 'form_id' => $form['id'], + '6.2' => 'Checkbox Choice 2', + ]; + + $pdf_field = new Field_Checkbox( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + add_filter( 'gfpdf_show_field_value', '__return_true' ); + $html = $pdf_field->html(); + remove_filter( 'gfpdf_show_field_value', '__return_true' ); + + $this->assertStringContainsString( 'Checkbox Choice 2', $html ); + $this->assertStringNotContainsString( 'Checkbox Choice 2 Text', $html ); + } +} diff --git a/tests/phpunit/unit-tests/Helper/Fields/Test_Field_Consent.php b/tests/phpunit/integration/Helper/Fields/Test_Field_Consent.php similarity index 84% rename from tests/phpunit/unit-tests/Helper/Fields/Test_Field_Consent.php rename to tests/phpunit/integration/Helper/Fields/Test_Field_Consent.php index 5e3765e85..34bf0a7ca 100644 --- a/tests/phpunit/unit-tests/Helper/Fields/Test_Field_Consent.php +++ b/tests/phpunit/integration/Helper/Fields/Test_Field_Consent.php @@ -4,7 +4,7 @@ namespace GFPDF\Helper\Fields; -use WP_UnitTestCase; +use GFPDF\Tests\Integration\TestCase; /** * @package Gravity PDF @@ -16,7 +16,13 @@ * @group helper * @group fields */ -class Test_Field_Consent extends WP_UnitTestCase { +class Test_Field_Consent extends TestCase { + + public static function set_up_before_class(): void { + parent::set_up_before_class(); + static::load_fixtures( [ 'repeater-consent-form' ] ); + } + public $form; @@ -24,17 +30,11 @@ class Test_Field_Consent extends WP_UnitTestCase { public $pdf_field; - public function set_up() { + public function set_up(): void { parent::set_up(); - $this->form = $GLOBALS['GFPDF_Test']->form['repeater-consent-form']; - - foreach ( $this->form['fields'] as $field ) { - if ( $field->type === 'consent' ) { - $this->gf_field = new \GF_Field_Consent( $field ); - break; - } - } + $this->form = $this->form( 'repeater-consent-form' ); + $this->gf_field = new \GF_Field_Consent( $this->field_from_fixture( 'consent', 'repeater-consent-form' ) ); $entry = [ 'form_id' => $this->form['id'], diff --git a/tests/phpunit/integration/Helper/Fields/Test_Field_Coupon.php b/tests/phpunit/integration/Helper/Fields/Test_Field_Coupon.php new file mode 100644 index 000000000..97c5f5c7e --- /dev/null +++ b/tests/phpunit/integration/Helper/Fields/Test_Field_Coupon.php @@ -0,0 +1,80 @@ +markTestSkipped( 'Gravity Forms Coupons add-on is not active in the test environment.' ); + } + } + + public function tear_down(): void { + foreach ( $this->created_entry_ids as $id ) { + \GFAPI::delete_entry( $id ); + } + $this->created_entry_ids = []; + + parent::tear_down(); + } + + public function test_constructor_rejects_non_coupon_field(): void { + $this->expectException( Exception::class ); + + new Field_Coupon( new GF_Field(), [], \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + } + + public function test_html_returns_empty_value_markup_when_entry_has_no_coupon(): void { + $pdf_field = $this->make_field_with_real_entry( '' ); + + $html = $pdf_field->html(); + + $this->assertStringContainsString( 'class="gfpdf-field gfpdf-coupon', $html ); + $this->assertStringContainsString( '
     
    ', $html ); + } + + public function test_value_caches_subsequent_calls(): void { + $pdf_field = $this->make_field_with_real_entry( 'CODE10' ); + + $first = $pdf_field->value(); + $second = $pdf_field->value(); + + $this->assertSame( $first, $second ); + $this->assertTrue( $pdf_field->has_cache() ); + } + + private function make_field_with_real_entry( string $entry_value ): Field_Coupon { + $gf_field = new GF_Field_Coupon(); + $gf_field->id = 99; + + $form_id = $this->form( 'all-form-fields' )['id']; + $entry_id = $this->gf_factory()->entry->create( [ 'form_id' => $form_id, '99' => $entry_value ] ); + + $this->created_entry_ids[] = $entry_id; + + return new Field_Coupon( $gf_field, \GFAPI::get_entry( $entry_id ), \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + } +} diff --git a/tests/phpunit/integration/Helper/Fields/Test_Field_Creditcard.php b/tests/phpunit/integration/Helper/Fields/Test_Field_Creditcard.php new file mode 100644 index 000000000..485cee2f2 --- /dev/null +++ b/tests/phpunit/integration/Helper/Fields/Test_Field_Creditcard.php @@ -0,0 +1,84 @@ + $id, + 'inputs' => [ + [ 'id' => "{$id}.1", 'label' => 'Card Number' ], + [ 'id' => "{$id}.2", 'label' => 'Expiration Month' ], + [ 'id' => "{$id}.3", 'label' => 'Expiration Year' ], + [ 'id' => "{$id}.4", 'label' => 'Card Type' ], + [ 'id' => "{$id}.5", 'label' => 'Cardholder Name' ], + ], + ] ); + } + + public function test_html_renders_masked_number_and_card_type() { + $gf_field = $this->make_cc_field(); + + /* + * GF stores only the last-four digits and the card type in the entry after + * payment processing; the full PAN is never persisted. The subfield keys + * are 1.1 (masked number) and 1.4 (card type). + */ + $entry = [ + 'id' => 0, + 'form_id' => 0, + '1.1' => 'XXXX XXXX XXXX 1234', + '1.4' => 'Visa', + ]; + + $pdf_field = new Field_Creditcard( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + $html = $pdf_field->html(); + $this->assertStringContainsString( 'XXXX XXXX XXXX 1234', $html ); + $this->assertStringContainsString( 'Visa', $html ); + } + + public function test_value_returns_type_and_number_keys() { + $gf_field = $this->make_cc_field(); + + $entry = [ + 'id' => 0, + 'form_id' => 0, + '1.1' => 'XXXX XXXX XXXX 5678', + '1.4' => 'Mastercard', + ]; + + $pdf_field = new Field_Creditcard( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $value = $pdf_field->value(); + + $this->assertArrayHasKey( 'type', $value ); + $this->assertArrayHasKey( 'number', $value ); + $this->assertSame( 'Mastercard', $value['type'] ); + $this->assertSame( 'XXXX XXXX XXXX 5678', $value['number'] ); + } + + public function test_html_omits_empty_subfields() { + $gf_field = $this->make_cc_field(); + $entry = [ 'id' => 0, 'form_id' => 0, '1.4' => 'Visa' ]; + $pdf_field = new Field_Creditcard( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + $html = $pdf_field->html(); + $this->assertStringContainsString( 'Visa', $html ); + $this->assertStringNotContainsString( '
    ', $html ); + } +} diff --git a/tests/phpunit/integration/Helper/Fields/Test_Field_Date.php b/tests/phpunit/integration/Helper/Fields/Test_Field_Date.php new file mode 100644 index 000000000..98db1df2f --- /dev/null +++ b/tests/phpunit/integration/Helper/Fields/Test_Field_Date.php @@ -0,0 +1,67 @@ +form( 'all-form-fields' ); + $gf_field = new GF_Field_Date( $this->field_from_fixture( 'date' ) ); + + $entry = [ + 'id' => 0, + 'form_id' => $form['id'], + '12' => '2015-01-01', + ]; + + $pdf_field = new Field_Date( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $value = $pdf_field->value(); + + /* dmy format yields "01/01/2015" — day and month precede the year, unlike the raw "2015-01-01" */ + $this->assertSame( '01/01/2015', $value ); + } + + public function test_html_contains_formatted_date() { + $form = $this->form( 'all-form-fields' ); + $gf_field = new GF_Field_Date( $this->field_from_fixture( 'date' ) ); + + $entry = [ + 'id' => 0, + 'form_id' => $form['id'], + '12' => '2015-01-01', + ]; + + $pdf_field = new Field_Date( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $html = $pdf_field->html(); + + $this->assertStringContainsString( '2015', $html ); + } + + public function test_is_empty_when_no_date_stored() { + $gf_field = new GF_Field_Date( [ 'id' => 12, 'dateFormat' => 'mdy' ] ); + $entry = [ 'id' => 0, 'form_id' => 0 ]; + $pdf_field = new Field_Date( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + $this->assertTrue( $pdf_field->is_empty() ); + } +} diff --git a/tests/phpunit/integration/Helper/Fields/Test_Field_Default.php b/tests/phpunit/integration/Helper/Fields/Test_Field_Default.php new file mode 100644 index 000000000..b2a28a0fb --- /dev/null +++ b/tests/phpunit/integration/Helper/Fields/Test_Field_Default.php @@ -0,0 +1,50 @@ +id = 1; + $gf_field->type = 'text'; + + $entry = [ + 'id' => 0, + 'form_id' => 0, + '1' => 'Hello World', + ]; + + $pdf_field = new Field_Default( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $html = $pdf_field->html(); + + $this->assertStringContainsString( 'Hello World', $html ); + } + + public function test_value_esc_htmls_string_input() { + $gf_field = new GF_Field(); + $gf_field->id = 1; + $gf_field->type = 'text'; + + $entry = [ + 'id' => 0, + 'form_id' => 0, + '1' => 'Safe text', + ]; + + $pdf_field = new Field_Default( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $value = $pdf_field->value(); + + $this->assertStringContainsString( 'Safe', $value ); + $this->assertStringNotContainsString( '', $value ); + } +} diff --git a/tests/phpunit/integration/Helper/Fields/Test_Field_Discount.php b/tests/phpunit/integration/Helper/Fields/Test_Field_Discount.php new file mode 100644 index 000000000..04c0dd195 --- /dev/null +++ b/tests/phpunit/integration/Helper/Fields/Test_Field_Discount.php @@ -0,0 +1,74 @@ +entry( 'non-group-products-form' ); + + $gf_field = new GF_Field(); + $gf_field->id = 99; + $gf_field->type = 'discount'; + + $pdf_field = new Field_Discount( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $pdf_field->set_products( new Field_Products( new GF_Field(), $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ) ); + + return $pdf_field; + } + + public function test_is_empty_when_gp_ecommerce_fields_absent() { + $gf_field = new GF_Field(); + $gf_field->id = 1; + $gf_field->type = 'discount'; + + $entry = [ 'id' => 0, 'form_id' => 0 ]; + $pdf_field = new Field_Discount( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $pdf_field->set_products( new Field_Products( new GF_Field(), $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ) ); + + /* + * GP_Ecommerce_Fields is not present in the test environment so + * Field_Discount::is_empty() short-circuits and returns true. + */ + $this->assertTrue( $pdf_field->is_empty() ); + } + + public function test_value_returns_empty_array_when_no_matching_product() { + $pdf_field = $this->make_pdf_field_with_real_entry(); + $value = $pdf_field->value(); + + $this->assertIsArray( $value ); + $this->assertEmpty( $value ); + } + + public function test_form_data_returns_empty_strings_when_no_discount() { + $pdf_field = $this->make_pdf_field_with_real_entry(); + $form_data = $pdf_field->form_data(); + + $this->assertArrayHasKey( 'field', $form_data ); + + foreach ( $form_data['field'] as $v ) { + $this->assertSame( '', $v ); + } + } +} diff --git a/tests/phpunit/integration/Helper/Fields/Test_Field_Email.php b/tests/phpunit/integration/Helper/Fields/Test_Field_Email.php new file mode 100644 index 000000000..4a59d6dfb --- /dev/null +++ b/tests/phpunit/integration/Helper/Fields/Test_Field_Email.php @@ -0,0 +1,56 @@ + 1 ] ); + + $entry = [ + 'id' => 0, + 'form_id' => 0, + '1' => 'support@gravitypdf.com', + ]; + + $pdf_field = new Field_Email( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + $html = $pdf_field->html(); + + $this->assertStringContainsString( 'href="mailto:support@gravitypdf.com"', $html ); + $this->assertStringContainsString( 'support@gravitypdf.com', $html ); + } + + public function test_html_esc_htmls_non_email_string() { + $gf_field = new GF_Field_Email( [ 'id' => 1 ] ); + + $entry = [ + 'id' => 0, + 'form_id' => 0, + '1' => 'not-an-email' ]; + + $pdf_field = new Field_Post_Content( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + $this->assertStringNotContainsString( '' ]; + + $pdf_field = new Field_Text( $gf_field, $entry, \GPDFAPI::get_form_class(), \GPDFAPI::get_misc_class() ); + + $this->assertStringNotContainsString( '