From d5074283aadd975ff4bfccd9ad0a80498fca0845 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Tue, 3 Nov 2026 20:44:09 +1100 Subject: [PATCH] Keep font state in one place for every site, and keep every site's fonts in the 6.19 cleanup Every site shares the fonts folder, so the font state about it is now stored the same way on single site and Multisite: the failed downloads record and the 6.19 cleanup's progress are main network options, the language font cache is a site transient, and the failed fonts notice's dismissal lives in the record rather than in each site's settings. The 6.19 cleanup keeps the fonts any site needs, reading each site's settings, locale and PDFs directly through the same reader the Font Manager uses for other sites' fonts. It runs in a cron event that reads the sites in batches within the sync's time budget, so no upgrade request pays for a large network's scan. A malformed cleanup record is now logged and skipped rather than fatal. Adds a Multisite Playwright environment on port 8704, run as a fifth entry in the E2E matrix, and tests the failed fonts notice across sites and the network admin. Co-Authored-By: Claude Opus 5.5 --- .claude/CLAUDE.md | 1 + .github/workflows/tests.yml | 30 +- .github/workflows/update-dependencies.yml | 1 + package.json | 2 + src/Controller/Controller_Font_Downloader.php | 1 + .../Controller_Upgrade_Routines.php | 21 +- src/Model/Model_Actions.php | 33 +- src/Model/Model_Font_Downloader.php | 198 +++++++--- src/Model/Model_Font_Sources.php | 35 +- src/Model/Model_Required_Fonts.php | 83 +++-- src/Model/Model_Uninstall.php | 40 +- .../phpunit/Concerns/RecordsFontFailures.php | 47 ++- tests/phpunit/Concerns/StubsFontSources.php | 4 +- tests/phpunit/Concerns/SwitchesNetwork.php | 34 ++ .../Controller/Test_Controller_Actions.php | 83 ++++- .../Test_Controller_Font_Downloader.php | 1 + .../Test_Controller_Upgrade_Routines.php | 91 +++-- .../Model/Test_Model_Font_Downloader.php | 348 ++++++++++++++++-- .../Model/Test_Model_Font_Sources.php | 54 ++- .../Model/Test_Model_Required_Fonts.php | 222 +++++++++++ .../integration/Model/Test_Uninstaller.php | 90 ++++- .../core/system-status/core-fonts.spec.ts | 8 +- .../multisite/failed-fonts-notice.spec.ts | 141 +++++++ ...on-every-site-and-in-the-network-admin.png | Bin 0 -> 26412 bytes tools/mu-plugins/gravitypdf.php | 15 +- tools/playwright/config-multisite.ts | 51 +++ tools/playwright/multisite-setup.ts | 32 ++ tools/playwright/utils/multisite.ts | 2 + tools/playwright/utils/notices.ts | 9 + tools/playwright/utils/wp-cli.ts | 7 +- tools/wp-env/e2e-multisite.json | 26 ++ tools/wp-env/wp-version.mjs | 3 +- 32 files changed, 1467 insertions(+), 246 deletions(-) create mode 100644 tests/phpunit/Concerns/SwitchesNetwork.php create mode 100644 tests/playwright/multisite/failed-fonts-notice.spec.ts create mode 100644 tests/playwright/visual-baselines/multisite-multisite-failed-fonts-notice-failed-core-fonts-notice-on-a-multisite-should-list-the-fonts-that-gave-up-on-every-site-and-in-the-network-admin.png create mode 100644 tools/playwright/config-multisite.ts create mode 100644 tools/playwright/multisite-setup.ts create mode 100644 tools/playwright/utils/multisite.ts create mode 100644 tools/wp-env/e2e-multisite.json diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2fa9ad7d1..d2d5e869b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -47,6 +47,7 @@ composer lint:fix # PHPCS auto-fix yarn wp-env:e2e start # Start dedicated E2E environment (port 8702) yarn test:e2e # Run all Playwright tests (headless) yarn test:e2e:debug # Open Playwright UI for interactive debugging +yarn test:e2e:multisite # Run tests/playwright/multisite/ on its own Multisite wp-env (port 8704), started by the command ``` E2E tests live in `tests/playwright/` and run against a single wp-env instance on port 8702, in one Playwright invocation with every test in parallel (`fullyParallel`, 4 workers). The `setup-core` project (`tools/playwright/global-setup.ts`) logs in and puts the site on `/%postname%/` permalinks, then: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d4325d66e..e8a269bbd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -313,13 +313,18 @@ jobs: signature: ":robot: PHPUnit coverage report" e2e-playwright: - name: Playwright E2E Tests (shard ${{ matrix.shard }}/${{ strategy.job-total }}) + name: Playwright E2E Tests (${{ matrix.name }}) needs: prebuild runs-on: ubuntu-latest strategy: fail-fast: false matrix: - shard: [ 1, 2, 3, 4 ] + include: + - { id: '1', name: 'shard 1/4', env: 'wp-env:e2e', config: 'e2e', test: 'test:e2e -- --shard=1/4' } + - { id: '2', name: 'shard 2/4', env: 'wp-env:e2e', config: 'e2e', test: 'test:e2e -- --shard=2/4' } + - { id: '3', name: 'shard 3/4', env: 'wp-env:e2e', config: 'e2e', test: 'test:e2e -- --shard=3/4' } + - { id: '4', name: 'shard 4/4', env: 'wp-env:e2e', config: 'e2e', test: 'test:e2e -- --shard=4/4' } + - { id: 'multisite', name: 'Multisite', env: 'wp-env:e2e-multisite', config: 'e2e-multisite', test: 'test:e2e:multisite' } steps: - name: Checkout repository @@ -329,6 +334,7 @@ jobs: uses: ./.github/actions/setup-e2e with: php-version: 7.4 + wp-env-config: tools/wp-env/${{ matrix.config }}.json - name: Log debug information run: | @@ -350,24 +356,24 @@ jobs: path: | ~/wp-env ~/.wp-env - key: wp-env-e2e-v2-${{ runner.os }}-${{ hashFiles('tools/wp-env/e2e.json', 'composer.lock') }} + key: wp-env-${{ matrix.config }}-v2-${{ runner.os }}-${{ hashFiles(format('tools/wp-env/{0}.json', matrix.config), 'composer.lock') }} - name: Install / Setup Gravity PDF + WordPress - run: yarn wp-env:e2e start + run: yarn ${{ matrix.env }} 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. # A bare `wp core install` leaves the env half-configured (GF REST never enables), so run wp-env's full reset. - name: Ensure WordPress is installed (wp-env cache fallback) run: | - if yarn wp-env:e2e run cli wp core is-installed >/dev/null 2>&1; then + if yarn ${{ matrix.env }} run cli wp core is-installed >/dev/null 2>&1; then echo "WordPress already installed." else echo "wp-env work-dir cache hit a fresh DB volume — running wp-env reset for a full install." - yarn wp-env:e2e reset + yarn ${{ matrix.env }} reset fi - name: Run the tests - run: yarn test:e2e -- --shard=${{ matrix.shard }}/${{ strategy.job-total }} + run: yarn ${{ matrix.test }} env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true @@ -376,24 +382,24 @@ jobs: if: success() run: | mkdir -p tmp/visual/current - tar -C tmp/visual -cf "$RUNNER_TEMP/visual-shots-${{ matrix.shard }}.tar" current + tar -C tmp/visual -cf "$RUNNER_TEMP/visual-shots-${{ matrix.id }}.tar" current - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: success() with: - name: visual-shots-${{ matrix.shard }} - path: ${{ runner.temp }}/visual-shots-${{ matrix.shard }}.tar + name: visual-shots-${{ matrix.id }} + path: ${{ runner.temp }}/visual-shots-${{ matrix.id }}.tar compression-level: 0 - 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" + run: yarn ${{ matrix.env }} 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: failure() with: - name: failure-artifacts-shard-${{ matrix.shard }} + name: failure-artifacts-shard-${{ matrix.id }} path: tmp/artifacts addon-smoke: diff --git a/.github/workflows/update-dependencies.yml b/.github/workflows/update-dependencies.yml index 15a2825e2..aef655e58 100644 --- a/.github/workflows/update-dependencies.yml +++ b/.github/workflows/update-dependencies.yml @@ -115,6 +115,7 @@ jobs: tools/wp-env/development.json tools/wp-env/integration.json tools/wp-env/e2e.json + tools/wp-env/e2e-multisite.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 }} diff --git a/package.json b/package.json index 06f71ae8f..8689633dd 100644 --- a/package.json +++ b/package.json @@ -64,12 +64,14 @@ "wp-env": "WP_ENV_PORT=8700 wp-env --config=./tools/wp-env/development.json", "wp-env:integration": "WP_ENV_PORT=8701 wp-env --config=./tools/wp-env/integration.json", "wp-env:e2e": "WP_ENV_PORT=8702 wp-env --config=./tools/wp-env/e2e.json", + "wp-env:e2e-multisite": "WP_ENV_PORT=8704 wp-env --config=./tools/wp-env/e2e-multisite.json", "wp-env:addons": "WP_ENV_PORT=8703 wp-env --config=./tmp/addons/wp-env.json", "test:php": "yarn wp-env:integration run wordpress env PHP_IDE_CONFIG=serverName=localhost /var/www/html/wp-content/plugins/gravity-pdf/vendor/bin/phpunit -- -c /var/www/html/wp-content/plugins/gravity-pdf/tools/phpunit/config.xml", "test:php:snapshots": "yarn wp-env:integration run wordpress env GPDF_UPDATE_SNAPSHOTS=1 /var/www/html/wp-content/plugins/gravity-pdf/vendor/bin/phpunit -- -c /var/www/html/wp-content/plugins/gravity-pdf/tools/phpunit/config.xml --group snapshot", "test:php:multisite": "yarn wp-env:integration run wordpress env PHP_IDE_CONFIG=serverName=localhost /var/www/html/wp-content/plugins/gravity-pdf/vendor/bin/phpunit -- -c /var/www/html/wp-content/plugins/gravity-pdf/tools/phpunit/config-multisite.xml", "test:e2e": "wp-scripts test-playwright --config tools/playwright/config.ts", "test:e2e:debug": "wp-scripts test-playwright --config tools/playwright/config.ts --ui", + "test:e2e:multisite": "wp-scripts test-playwright --config tools/playwright/config-multisite.ts", "visual:compare": "LOST_PIXEL_CONFIG_DIR=tools/lost-pixel LOST_PIXEL_DISABLE_TELEMETRY=1 lost-pixel", "visual:prune": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON tools/lost-pixel/prune-unchanged-baselines.mts", "test:js": "jest --verbose", diff --git a/src/Controller/Controller_Font_Downloader.php b/src/Controller/Controller_Font_Downloader.php index a68870750..442334c9a 100644 --- a/src/Controller/Controller_Font_Downloader.php +++ b/src/Controller/Controller_Font_Downloader.php @@ -72,6 +72,7 @@ public function init() { */ public function add_actions() { add_action( Model_Font_Downloader::SYNC_EVENT, [ $this->model, 'sync' ] ); + add_action( Model_Font_Downloader::CLEANUP_EVENT, [ $this->model, 'cleanup' ] ); add_action( 'gfpdf_plugin_installed', [ $this->model, 'schedule_sync' ] ); add_action( 'gfpdf_version_changed', [ $this->model, 'schedule_sync' ] ); diff --git a/src/Controller/Controller_Upgrade_Routines.php b/src/Controller/Controller_Upgrade_Routines.php index 3fa02e57d..b0ff4ad9c 100644 --- a/src/Controller/Controller_Upgrade_Routines.php +++ b/src/Controller/Controller_Upgrade_Routines.php @@ -102,11 +102,10 @@ public function maybe_run_upgrade( $old_version, $current_version ): void { /* A site upgraded from before Arimo was the default keeps DejaVu Sans Condensed, even if it never saved one */ if ( $old_version !== '' && version_compare( $old_version, '6.19.0', '<' ) ) { - $this->save_unset_default_font( 'dejavusanscondensed' ); + $this->save_unset_default_font( Model_Font_Downloader::PRE_6_19_DEFAULT_FONT ); $this->adopt_font_source_records(); - /* After the default font save, so the font it saves is kept */ - $this->remove_unneeded_core_fonts(); + $this->schedule_core_font_cleanup(); } if ( version_compare( $current_version, '7.0.0-beta1', '>=' ) && version_compare( $old_version, '7.0.0-beta1', '<' ) ) { @@ -180,22 +179,16 @@ protected function adopt_font_source_records(): void { } /** - * Delete the core fonts a new install wouldn't have; any still needed download again on use. A network shares one - * fonts folder, so a Multisite prunes it once, for the site whose request runs the upgrade. + * Queue the removal of the core fonts a new install wouldn't have. It keeps the fonts every site needs and runs in + * the background, as every site on every network shares the fonts folder. * * @since 6.19.0 */ - protected function remove_unneeded_core_fonts(): void { + protected function schedule_core_font_cleanup(): void { $downloader = \GPDFAPI::get_mvc_class( 'Model_Font_Downloader' ); - if ( ! $downloader instanceof Model_Font_Downloader ) { - return; + if ( $downloader instanceof Model_Font_Downloader ) { + $downloader->schedule_cleanup(); } - - if ( is_multisite() && ! add_site_option( Model_Font_Downloader::PRUNED, true ) ) { - return; - } - - $downloader->prune(); } /** diff --git a/src/Model/Model_Actions.php b/src/Model/Model_Actions.php index 2975bf5ed..8bc3f3fe2 100644 --- a/src/Model/Model_Actions.php +++ b/src/Model/Model_Actions.php @@ -57,13 +57,6 @@ protected static function get_removed_methods(): array { */ const UNWRITABLE_FOLDER_DISMISSAL = 'unwritable_folder_'; - /** - * The prefix each failed core font's dismissal is recorded under in `action_dismissal` - * - * @since 6.19.0 - */ - const FAILED_CORE_FONT_DISMISSAL = 'failed_core_font_'; - /** * How long dismissing the unwritable folders notice hides a folder that keeps failing * @@ -296,22 +289,13 @@ protected function get_unwritable_folder_dismissal( string $dir ): string { } /** - * The core fonts that failed to download after their retry, less the ones dismissed since + * The core fonts that failed to download after their retry, less the ones dismissed * * @return string[] * @since 6.19.0 */ public function get_undismissed_failed_core_fonts(): array { - $files = []; - - /* A dismissal covers only the failure it followed */ - foreach ( $this->get_downloader()->get_failed_files() as $file => $gave_up ) { - if ( ! $this->is_notice_already_dismissed( $this->get_failed_core_font_dismissal( $file ), $gave_up ) ) { - $files[] = $file; - } - } - - return $files; + return $this->get_downloader()->get_undismissed_failed_files(); } /** @@ -324,12 +308,12 @@ public function has_failed_core_fonts(): bool { } /** - * Dismiss each listed font; one that fails later raises the notice again + * Dismiss the listed fonts for every site, as they share the fonts folder; one that fails later raises it again * * @since 6.19.0 */ public function dismiss_failed_core_fonts(): void { - $this->dismiss_notices( array_map( [ $this, 'get_failed_core_font_dismissal' ], $this->get_undismissed_failed_core_fonts() ) ); + $this->get_downloader()->dismiss_failed_files(); } /** @@ -341,15 +325,6 @@ protected function get_downloader(): Model_Font_Downloader { return $this->downloader ?? GPDFAPI::get_mvc_class( 'Model_Font_Downloader' ); } - /** - * The ID a failed core font's dismissal is recorded under in `action_dismissal` - * - * @since 6.19.0 - */ - protected function get_failed_core_font_dismissal( string $file ): string { - return static::FAILED_CORE_FONT_DISMISSAL . md5( $file ); - } - /** * Send the user to the system report, which lists every detection behind the notice * diff --git a/src/Model/Model_Font_Downloader.php b/src/Model/Model_Font_Downloader.php index 9f3b0bc64..4e9cd1f32 100644 --- a/src/Model/Model_Font_Downloader.php +++ b/src/Model/Model_Font_Downloader.php @@ -45,14 +45,16 @@ class Model_Font_Downloader extends Helper_Abstract_Model { ]; /** - * Failed files awaiting their one retry, and those that gave up, which the admin notice lists + * Failed files awaiting their one retry, and those that gave up, which the admin notice lists. An option of the + * main network, as every site shares the fonts folder * * @since 6.19.0 */ const FAILURES = 'gfpdf_core_font_failures'; /** - * Prefix of the per-file transient holding when a download last failed, which skips the network until it expires + * Prefix of the per-file site transient holding when a download last failed, which skips the network until it + * expires * * @since 6.19.0 */ @@ -92,11 +94,25 @@ class Model_Font_Downloader extends Helper_Abstract_Model { const SYNC_BUDGET = 20; /** - * Set once a Multisite network's shared fonts folder is pruned on the upgrade to 6.19 + * Main network option holding the 6.19 cleanup's progress while it runs, then true once it's done * * @since 6.19.0 */ - const PRUNED = 'gfpdf_core_font_pruned'; + const CLEANUP = 'gfpdf_core_font_cleanup'; + + /** + * The default font of a site upgraded from before 6.19 with none saved + * + * @since 6.19.0 + */ + const PRE_6_19_DEFAULT_FONT = 'dejavusanscondensed'; + + /** + * Cron event that reads every site's fonts a batch at a time, then cleans the shared fonts folder + * + * @since 6.19.0 + */ + const CLEANUP_EVENT = 'gfpdf_core_font_cleanup_run'; /** * @var Model_Font_Sources @@ -129,9 +145,10 @@ class Model_Font_Downloader extends Helper_Abstract_Model { protected $log; /** - * When each failed file's retry is due (else 0) and, once the retry also failed, when it gave up (else 0) + * When each failed file's retry is due (else 0), once the retry also failed when it gave up (else 0), and whether + * an admin dismissed the notice listing it * - * @var array|null + * @var array|null * @since 6.19.0 */ protected $failures; @@ -298,13 +315,14 @@ public function download( string $file ): bool { * Delete the unmodified files (fonts and licences) the Core Font installer downloaded that aren't kept. A file whose * download is turned off stays, as it couldn't come back. Only files of the installer's size are hashed. * - * @param string[]|null $keep File names to keep, else the fonts the site's PDFs need + * @param string[]|null $keep File names to keep, else the fonts the site's PDFs need + * @param int $since Also keep files written after this time, as a download since then may be needed * * @return string[] The files removed * * @since 6.19.0 */ - public function prune( ?array $keep = null ): array { + public function prune( ?array $keep = null, int $since = 0 ): array { $manifest = $this->font_sources->get_legacy_manifest( 'core' ); /* Without the manifest nothing is known to be unmodified */ @@ -327,7 +345,7 @@ public function prune( ?array $keep = null ): array { $path = $this->get_font_path( $file ); clearstatcache( true, $path ); - if ( ! is_file( $path ) || filesize( $path ) !== $entry['size'] ) { + if ( ! is_file( $path ) || filesize( $path ) !== $entry['size'] || ( $since > 0 && filemtime( $path ) > $since ) ) { continue; } @@ -860,6 +878,40 @@ public function get_failed_files(): array { return $failed; } + /** + * Failed files no admin has dismissed the notice for + * + * @return string[] + * + * @since 6.19.0 + */ + public function get_undismissed_failed_files(): array { + $undismissed = array_filter( + $this->get_failures(), + static function ( array $failure ): bool { + return $failure['gave_up'] > 0 && ! $failure['dismissed']; + } + ); + + return array_map( 'strval', array_keys( $undismissed ) ); + } + + /** + * Dismiss the notice for every failed file, on every site. A file that downloads, then fails again, is listed anew + * + * @since 6.19.0 + */ + public function dismiss_failed_files(): void { + $failures = $this->get_failures(); + foreach ( $failures as $file => $failure ) { + if ( $failure['gave_up'] > 0 ) { + $failures[ $file ]['dismissed'] = true; + } + } + + $this->save_failures( $failures ); + } + /** * Forget the failures read this request * @@ -887,11 +939,6 @@ public function schedule_sync(): void { /* The trigger may have changed the required set */ delete_transient( self::SYNC_REMAINING ); - /* Stored empty, so the notice check reads it from the autoloaded options rather than querying for it */ - if ( get_site_option( self::FAILURES ) === false ) { - $this->save_failures( [] ); - } - $this->schedule_by( time() ); } @@ -910,7 +957,7 @@ public function sync(): void { array_map( [ $this, 'delete_back_off' ], $due ); $this->syncing = true; - $left = $this->download_within_budget( array_merge( $due, $missing ) ); + $left = $this->within_budget( array_merge( $due, $missing ), [ $this, 'download' ] ); $this->syncing = false; /* Includes the retries this run's first failures added */ @@ -931,45 +978,108 @@ public function sync(): void { } /** - * Have the sync run by the time given: queue it, or move one queued later forward. Never two, never later. + * Queue the 6.19 cleanup of the shared fonts folder, once per install + * + * @since 6.19.0 + */ + public function schedule_cleanup(): void { + if ( get_network_option( get_main_network_id(), self::CLEANUP ) !== false ) { + return; + } + + update_network_option( + get_main_network_id(), + self::CLEANUP, + [ + 'started' => time(), + 'sites' => $this->required_fonts->get_site_ids(), + 'keys' => [], + ] + ); + + $this->schedule_by( time(), self::CLEANUP_EVENT ); + } + + /** + * Read the fonts of the sites left until the time budget runs out, then clean the fonts folder once all are read * * @since 6.19.0 */ - protected function schedule_by( int $at ): void { - $next = wp_next_scheduled( self::SYNC_EVENT ); + public function cleanup(): void { + $state = get_network_option( get_main_network_id(), self::CLEANUP ); + if ( ! is_array( $state ) ) { + return; + } + + if ( ! is_array( $state['sites'] ?? null ) || ! is_array( $state['keys'] ?? null ) || ! is_int( $state['started'] ?? null ) ) { + $this->log->warning( 'Core font cleanup skipped: its stored progress is malformed' ); + + return; + } + + $keys = array_fill_keys( $state['keys'], true ); + $left = $this->within_budget( + $state['sites'], + function ( $site_id ) use ( &$keys ) { + /* Sites yet to upgrade will save it as their default when they do */ + $keys += array_fill_keys( $this->required_fonts->get_site_font_keys( (int) $site_id, self::PRE_6_19_DEFAULT_FONT ), true ); + } + ); + $keys = array_map( 'strval', array_keys( $keys ) ); + + if ( $left !== [] ) { + $state['sites'] = $left; + $state['keys'] = $keys; + update_network_option( get_main_network_id(), self::CLEANUP, $state ); + $this->schedule_by( time(), self::CLEANUP_EVENT ); + + return; + } + + $this->prune( $this->required_fonts->get_install_font_files( $keys ), $state['started'] ); + update_network_option( get_main_network_id(), self::CLEANUP, true ); + } + + /** + * Have the event run by the time given: queue it, or move one queued later forward. Never two, never later. + * + * @since 6.19.0 + */ + protected function schedule_by( int $at, string $event = self::SYNC_EVENT ): void { + $next = wp_next_scheduled( $event ); if ( $next !== false && $next <= $at ) { return; } if ( $next !== false ) { - wp_unschedule_event( $next, self::SYNC_EVENT ); + wp_unschedule_event( $next, $event ); } - if ( wp_schedule_single_event( $at, self::SYNC_EVENT ) !== true ) { - $this->log->warning( 'Font sync could not be scheduled' ); + if ( wp_schedule_single_event( $at, $event ) !== true ) { + $this->log->warning( 'Font event could not be scheduled', [ 'event' => $event ] ); } } /** - * Download each file until the time budget runs out + * Run the callback on each item until the time budget runs out * - * @param string[] $files + * @param mixed[] $items * - * @return string[] The files it didn't reach + * @return mixed[] The items it didn't reach * * @since 6.19.0 */ - protected function download_within_budget( array $files ): array { - $files = array_values( $files ); + protected function within_budget( array $items, callable $each ): array { + $items = array_values( $items ); $deadline = microtime( true ) + $this->sync_budget; - foreach ( $files as $i => $file ) { - /* At least one file per run, so slow hosts make progress */ + foreach ( $items as $i => $item ) { + /* At least one per run, so slow hosts make progress */ if ( $i > 0 && microtime( true ) >= $deadline ) { - return array_slice( $files, $i ); + return array_slice( $items, $i ); } - $this->download( $file ); + $each( $item ); } return []; @@ -1164,8 +1274,9 @@ protected function record_failure( string $file ): void { if ( $failure === null ) { $failures[ $file ] = [ - 'retry_at' => time() + wp_rand( 0, $this->retry_jitter ), - 'gave_up' => 0, + 'retry_at' => time() + wp_rand( 0, $this->retry_jitter ), + 'gave_up' => 0, + 'dismissed' => false, ]; $this->save_failures( $failures ); @@ -1179,10 +1290,11 @@ protected function record_failure( string $file ): void { } if ( $failure['gave_up'] === 0 ) { - /* First give-up time, which notice dismissals are compared against */ + /* The first give-up, which the notice lists */ $failures[ $file ] = [ - 'retry_at' => 0, - 'gave_up' => time(), + 'retry_at' => 0, + 'gave_up' => time(), + 'dismissed' => false, ]; $this->save_failures( $failures ); @@ -1214,23 +1326,20 @@ protected function delete_back_off( string $file ): void { } /** - * Network-wide like the fonts folder, and kept when empty - * - * @param array $failures + * @param array $failures * * @since 6.19.0 */ protected function save_failures( array $failures ): void { $this->failures = $failures; - /* Autoloaded on a single site, as the notice checks it on most admin pages */ - is_multisite() ? update_site_option( self::FAILURES, $failures ) : update_option( self::FAILURES, $failures, true ); + update_network_option( get_main_network_id(), self::FAILURES, $failures ); } /** * Failures by file, read once per request * - * @return array + * @return array * * @since 6.19.0 */ @@ -1238,7 +1347,7 @@ protected function get_failures(): array { if ( $this->failures === null ) { $this->failures = []; - $stored = get_site_option( self::FAILURES, [] ); + $stored = get_network_option( get_main_network_id(), self::FAILURES, [] ); foreach ( is_array( $stored ) ? $stored : [] as $file => $failure ) { $retry_at = is_array( $failure ) && is_int( $failure['retry_at'] ?? null ) ? $failure['retry_at'] : 0; $gave_up = is_array( $failure ) && is_int( $failure['gave_up'] ?? null ) ? $failure['gave_up'] : 0; @@ -1246,8 +1355,9 @@ protected function get_failures(): array { /* An entry with neither is waiting on nothing */ if ( is_string( $file ) && ( $retry_at > 0 || $gave_up > 0 ) ) { $this->failures[ $file ] = [ - 'retry_at' => $gave_up > 0 ? 0 : $retry_at, - 'gave_up' => $gave_up, + 'retry_at' => $gave_up > 0 ? 0 : $retry_at, + 'gave_up' => $gave_up, + 'dismissed' => ( $failure['dismissed'] ?? false ) === true, ]; } } diff --git a/src/Model/Model_Font_Sources.php b/src/Model/Model_Font_Sources.php index 730166388..c3bb0d38a 100644 --- a/src/Model/Model_Font_Sources.php +++ b/src/Model/Model_Font_Sources.php @@ -61,7 +61,7 @@ class Model_Font_Sources extends Helper_Abstract_Model { public const SUBSTITUTION_CORE_FONTS = [ 'dejavusanscondensed', 'freesans', 'garuda', 'khmeros', 'padaukbook', 'kaputaunicode', 'abyssinicasil' ]; /** - * The transient holding the language font keys with the locale they were mapped for + * The site transient holding the installed language packs' font keys, which every site shares * * @since 6.19.0 */ @@ -350,41 +350,30 @@ public function flush(): void { $this->font_data = null; $this->language_font_keys = null; $this->language_font_locales = null; - delete_transient( self::LANGUAGE_FONT_KEYS ); + delete_site_transient( self::LANGUAGE_FONT_KEYS ); } /** - * The mPDF font keys for the installed languages and the locale, cached for a day so a render doesn't scan the - * languages folder + * The mPDF font keys for the installed languages (cached for a day, network-wide) and this site's locale * * @return string[] * * @since 6.19.0 */ public function get_language_font_keys(): array { - if ( $this->language_font_keys !== null ) { - return $this->language_font_keys; - } + if ( $this->language_font_keys === null ) { + $keys = get_site_transient( self::LANGUAGE_FONT_KEYS ); + if ( ! is_array( $keys ) ) { + $keys = array_keys( $this->map_language_font_locales( (array) get_available_languages() ) ); + set_site_transient( self::LANGUAGE_FONT_KEYS, $keys, DAY_IN_SECONDS ); + } - $locale = get_locale(); - $cached = get_transient( self::LANGUAGE_FONT_KEYS ); - if ( is_array( $cached ) && ( $cached['locale'] ?? null ) === $locale && is_array( $cached['keys'] ?? null ) ) { - $this->language_font_keys = array_values( array_filter( $cached['keys'], 'is_string' ) ); + /* Per site, and may be set without its language pack */ + $locale_keys = array_keys( $this->map_language_font_locales( [ get_locale() ] ) ); - return $this->language_font_keys; + $this->language_font_keys = array_values( array_unique( array_merge( array_filter( $keys, 'is_string' ), $locale_keys ) ) ); } - $this->language_font_keys = array_map( 'strval', array_keys( $this->get_language_font_locales() ) ); - - set_transient( - self::LANGUAGE_FONT_KEYS, - [ - 'locale' => $locale, - 'keys' => $this->language_font_keys, - ], - DAY_IN_SECONDS - ); - return $this->language_font_keys; } diff --git a/src/Model/Model_Required_Fonts.php b/src/Model/Model_Required_Fonts.php index b17218bb5..fdbd7fd84 100644 --- a/src/Model/Model_Required_Fonts.php +++ b/src/Model/Model_Required_Fonts.php @@ -114,8 +114,34 @@ public function get_font_files( $key ): array { * @since 6.19.0 */ public function get_required_font_files(): array { + return $this->get_key_files( array_keys( $this->get_font_reasons() ) ); + } + + /** + * The files of the fonts sites need, plus the installed languages', as every site shares the fonts folder + * + * @param string[] $keys Font keys from get_site_font_keys() + * + * @return string[] + * + * @since 6.19.0 + */ + public function get_install_font_files( array $keys ): array { + return $this->get_key_files( array_merge( $this->font_sources->get_language_font_keys(), $keys ) ); + } + + /** + * The files of the font keys, plus every required source's + * + * @param array $keys + * + * @return string[] + * + * @since 6.19.0 + */ + protected function get_key_files( array $keys ): array { $files = []; - foreach ( array_keys( $this->get_font_reasons() ) as $key ) { + foreach ( $keys as $key ) { $files = array_merge( $files, $this->get_font_files( (string) $key ) ); } @@ -435,47 +461,38 @@ protected function get_other_site_font_counts(): array { } /** - * @return int[] Every other site of the network, or none when not a Multisite + * @return int[] Every other site, across all networks * * @since 6.19.0 */ protected function get_other_site_ids(): array { - if ( ! is_multisite() ) { - return []; - } - - return array_map( - 'intval', - get_sites( - [ - 'fields' => 'ids', - 'number' => 0, - 'site__not_in' => [ get_current_blog_id() ], - ] - ) - ); + return array_values( array_diff( $this->get_site_ids(), [ get_current_blog_id() ] ) ); } /** * The fonts a site uses (its default font, locale and PDF fonts), read from its tables by prefix rather than by * switching to it * + * @param string $unset_default The default font of a site with none saved + * * @return string[] Font keys * * @since 6.19.0 */ - public function get_site_font_keys( int $site_id ): array { - return array_map( 'strval', array_keys( iterator_to_array( $this->each_site_font_key( $site_id ) ) ) ); + public function get_site_font_keys( int $site_id, string $unset_default = '' ): array { + return array_map( 'strval', array_keys( iterator_to_array( $this->each_site_font_key( $site_id, $unset_default ) ) ) ); } /** * Each font key a site uses, the settings' before its forms' so a caller can stop without reading them * + * @param string $unset_default The default font of a site with none saved + * * @return \Generator Keyed by font key, which may repeat * * @since 6.19.0 */ - protected function each_site_font_key( int $site_id ): \Generator { + protected function each_site_font_key( int $site_id, string $unset_default = '' ): \Generator { global $wpdb; $prefix = $wpdb->get_blog_prefix( $site_id ); @@ -483,11 +500,10 @@ protected function each_site_font_key( int $site_id ): \Generator { $options = wp_list_pluck( (array) $options, 'option_value' ); $settings = maybe_unserialize( $options['gfpdf_settings'] ?? '' ); - $default = is_array( $settings ) ? ( $settings['default_font'] ?? '' ) : ''; + $default = $this->normalise_font( is_array( $settings ) ? ( $settings['default_font'] ?? '' ) : '' ); $locales = $this->font_sources->map_language_font_locales( [ $options['WPLANG'] ?? '' ] ); - foreach ( array_merge( array_map( 'strval', array_keys( $locales ) ), [ $default ] ) as $key ) { - $key = $this->normalise_font( $key ); + foreach ( array_merge( array_map( 'strval', array_keys( $locales ) ), [ $default !== '' ? $default : $unset_default ] ) as $key ) { if ( $key !== '' ) { yield $key => true; } @@ -499,6 +515,29 @@ protected function each_site_font_key( int $site_id ): \Generator { } } + /** + * Every site's ID, across all networks + * + * @return int[] + * + * @since 6.19.0 + */ + public function get_site_ids(): array { + if ( ! is_multisite() ) { + return [ get_current_blog_id() ]; + } + + return array_map( + 'intval', + get_sites( + [ + 'fields' => 'ids', + 'number' => 0, + ] + ) + ); + } + /** * A page of the stored meta of the active forms with PDFs, read raw so Gravity Forms doesn't build their fields. * Pages by ID, as OFFSET would run the LIKE over every form it skips. The LIKE compares bytes, about 6x faster than diff --git a/src/Model/Model_Uninstall.php b/src/Model/Model_Uninstall.php index 2f14e10c5..61850d8b0 100644 --- a/src/Model/Model_Uninstall.php +++ b/src/Model/Model_Uninstall.php @@ -114,14 +114,16 @@ public function uninstall_plugin() { $this->remove_plugin_network_options(); } else { $this->remove_plugin_transients(); - $this->remove_font_back_offs(); $this->remove_plugin_options(); $this->remove_plugin_form_settings(); } + $this->remove_font_state(); + /* Removes background processes */ $this->queue->clear_scheduled_events(); wp_clear_scheduled_hook( Model_Font_Downloader::SYNC_EVENT ); + wp_clear_scheduled_hook( Model_Font_Downloader::CLEANUP_EVENT ); $this->queue->clear_queue( true ); $this->queue->unlock_process(); @@ -166,27 +168,30 @@ public function remove_plugin_transients() { } delete_transient( Model_Font_Downloader::SYNC_REMAINING ); - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); $templates = \GPDFAPI::get_templates_class(); $templates->flush_template_transient_cache(); } /** - * Remove the font downloads' back-offs, which a single site keeps in its options table (a Multisite in sitemeta) + * Remove the font state every site shares: the failed downloads and their back-offs, the language fonts and the + * 6.19 cleanup's progress * * @since 6.19.0 */ - public function remove_font_back_offs() { - global $wpdb; + public function remove_font_state(): void { + delete_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ); + delete_network_option( get_main_network_id(), Model_Font_Downloader::CLEANUP ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); - /* phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching */ - $keys = $wpdb->get_col( - $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s", $wpdb->esc_like( '_site_transient_' . Model_Font_Downloader::BACK_OFF_KEY ) . '%' ) - ); + $font_sources = \GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + if ( ! $font_sources instanceof Model_Font_Sources ) { + return; + } - foreach ( $keys as $key ) { - delete_site_transient( substr( $key, strlen( '_site_transient_' ) ) ); + /* Only the manifest's files are downloaded, so only they back off */ + foreach ( array_keys( $font_sources->get_manifest() ) as $file ) { + delete_site_transient( Model_Font_Downloader::back_off_key( (string) $file ) ); } } @@ -205,7 +210,6 @@ public function remove_plugin_options() { delete_option( Model_Pdf_Cache::SWEEP_STATE_OPTION ); delete_option( Model_Pdf_Cache::PURGE_REQUEST_OPTION ); delete_option( Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS ); /* A Multisite keeps it in sitemeta */ - delete_option( Model_Font_Downloader::FAILURES ); /* A Multisite keeps it in sitemeta */ Deprecation::delete_stored_data(); /* Remove license API data. Deleting one by one, not with a raw DELETE, lets WordPress drop its cached copies */ @@ -222,8 +226,8 @@ public function remove_plugin_options() { } /** - * Remove the network options stored in sitemeta on a Multisite: license package caches, the rich text merge - * tags flag, the failed font downloads and their back-offs, and the 6.19 core font prune flag + * Remove the network options stored in sitemeta on a Multisite: license package caches and the rich text merge + * tags flag. * * Every extension keeps its own license cache entry (see EDD_SL_Plugin_Updater::get_network_cache_key()), but all * are network-scoped rather than per-site, so one pass clears them, and the flag's exact key, without walking the @@ -241,13 +245,9 @@ public function remove_plugin_network_options() { /* phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching */ $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT site_id, meta_key FROM $wpdb->sitemeta WHERE meta_key LIKE %s OR meta_key LIKE %s OR meta_key LIKE %s OR meta_key IN ( %s, %s, %s )", + "SELECT site_id, meta_key FROM $wpdb->sitemeta WHERE meta_key LIKE %s OR meta_key = %s", $wpdb->esc_like( 'gpdf_sl_net_' ) . '%', - $wpdb->esc_like( '_site_transient_' . Model_Font_Downloader::BACK_OFF_KEY ) . '%', - $wpdb->esc_like( '_site_transient_timeout_' . Model_Font_Downloader::BACK_OFF_KEY ) . '%', - Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS, - Model_Font_Downloader::FAILURES, - Model_Font_Downloader::PRUNED + Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS ) ); diff --git a/tests/phpunit/Concerns/RecordsFontFailures.php b/tests/phpunit/Concerns/RecordsFontFailures.php index 98253f8aa..acb5f062a 100644 --- a/tests/phpunit/Concerns/RecordsFontFailures.php +++ b/tests/phpunit/Concerns/RecordsFontFailures.php @@ -19,22 +19,23 @@ trait RecordsFontFailures { * @param int $gave_up When its retry also failed, or 0 while the retry is pending * @param int|null $retry_at When the pending retry is due, defaulting to now * - * @return array{retry_at: int, gave_up: int} + * @return array{retry_at: int, gave_up: int, dismissed: bool} */ protected function failure( int $gave_up = 0, ?int $retry_at = null ): array { return [ - 'retry_at' => $gave_up > 0 ? 0 : ( $retry_at ?? time() ), - 'gave_up' => $gave_up, + 'retry_at' => $gave_up > 0 ? 0 : ( $retry_at ?? time() ), + 'gave_up' => $gave_up, + 'dismissed' => false, ]; } /** - * Store the record where the downloader keeps it, and have the shared downloader read it afresh + * Store the record where the downloader keeps it, on the main network, and have the shared downloader read it afresh * * @param mixed $failures */ protected function set_font_failures( $failures ): void { - update_site_option( Model_Font_Downloader::FAILURES, $failures ); + update_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES, $failures ); GPDFAPI::get_mvc_class( 'Model_Font_Downloader' )->flush(); } @@ -44,7 +45,7 @@ protected function set_font_failures( $failures ): void { * @return mixed The stored record, or false */ protected function get_font_failures() { - return get_site_option( Model_Font_Downloader::FAILURES ); + return get_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ); } /** @@ -75,11 +76,43 @@ protected function get_back_off_expiry( string $file ): int { return (int) get_site_option( '_site_transient_timeout_' . Model_Font_Downloader::back_off_key( $file ) ); } + /** + * The stored 6.19 cleanup progress + * + * @return mixed + */ + protected function get_font_cleanup() { + return get_network_option( get_main_network_id(), Model_Font_Downloader::CLEANUP ); + } + + /** + * @param mixed $state + */ + protected function set_font_cleanup( $state ): void { + update_network_option( get_main_network_id(), Model_Font_Downloader::CLEANUP, $state ); + } + + /** + * Cleanup progress for the current site with nothing found yet, started after any fixture was written + * + * @param array $state Replaces the fields given + */ + protected function font_cleanup_state( array $state = [] ): array { + return array_merge( + [ + 'started' => time(), + 'sites' => [ get_current_blog_id() ], + 'keys' => [], + ], + $state + ); + } + /** * Delete the failure record, and have the shared downloader read it afresh */ protected function delete_font_failures(): void { - delete_site_option( Model_Font_Downloader::FAILURES ); + delete_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ); GPDFAPI::get_mvc_class( 'Model_Font_Downloader' )->flush(); } } diff --git a/tests/phpunit/Concerns/StubsFontSources.php b/tests/phpunit/Concerns/StubsFontSources.php index 847764344..95d5cd2de 100644 --- a/tests/phpunit/Concerns/StubsFontSources.php +++ b/tests/phpunit/Concerns/StubsFontSources.php @@ -19,7 +19,7 @@ trait StubsFontSources { * @param mixed $languages What get_available_languages() returns */ protected function set_languages( $languages ): void { - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); add_filter( 'get_available_languages', static function () use ( $languages ) { @@ -34,7 +34,7 @@ static function () use ( $languages ) { * @param mixed $locale What get_locale() returns */ protected function set_locale( $locale ): void { - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); add_filter( 'locale', static function () use ( $locale ) { diff --git a/tests/phpunit/Concerns/SwitchesNetwork.php b/tests/phpunit/Concerns/SwitchesNetwork.php new file mode 100644 index 000000000..30527a4b3 --- /dev/null +++ b/tests/phpunit/Concerns/SwitchesNetwork.php @@ -0,0 +1,34 @@ +network->create( + [ + 'domain' => 'other-network.test', + 'path' => '/', + ] + ); + $original = $current_site; + + $current_site = get_network( $network ); + + try { + $callback( $network ); + } finally { + $current_site = $original; + } + } +} diff --git a/tests/phpunit/integration/Controller/Test_Controller_Actions.php b/tests/phpunit/integration/Controller/Test_Controller_Actions.php index e95396daf..5cf43f396 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Actions.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Actions.php @@ -37,9 +37,14 @@ public function set_up(): void { } public function tear_down(): void { - unset( $_POST['gfpdf_action'], $_POST['gfpdf-dismiss-notice'], $_GET['page'] ); + unset( $_POST['gfpdf_action'], $_POST['gfpdf_action_failed_core_fonts'], $_POST['gfpdf-dismiss-notice'], $_GET['page'] ); $this->delete_font_failures(); + /* A subsite leaves these behind */ + global $wp_settings_errors, $wp_rewrite; + $wp_settings_errors = []; + $wp_rewrite->init(); + parent::tear_down(); } @@ -233,9 +238,12 @@ function () { $model->dismiss_failed_core_fonts(); $this->assertStringNotContainsString( 'could not download these fonts', $this->render_notices() ); + $this->assertTrue( $this->get_font_failures()['Garuda.ttf']['dismissed'] ); - /* A font that fails later raises it again, for that font alone */ - $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() - 10 ), 'FreeSans.ttf' => $this->failure( time() + 1 ) ] ); + /* A font that gives up later raises it again, for that font alone */ + $failures = $this->get_font_failures(); + $failures['FreeSans.ttf'] = $this->failure( time() ); + $this->set_font_failures( $failures ); $this->assertSame( [ 'FreeSans.ttf' ], $model->get_undismissed_failed_core_fonts() ); /* A font that downloads leaves the notice, which goes once none are left */ @@ -276,6 +284,75 @@ function () { ); } + public function test_dismissing_the_failed_core_fonts_notice_dismisses_it_on_every_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + global $gfpdf; + + $model = $gfpdf->singleton->get_class( 'Model_Actions' ); + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + $model->dismiss_failed_core_fonts(); + + switch_to_blog( self::factory()->blog->create() ); + try { + \GPDFAPI::get_mvc_class( 'Model_Font_Downloader' )->flush(); + $this->assertFalse( $model->has_failed_core_fonts() ); + } finally { + restore_current_blog(); + } + } + + public function test_the_dismiss_request_dismisses_the_failed_core_fonts_notice() { + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $this->post_failed_core_fonts_dismissal(); + $this->controller->route(); + + $this->assertTrue( $this->get_font_failures()['Garuda.ttf']['dismissed'] ); + } + + public function test_a_bad_nonce_leaves_the_failed_core_fonts_notice_undismissed() { + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $this->post_failed_core_fonts_dismissal( 'not-a-nonce' ); + $this->controller->route(); + + $this->assertFalse( $this->get_font_failures()['Garuda.ttf']['dismissed'] ); + } + + public function test_a_user_who_cannot_edit_the_settings_cannot_dismiss_the_failed_core_fonts_notice() { + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'editor' ] ) ); + + $this->post_failed_core_fonts_dismissal(); + + try { + $this->controller->route(); + $this->fail( 'The request should have been refused' ); + } catch ( \WPDieException $e ) { + $this->assertFalse( $this->get_font_failures()['Garuda.ttf']['dismissed'] ); + } + } + + public function test_a_dismiss_request_with_nothing_failed_stores_nothing() { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $this->post_failed_core_fonts_dismissal(); + $this->controller->route(); + + $this->assertFalse( $this->get_font_failures() ); + } + + private function post_failed_core_fonts_dismissal( ?string $nonce = null ): void { + $_POST['gfpdf_action'] = 'gfpdf_failed_core_fonts'; + $_POST['gfpdf_action_failed_core_fonts'] = $nonce ?? wp_create_nonce( 'gfpdf_action_failed_core_fonts' ); + $_POST['gfpdf-dismiss-notice'] = '1'; + } + /** * Run $test as a new user with the role, on the admin screen given, then restore the screen and the notices */ diff --git a/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php b/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php index 20bf103ff..0b47f893c 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php @@ -70,6 +70,7 @@ public function tear_down(): void { public function test_hooks() { $this->assertInstanceOf( Model_Font_Downloader::class, $this->model ); $this->assertSame( 10, has_action( Model_Font_Downloader::SYNC_EVENT, [ $this->model, 'sync' ] ) ); + $this->assertSame( 10, has_action( Model_Font_Downloader::CLEANUP_EVENT, [ $this->model, 'cleanup' ] ) ); $this->assertSame( 10, has_action( 'upgrader_process_complete', [ $this->controller, 'maybe_sync_translations' ] ) ); $this->assertSame( 10, has_action( 'add_option_gfpdf_settings', [ $this->controller, 'maybe_sync_default_font' ] ) ); $this->assertSame( 10, has_action( 'update_option_gfpdf_settings', [ $this->controller, 'maybe_sync_default_font' ] ) ); diff --git a/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php b/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php index b1540e90a..43a0c71ce 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php @@ -12,6 +12,7 @@ use GFPDF\Statics\Deprecation; use GFPDF\Tests\Concerns\CopiesFontFixtures; use GFPDF\Tests\Concerns\CreatesLegacyDownloadUrls; +use GFPDF\Tests\Concerns\RecordsFontFailures; use GFPDF\Tests\Concerns\UnregistersMvcClasses; use GFPDF\Tests\Integration\TestCase; use GFPDF_Vendor\Monolog\Handler\TestHandler; @@ -35,6 +36,7 @@ class Test_Controller_Upgrade_Routines extends TestCase { use CopiesFontFixtures; use CreatesLegacyDownloadUrls; + use RecordsFontFailures; use UnregistersMvcClasses; /** @@ -51,6 +53,11 @@ public function set_up(): void { public function tear_down(): void { $this->remove_font_fixtures(); + /* A subsite leaves these behind */ + global $wp_settings_errors, $wp_rewrite; + $wp_settings_errors = []; + $wp_rewrite->init(); + parent::tear_down(); } @@ -458,7 +465,7 @@ public function test_core_fonts_outside_the_required_set_are_removed_on_the_upgr $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); $this->copy_fixture( 'DejaVuSansCondensed.ttf' ); - do_action( 'gfpdf_version_changed', $old_version, $new_version ); + $this->upgrade( $old_version, $new_version ); $this->assertSame( ! $removed, is_file( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ) ); $this->assertFileExists( $this->font_path( 'DejaVuSansCondensed.ttf' ), 'The default font the upgrade saves is kept' ); @@ -473,12 +480,13 @@ public function data_core_font_cleanup(): array { ]; } - public function test_the_upgrade_removes_a_font_the_core_font_installer_downloaded() { - $this->copy_fixture( 'DejaVuSerifCondensed-v1.0.0.ttf', 'DejaVuSerifCondensed.ttf' ); + public function test_the_upgrade_leaves_the_core_font_cleanup_to_cron() { + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); - $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); } public function test_the_core_font_cleanup_keeps_the_required_fonts() { @@ -487,7 +495,7 @@ public function test_the_core_font_cleanup_keeps_the_required_fonts() { $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); $this->copy_fixture( 'DejaVuSansCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSansCondensed.ttf' ) ); @@ -498,40 +506,75 @@ public function test_the_core_font_cleanup_keeps_fonts_when_downloads_are_off() add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); } - public function test_the_core_font_cleanup_runs_once_per_network() { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Multisite only' ); - } - + public function test_the_core_font_cleanup_runs_once() { $this->set_installer_manifest(); $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); - $this->assertTrue( get_site_option( Model_Font_Downloader::PRUNED ) ); + $this->assertTrue( $this->get_font_cleanup() ); - /* Another site upgrading finds the folder already pruned */ + /* Another site upgrading, or this one again, finds the folder already cleaned */ $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); } - public function test_a_single_site_cleans_its_fonts_on_each_upgrade_to_6_19() { - if ( is_multisite() ) { - $this->markTestSkipped( 'Single site only' ); + public function test_the_core_font_cleanup_keeps_every_sites_fonts() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); } $this->set_installer_manifest(); - foreach ( [ 1, 2 ] as $run ) { - $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); - $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ), "Run $run" ); - } + $this->options->update_option( 'default_font', 'arimo' ); + update_blog_option( self::factory()->blog->create(), 'gfpdf_settings', [ 'default_font' => 'dejavuserifcondensed' ] ); + + /* A site yet to upgrade, with no default font saved, will save DejaVu Sans Condensed */ + self::factory()->blog->create(); + + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + $this->copy_fixture( 'DejaVuSansCondensed.ttf' ); - $this->assertFalse( get_site_option( Model_Font_Downloader::PRUNED ) ); + $this->upgrade( '6.18.0', '6.19.0' ); + + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertFileExists( $this->font_path( 'DejaVuSansCondensed.ttf' ) ); + } + + public function test_the_upgrade_queues_no_cleanup_without_the_downloader() { + $this->without_mvc_class( + 'Model_Font_Downloader', + static function () { + do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + } + ); + + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertFalse( $this->get_font_cleanup() ); + } + + public function test_another_sites_upgrade_leaves_a_cleanup_under_way_alone() { + $state = $this->font_cleanup_state( [ 'keys' => [ 'arimo' ] ] ); + $queued = time() + HOUR_IN_SECONDS; + + $this->set_font_cleanup( $state ); + wp_schedule_single_event( $queued, Model_Font_Downloader::CLEANUP_EVENT ); + + do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + + $this->assertSame( $state, $this->get_font_cleanup() ); + $this->assertSame( $queued, wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + } + + /** + * Run the version change, then the cleanup it queues + */ + private function upgrade( string $old_version, string $new_version ): void { + do_action( 'gfpdf_version_changed', $old_version, $new_version ); + do_action( Model_Font_Downloader::CLEANUP_EVENT ); } } diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php index df06b8641..0c07c6566 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php @@ -7,6 +7,7 @@ use GFPDF\Tests\Concerns\CopiesFontFixtures; use GFPDF\Tests\Concerns\RecordsFontFailures; use GFPDF\Tests\Concerns\Seamed_Font_Downloader; +use GFPDF\Tests\Concerns\SwitchesNetwork; use GFPDF\Tests\Integration\TestCase; use GFPDF_Vendor\Monolog\Handler\TestHandler; use GFPDF_Vendor\Monolog\Logger; @@ -22,6 +23,7 @@ class Test_Model_Font_Downloader extends TestCase { use CopiesFontFixtures; use RecordsFontFailures; + use SwitchesNetwork; private const FONT = 'DejaVuSerifCondensed.ttf'; @@ -51,6 +53,7 @@ public function set_up(): void { } public function tear_down(): void { + wp_clear_scheduled_hook( Model_Font_Downloader::CLEANUP_EVENT ); wp_delete_file( $this->font_path( self::FONT ) ); $this->remove_font_fixtures(); $this->delete_font_failures(); @@ -533,6 +536,248 @@ public function test_prune_without_a_fonts_folder_removes_nothing() { } } + public function test_prune_keeps_files_written_since_the_time_given() { + $this->copy_fixture( self::FONT ); + $this->copy_fixture( 'DejaVuSans.ttf' ); + touch( $this->font_path( 'DejaVuSans.ttf' ), time() - HOUR_IN_SECONDS ); + + $this->assertSame( [ 'DejaVuSans.ttf' ], $this->downloader->prune( [], time() - MINUTE_IN_SECONDS ) ); + } + + public function test_the_cleanup_reads_a_site_per_run_once_the_budget_is_spent_then_cleans_the_folder() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'arimo' ); + if ( is_multisite() ) { + self::factory()->blog->create_many( 2 ); + } + + $this->copy_fixture( self::FONT ); + touch( $this->font_path( self::FONT ), time() - HOUR_IN_SECONDS ); + + $downloader = $this->seamed( [ 'budget' => 0 ] ); + $sites = GPDFAPI::get_mvc_class( 'Model_Required_Fonts' )->get_site_ids(); + $downloader->schedule_cleanup(); + + foreach ( array_keys( $sites ) as $i ) { + $this->assertSame( array_slice( $sites, $i ), $this->get_font_cleanup()['sites'] ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertFileExists( $this->font_path( self::FONT ) ); + wp_clear_scheduled_hook( Model_Font_Downloader::CLEANUP_EVENT ); + + $downloader->cleanup(); + } + + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertTrue( $this->get_font_cleanup() ); + $this->assertFileDoesNotExist( $this->font_path( self::FONT ) ); + } + + /** + * @dataProvider provider_cleanup_states + * + * @param mixed $state + */ + public function test_the_cleanup_is_queued_once_per_install( $state ) { + $this->copy_fixture( self::FONT ); + $this->set_font_cleanup( $state ); + + $this->downloader->schedule_cleanup(); + $this->downloader->cleanup(); + + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertFileExists( $this->font_path( self::FONT ) ); + } + + public function provider_cleanup_states(): array { + return [ + 'done' => [ true ], + 'corrupt' => [ 'yes' ], + ]; + } + + /** + * @dataProvider provider_prune_since + */ + public function test_prune_only_keeps_a_file_written_after_the_time_given( int $since ) { + $this->copy_fixture( self::FONT ); + touch( $this->font_path( self::FONT ), 1000000000 ); + + $this->assertSame( [ self::FONT ], $this->downloader->prune( [], $since ) ); + } + + public function provider_prune_since(): array { + return [ + 'written at that very time' => [ 1000000000 ], + 'no time given' => [ 0 ], + ]; + } + + public function test_queueing_the_cleanup_records_every_site_and_when_it_started() { + $before = time(); + $sync = wp_next_scheduled( Model_Font_Downloader::SYNC_EVENT ); + + $this->downloader->schedule_cleanup(); + + $state = $this->get_font_cleanup(); + $this->assertSame( GPDFAPI::get_mvc_class( 'Model_Required_Fonts' )->get_site_ids(), $state['sites'] ); + $this->assertSame( [], $state['keys'] ); + $this->assertGreaterThanOrEqual( $before, $state['started'] ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertSame( $sync, wp_next_scheduled( Model_Font_Downloader::SYNC_EVENT ), 'The sync has its own event' ); + } + + public function test_queueing_a_cleanup_already_under_way_changes_nothing() { + $state = $this->font_cleanup_state( [ 'sites' => [ 7 ], 'keys' => [ 'arimo' ] ] ); + $this->set_font_cleanup( $state ); + + $this->downloader->schedule_cleanup(); + + $this->assertSame( $state, $this->get_font_cleanup() ); + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + } + + public function test_queueing_the_cleanup_moves_a_later_event_forward_rather_than_adding_one() { + wp_schedule_single_event( time() + HOUR_IN_SECONDS, Model_Font_Downloader::CLEANUP_EVENT ); + + $this->downloader->schedule_cleanup(); + + $this->assertLessThanOrEqual( time(), wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertCount( 1, $this->get_scheduled_events( Model_Font_Downloader::CLEANUP_EVENT ) ); + } + + public function test_a_cleanup_that_cannot_be_queued_is_logged_with_its_event() { + add_filter( 'schedule_event', '__return_false' ); + + $this->downloader->schedule_cleanup(); + + $logged = array_values( $this->log_messages( 'Font event could not be scheduled' ) ); + $this->assertCount( 1, $logged ); + $this->assertSame( Model_Font_Downloader::CLEANUP_EVENT, $logged[0]['context']['event'] ); + } + + public function test_a_run_that_cannot_requeue_itself_keeps_its_progress() { + add_filter( 'schedule_event', '__return_false' ); + $this->set_font_cleanup( $this->font_cleanup_state( [ 'sites' => [ get_current_blog_id(), 999999 ] ] ) ); + + $this->seamed( [ 'budget' => 0 ] )->cleanup(); + + $this->assertSame( [ 999999 ], $this->get_font_cleanup()['sites'] ); + } + + public function test_a_run_with_nothing_queued_does_nothing() { + $this->copy_fixture( self::FONT ); + + $this->downloader->cleanup(); + + $this->assertFileExists( $this->font_path( self::FONT ) ); + $this->assertFalse( $this->get_font_cleanup() ); + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + } + + /** + * @dataProvider provider_malformed_cleanup_states + */ + public function test_a_malformed_cleanup_record_cleans_nothing( array $state ) { + $this->copy_fixture( self::FONT ); + $this->set_font_cleanup( $state ); + + $this->downloader->cleanup(); + + $this->assertFileExists( $this->font_path( self::FONT ) ); + $this->assertSame( $state, $this->get_font_cleanup() ); + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertCount( 1, $this->log_messages( 'Core font cleanup skipped: its stored progress is malformed' ) ); + } + + public function provider_malformed_cleanup_states(): array { + $state = [ + 'started' => 1000000000, + 'sites' => [ 1 ], + 'keys' => [], + ]; + + return [ + 'empty' => [ [] ], + 'no sites' => [ array_diff_key( $state, [ 'sites' => true ] ) ], + 'no keys' => [ array_diff_key( $state, [ 'keys' => true ] ) ], + 'no start time' => [ array_diff_key( $state, [ 'started' => true ] ) ], + 'sites not a list' => [ array_merge( $state, [ 'sites' => '1' ] ) ], + 'keys not a list' => [ array_merge( $state, [ 'keys' => 'arimo' ] ) ], + 'start not a time' => [ array_merge( $state, [ 'started' => 'yesterday' ] ) ], + ]; + } + + public function test_the_last_run_keeps_the_fonts_earlier_runs_found() { + $this->copy_fixture( self::FONT ); + $this->copy_fixture( 'DejaVuSans.ttf' ); + touch( $this->font_path( self::FONT ), time() - HOUR_IN_SECONDS ); + touch( $this->font_path( 'DejaVuSans.ttf' ), time() - HOUR_IN_SECONDS ); + $this->set_font_cleanup( $this->font_cleanup_state( [ 'keys' => [ 'dejavuserifcondensed' ] ] ) ); + + $this->downloader->cleanup(); + + $this->assertFileExists( $this->font_path( self::FONT ) ); + $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSans.ttf' ) ); + $this->assertTrue( $this->get_font_cleanup() ); + } + + public function test_a_partial_run_adds_the_fonts_it_found_to_those_already_found() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserifcondensed' ); + $this->set_font_cleanup( $this->font_cleanup_state( [ 'sites' => [ get_current_blog_id(), get_current_blog_id(), 999999 ], 'keys' => [ 'arimo' ] ] ) ); + + /* The second run finds the same font again */ + $downloader = $this->seamed( [ 'budget' => 0 ] ); + $downloader->cleanup(); + $downloader->cleanup(); + + $keys = $this->get_font_cleanup()['keys']; + $this->assertContains( 'arimo', $keys ); + $this->assertSame( 1, array_count_values( $keys )['dejavuserifcondensed'] ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + } + + public function test_the_cleanup_carries_on_past_a_site_deleted_since_it_was_queued() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + global $wpdb; + + $this->copy_fixture( self::FONT ); + touch( $this->font_path( self::FONT ), time() - HOUR_IN_SECONDS ); + $this->set_font_cleanup( $this->font_cleanup_state( [ 'sites' => [ 999999, get_current_blog_id() ] ] ) ); + + $suppress = $wpdb->suppress_errors(); + try { + $this->downloader->cleanup(); + } finally { + $wpdb->suppress_errors( $suppress ); + } + + $this->assertTrue( $this->get_font_cleanup() ); + $this->assertFileDoesNotExist( $this->font_path( self::FONT ) ); + } + + public function test_another_network_keeps_its_font_state_on_the_main_network() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $this->on_another_network( + function ( int $network ) { + $site = self::factory()->blog->create( [ 'network_id' => $network ] ); + + $this->fail_font_downloads(); + $this->downloader->download( self::FONT ); + $this->downloader->schedule_cleanup(); + + $this->assertSame( [ self::FONT ], array_keys( $this->get_font_failures() ) ); + $this->assertContains( $site, $this->get_font_cleanup()['sites'] ); + $this->assertFalse( get_network_option( $network, Model_Font_Downloader::FAILURES ) ); + $this->assertFalse( get_network_option( $network, Model_Font_Downloader::CLEANUP ) ); + } + ); + } + /** * A deliberate trade-off: a Font Manager record pointing at a core font's file loses it, and mPDF downloads it * again by name when the font is next used @@ -572,7 +817,7 @@ public function test_a_failed_schedule_is_logged() { $this->downloader->schedule_sync(); $this->assertSame( [], $this->get_scheduled_args() ); - $this->assertCount( 1, $this->log_messages( 'Font sync could not be scheduled' ) ); + $this->assertCount( 1, $this->log_messages( 'Font event could not be scheduled' ) ); } public function test_sync_downloads_the_required_set_as_it_is_when_it_runs() { @@ -853,7 +1098,7 @@ public function test_a_retry_that_cannot_be_scheduled_is_logged() { $this->downloader->download( self::FONT ); - $this->assertCount( 1, $this->log_messages( 'Font sync could not be scheduled' ) ); + $this->assertCount( 1, $this->log_messages( 'Font event could not be scheduled' ) ); } /** @@ -920,36 +1165,89 @@ public function test_an_expired_back_off_leaves_the_retry_state() { $this->assertSame( [ self::FONT ], array_keys( $downloader->get_failed_files() ) ); } - public function test_a_single_site_autoloads_the_failures_but_not_the_back_off() { - if ( is_multisite() ) { - $this->markTestSkipped( 'A Multisite keeps them in sitemeta' ); - } - + public function test_the_failures_are_kept_on_the_main_network() { $this->fail_font_downloads(); $this->downloader->download( self::FONT ); - $this->assertContains( $this->get_autoload( Model_Font_Downloader::FAILURES ), [ 'yes', 'on' ] ); - $this->assertContains( $this->get_autoload( '_site_transient_' . Model_Font_Downloader::back_off_key( self::FONT ) ), [ 'no', 'off' ] ); + $this->assertSame( [ self::FONT ], array_keys( get_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ) ) ); } - public function test_scheduling_a_sync_stores_an_empty_failure_record() { - $this->delete_font_failures(); + public function test_dismissing_hides_the_failed_files_until_one_fails_again() { + $this->set_font_failures( + [ + self::FONT => $this->failure( time() - 10 ), + 'Garuda.ttf' => $this->failure(), + ] + ); - $this->downloader->schedule_sync(); + $this->assertSame( [ self::FONT ], $this->downloader->get_undismissed_failed_files() ); - $this->assertSame( [], $this->get_font_failures() ); + $this->downloader->dismiss_failed_files(); + $this->assertSame( [], $this->downloader->get_undismissed_failed_files() ); + $this->assertSame( [ self::FONT ], array_keys( $this->downloader->get_failed_files() ) ); + + /* Read afresh, as another site would */ + $this->assertSame( [], $this->new_downloader()->get_undismissed_failed_files() ); + + /* A font that gives up later is listed */ + $this->set_font_failures( $this->get_font_failures() + [ 'FreeSans.ttf' => $this->failure( time() ) ] ); + $this->assertSame( [ 'FreeSans.ttf' ], $this->new_downloader()->get_undismissed_failed_files() ); } - /** - * Read an option's autoload flag straight from the database - * - * @return string|null The option's autoload value - */ - private function get_autoload( string $option ): ?string { - global $wpdb; + public function test_only_a_true_dismissal_counts() { + $this->set_font_failures( [ self::FONT => [ 'retry_at' => 0, 'gave_up' => time(), 'dismissed' => 'yes' ] ] ); + + $this->assertSame( [ self::FONT ], $this->new_downloader()->get_undismissed_failed_files() ); + } + + public function test_dismissing_leaves_a_file_waiting_on_its_retry_to_be_listed_if_it_gives_up() { + $this->set_font_failures( + [ + self::FONT => $this->failure(), + 'Garuda.ttf' => $this->failure( time() - 10 ), + ] + ); + + $this->downloader->dismiss_failed_files(); + + $record = $this->get_font_failures(); + $this->assertFalse( $record[ self::FONT ]['dismissed'] ); + $this->assertTrue( $record['Garuda.ttf']['dismissed'] ); + + $this->fail_font_downloads(); + $downloader = $this->new_downloader(); + $downloader->download( self::FONT ); + + $this->assertSame( [ self::FONT ], $downloader->get_undismissed_failed_files() ); + } + + public function test_a_dismissed_file_that_keeps_failing_stays_dismissed() { + $this->set_font_failures( [ self::FONT => array_merge( $this->failure( time() - 10 ), [ 'dismissed' => true ] ) ] ); + + $this->fail_font_downloads(); + $downloader = $this->new_downloader(); + $downloader->download( self::FONT ); + + $this->assertSame( [], $downloader->get_undismissed_failed_files() ); + $this->assertSame( [ self::FONT ], array_keys( $downloader->get_failed_files() ) ); + } + + public function test_a_dismissed_file_that_downloads_then_gives_up_again_is_listed() { + $this->set_font_failures( [ self::FONT => array_merge( $this->failure( time() - 10 ), [ 'dismissed' => true ] ) ] ); + + $this->serve_font_downloads(); + $this->assertTrue( $this->new_downloader()->download( self::FONT ) ); + $this->assertSame( [], $this->get_font_failures() ); + wp_delete_file( $this->font_path( self::FONT ) ); + + remove_all_filters( 'pre_http_request' ); + $this->fail_font_downloads(); + $downloader = $this->new_downloader(); + $downloader->download( self::FONT ); + $this->delete_back_off( self::FONT ); + $downloader->download( self::FONT ); - /* phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching */ - return $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM $wpdb->options WHERE option_name = %s", $option ) ); + $this->assertSame( [ self::FONT ], $downloader->get_undismissed_failed_files() ); } /** @@ -1047,14 +1345,14 @@ private function get_scheduled(): array { } /** - * Each queued sync event, read from the cron array + * Each queued event of the hook, the sync's by default, read from the cron array * * @return array */ - private function get_scheduled_events(): array { + private function get_scheduled_events( string $hook = Model_Font_Downloader::SYNC_EVENT ): array { $events = []; foreach ( _get_cron_array() ?: [] as $time => $hooks ) { - foreach ( $hooks[ Model_Font_Downloader::SYNC_EVENT ] ?? [] as $event ) { + foreach ( $hooks[ $hook ] ?? [] as $event ) { $events[] = [ 'time' => (int) $time, 'args' => $event['args'], diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php index 5e0f70112..43b24c111 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php @@ -532,7 +532,7 @@ public function test_the_locale_counts_as_a_language() { $this->assertSame( [ 'unbatang' ], $this->stub_font_sources()->get_language_font_keys() ); } - public function test_language_font_keys_are_cached_for_the_locale() { + public function test_the_installed_languages_font_keys_are_cached_for_every_site() { $this->set_languages( [ 'th' ] ); $this->stub_font_sources()->get_language_font_keys(); @@ -549,7 +549,9 @@ static function () use ( &$reads ) { $this->assertSame( [ 'garuda' ], $this->stub_font_sources()->get_language_font_keys() ); $this->assertSame( 0, $reads ); - /* Another locale maps again */ + $this->assertSame( [ 'garuda' ], get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + + /* The locale is per site, so it's mapped on top of the cache */ add_filter( 'locale', static function () { @@ -558,7 +560,51 @@ static function () { ); $this->assertSame( [ 'garuda', 'sun-exta' ], $this->stub_font_sources()->get_language_font_keys() ); - $this->assertSame( 1, $reads ); + $this->assertSame( 0, $reads ); + $this->assertSame( [ 'garuda' ], get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + } + + /** + * @dataProvider provider_corrupt_language_caches + * + * @param mixed $cached + */ + public function test_a_corrupt_language_font_cache_is_rebuilt( $cached ) { + $this->set_languages( [ 'th' ] ); + set_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS, $cached, DAY_IN_SECONDS ); + + $this->assertSame( [ 'garuda' ], $this->stub_font_sources()->get_language_font_keys() ); + $this->assertSame( [ 'garuda' ], get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + } + + public function provider_corrupt_language_caches(): array { + return [ + 'text' => [ 'garuda' ], + 'number' => [ 5 ], + 'true' => [ true ], + ]; + } + + public function test_only_font_keys_are_read_from_the_language_font_cache() { + $this->set_languages( [] ); + set_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS, [ 'garuda', 5, null, [ 'sun-exta' ] ], DAY_IN_SECONDS ); + + $this->assertSame( [ 'garuda' ], $this->stub_font_sources()->get_language_font_keys() ); + } + + public function test_a_locale_whose_font_is_already_cached_does_not_repeat_it() { + $this->set_locale( 'th' ); + set_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS, [ 'garuda' ], DAY_IN_SECONDS ); + + $this->assertSame( [ 'garuda' ], $this->stub_font_sources()->get_language_font_keys() ); + } + + public function test_a_locale_without_its_language_pack_still_counts_but_is_not_cached() { + $this->set_languages( [] ); + $this->set_locale( 'th' ); + + $this->assertSame( [ 'garuda' ], $this->stub_font_sources()->get_language_font_keys() ); + $this->assertSame( [], get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); } public function test_a_language_pack_install_flushes_the_language_fonts() { @@ -566,7 +612,7 @@ public function test_a_language_pack_install_flushes_the_language_fonts() { } public function test_language_font_keys_are_read_once_until_flushed() { - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); $reads = 0; add_filter( 'get_available_languages', diff --git a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php index 4aea9bfe1..e273e93a6 100644 --- a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php +++ b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php @@ -6,6 +6,7 @@ use GFPDF\Tests\Concerns\CopiesFontFixtures; use GFPDF\Tests\Concerns\StubsFontSources; +use GFPDF\Tests\Concerns\SwitchesNetwork; use GFPDF\Tests\Integration\TestCase; use GFPDF_Vendor\Monolog\Handler\TestHandler; use GFPDF_Vendor\Monolog\Logger; @@ -20,9 +21,12 @@ class Test_Model_Required_Fonts extends TestCase { use CopiesFontFixtures; use StubsFontSources; + use SwitchesNetwork; private const FONT = 'DejaVuSerifCondensed.ttf'; + private const GARUDA = [ 'Garuda.ttf', 'Garuda-Bold.ttf', 'Garuda-Oblique.ttf', 'Garuda-BoldOblique.ttf' ]; + /** * @var Model_Required_Fonts */ @@ -38,6 +42,11 @@ public function tear_down(): void { $this->remove_font_fixtures(); GPDFAPI::get_options_class()->delete_option( 'default_font' ); + /* A subsite leaves these behind */ + global $wp_settings_errors, $wp_rewrite; + $wp_settings_errors = []; + $wp_rewrite->init(); + parent::tear_down(); } @@ -476,6 +485,219 @@ public function test_a_site_gravity_forms_has_not_run_on_has_no_pdf_fonts() { $this->assertSame( [ 'freeserif' ], $this->required_with( $this->stub_font_sources() )->get_site_font_keys( get_current_blog_id() ) ); } + public function test_on_its_own_a_site_requires_the_same_files_as_across_all_sites() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserif' ); + $this->set_languages( [ 'th' ] ); + $this->add_form_with_pdfs( [ [ 'font' => 'freeserif' ] ] ); + + $required = $this->required_with( $this->stub_font_sources() ); + $files = $required->get_required_font_files(); + + $this->assertCount( 12, $files ); + $this->assertEqualsCanonicalizing( $files, $this->all_sites_files( 'dejavusanscondensed', $required ) ); + } + + /** + * @dataProvider provider_unset_default_fonts + * + * @param mixed $settings + */ + public function test_a_site_without_a_saved_default_font_requires_the_one_given( $settings ) { + update_option( 'gfpdf_settings', $settings ); + + $this->assertSame( [], $this->all_sites_files() ); + $this->assertSame( + [ 'DejaVuSansCondensed.ttf', 'DejaVuSansCondensed-Bold.ttf', 'DejaVuSansCondensed-Oblique.ttf', 'DejaVuSansCondensed-BoldOblique.ttf' ], + $this->all_sites_files( 'dejavusanscondensed' ) + ); + } + + public function provider_unset_default_fonts(): array { + return [ + 'none saved' => [ [ 'debug_mode' => 'No' ] ], + 'an empty font' => [ [ 'default_font' => '' ] ], + 'corrupt settings' => [ 'arimo' ], + ]; + } + + public function test_every_sites_fonts_are_required_across_all_sites() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + GPDFAPI::get_options_class()->update_option( 'default_font', 'arimo' ); + update_blog_option( self::factory()->blog->create(), 'gfpdf_settings', [ 'default_font' => 'dejavuserif' ] ); + + /* A site Gravity Forms hasn't run on has no form tables; its locale has no language pack */ + $no_forms = self::factory()->blog->create(); + update_blog_option( $no_forms, 'gfpdf_settings', [ 'default_font' => 'arimo' ] ); + /* Raw, as saving WPLANG drops a locale without its pack */ + $GLOBALS['wpdb']->replace( $GLOBALS['wpdb']->get_blog_prefix( $no_forms ) . 'options', [ 'option_name' => 'WPLANG', 'option_value' => 'th' ] ); + + switch_to_blog( self::factory()->blog->create() ); + try { + gf_upgrade()->install(); + update_option( 'gfpdf_settings', [ 'default_font' => 'arimo' ] ); + $this->add_form_with_pdfs( [ [ 'font' => 'freeserif' ] ] ); + } finally { + restore_current_blog(); + } + + $this->assertSame( [], $this->required->get_required_font_files() ); + $this->assertSame( + array_merge( + [ 'DejaVuSerif.ttf', 'DejaVuSerif-Bold.ttf', 'DejaVuSerif-Italic.ttf', 'DejaVuSerif-BoldItalic.ttf' ], + self::GARUDA, + self::$freeserif + ), + $this->all_sites_files() + ); + } + + public function test_a_single_site_is_its_only_site() { + if ( is_multisite() ) { + $this->markTestSkipped( 'Single site only' ); + } + + $this->assertSame( [ get_current_blog_id() ], $this->required->get_site_ids() ); + $this->assertSame( [], $this->get_other_site_ids() ); + } + + public function test_the_site_ids_cover_every_network() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $same_network = self::factory()->blog->create(); + + $this->on_another_network( + function ( int $network ) use ( $same_network ) { + $other_network = self::factory()->blog->create( [ 'network_id' => $network ] ); + $ids = $this->required->get_site_ids(); + + $this->assertContains( get_main_site_id( get_main_network_id() ), $ids ); + $this->assertContains( $same_network, $ids ); + $this->assertContains( $other_network, $ids ); + } + ); + } + + public function test_the_other_sites_leave_out_only_the_current_one() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $site = self::factory()->blog->create(); + + $others = $this->get_other_site_ids(); + $this->assertContains( $site, $others ); + $this->assertNotContains( get_current_blog_id(), $others ); + $this->assertSame( array_keys( $others ), range( 0, count( $others ) - 1 ), 'A list, with no gap where the current site was' ); + } + + /** + * @dataProvider provider_saved_default_fonts + */ + public function test_a_saved_default_font_is_used_over_the_one_given( string $saved ) { + update_option( 'gfpdf_settings', [ 'default_font' => $saved ] ); + + $keys = $this->required->get_site_font_keys( get_current_blog_id(), 'dejavusanscondensed' ); + + $this->assertContains( 'dejavuserif', $keys ); + $this->assertNotContains( 'dejavusanscondensed', $keys ); + } + + public function provider_saved_default_fonts(): array { + return [ + 'as stored' => [ 'dejavuserif' ], + 'mixed case' => [ 'DejaVuSerif' ], + 'stray whitespace' => [ ' dejavuserif ' ], + ]; + } + + public function test_a_site_that_no_longer_exists_only_has_the_default_font_given() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + global $wpdb; + + $suppress = $wpdb->suppress_errors(); + try { + $this->assertSame( [ 'arimo' ], $this->required->get_site_font_keys( 999999, 'arimo' ) ); + $this->assertSame( [], $this->required->get_site_font_keys( 999999 ) ); + } finally { + $wpdb->suppress_errors( $suppress ); + } + } + + /** + * @dataProvider provider_install_font_keys + */ + public function test_the_install_font_files_are_those_of_the_keys_given( array $keys, array $expected ) { + $this->set_languages( [] ); + + $this->assertSame( $expected, $this->required_with( $this->stub_font_sources() )->get_install_font_files( $keys ) ); + } + + public function provider_install_font_keys(): array { + return [ + 'no keys' => [ [], [] ], + 'unknown font' => [ [ 'not-a-font' ], [] ], + 'a font' => [ [ 'garuda' ], self::GARUDA ], + 'a repeat' => [ [ 'garuda', 'garuda' ], self::GARUDA ], + 'mixed' => [ [ 'not-a-font', 'garuda' ], self::GARUDA ], + ]; + } + + public function test_the_install_font_files_include_the_installed_languages() { + $this->set_languages( [ 'th' ] ); + + $this->assertSame( + self::GARUDA, + $this->required_with( $this->stub_font_sources() )->get_install_font_files( [] ) + ); + } + + public function test_the_install_font_files_include_every_required_source() { + $this->set_languages( [] ); + + $sources = $this->stub_font_sources( + static function ( array $sources ): array { + $sources['fixture'] = $sources['core']; + $sources['fixture']['required'] = true; + + return $sources; + } + ); + + $this->assertContains( 'FreeMono.ttf', $this->required_with( $sources )->get_install_font_files( [] ) ); + } + + /** + * @return int[] + */ + private function get_other_site_ids(): array { + return ( function () { + return $this->get_other_site_ids(); + } )->call( $this->required ); + } + + /** + * The files every site needs, as the 6.19 cleanup gathers them + * + * @return string[] + */ + private function all_sites_files( string $unset_default = '', ?Model_Required_Fonts $required = null ): array { + $required = $required ?? $this->required; + $keys = []; + foreach ( $required->get_site_ids() as $site_id ) { + $keys = array_merge( $keys, $required->get_site_font_keys( $site_id, $unset_default ) ); + } + + return $required->get_install_font_files( $keys ); + } + /** * Each log record as its level name, message and context */ diff --git a/tests/phpunit/integration/Model/Test_Uninstaller.php b/tests/phpunit/integration/Model/Test_Uninstaller.php index 1fa3758c9..185696f8e 100644 --- a/tests/phpunit/integration/Model/Test_Uninstaller.php +++ b/tests/phpunit/integration/Model/Test_Uninstaller.php @@ -13,6 +13,8 @@ use GFPDF\Statics\Cache; use GFPDF\Statics\Deprecation_V3; use GFPDF\Tests\Concerns\RecordsFontFailures; +use GFPDF\Tests\Concerns\SwitchesNetwork; +use GFPDF\Tests\Concerns\UnregistersMvcClasses; use GFPDF\Tests\Integration\TestCase; /** @@ -33,6 +35,8 @@ class Test_Uninstaller extends TestCase { use RecordsFontFailures; + use SwitchesNetwork; + use UnregistersMvcClasses; /** * Our Controller @@ -133,14 +137,20 @@ public function test_uninstall_plugin() { $this->set_font_failures( [ 'Garuda.ttf' => $this->failure() ] ); $this->set_back_off( 'Garuda.ttf' ); set_transient( Model_Font_Downloader::SYNC_REMAINING, [ 'Garuda.ttf' ], DAY_IN_SECONDS ); + set_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS, [ 'garuda' ], DAY_IN_SECONDS ); + update_site_option( Model_Font_Downloader::CLEANUP, true ); + wp_schedule_single_event( time(), Model_Font_Downloader::CLEANUP_EVENT ); /* Uninstall */ $this->model->uninstall_plugin(); $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::SYNC_EVENT ) ); + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); $this->assertFalse( $this->get_font_failures() ); $this->assertFalse( $this->get_back_off( 'Garuda.ttf' ) ); $this->assertFalse( get_transient( Model_Font_Downloader::SYNC_REMAINING ) ); + $this->assertFalse( get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + $this->assertFalse( get_site_option( Model_Font_Downloader::CLEANUP ) ); /* Check software was uninstalled */ $this->assertDirectoryDoesNotExist( $gfpdf->data->template_location ); @@ -244,7 +254,6 @@ public function test_remove_plugin_network_options() { update_site_option( 'gpdf_sl_net_abc123', [ 'timeout' => time(), 'value' => '{}' ] ); update_site_option( Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS, true ); - update_site_option( Model_Font_Downloader::PRUNED, true ); $this->assertNotFalse( get_site_option( 'gpdf_sl_net_abc123' ) ); set_site_transient( 'gfpdf_cleanup_tmp_dir', 1 ); @@ -253,7 +262,6 @@ public function test_remove_plugin_network_options() { /* No cache flush here on purpose — the uninstall must invalidate what it deletes */ $this->assertFalse( get_site_option( 'gpdf_sl_net_abc123' ) ); $this->assertFalse( get_site_option( Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS ) ); - $this->assertFalse( get_site_option( Model_Font_Downloader::PRUNED ) ); $this->assertFalse( get_site_transient( 'gfpdf_cleanup_tmp_dir' ) ); } @@ -320,4 +328,82 @@ public function test_deactivate_plugin_wraps_string_basename_in_array() { $this->assertFalse( is_plugin_active( 'unknown/plugin.php' ) ); } + + public function test_removing_the_font_state_clears_what_every_site_shares() { + $this->store_font_state(); + + $this->model->remove_font_state(); + + $this->assert_font_records_removed(); + $this->assertFalse( $this->get_back_off( 'Garuda.ttf' ) ); + } + + public function test_removing_the_font_state_only_clears_the_back_offs_of_font_files() { + $this->set_back_off( 'not-on-the-manifest.ttf' ); + + $this->model->remove_font_state(); + + $this->assertNotFalse( $this->get_back_off( 'not-on-the-manifest.ttf' ) ); + } + + public function test_a_subsite_removes_the_font_state_every_site_shares() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $this->store_font_state(); + + switch_to_blog( self::factory()->blog->create() ); + try { + $this->model->remove_font_state(); + } finally { + restore_current_blog(); + } + + $this->assert_font_records_removed(); + } + + public function test_another_network_removes_the_font_state_from_the_main_network() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $this->store_font_state(); + + $this->on_another_network( + function () { + $this->model->remove_font_state(); + } + ); + + $this->assertFalse( $this->get_font_failures() ); + $this->assertFalse( $this->get_font_cleanup() ); + } + + public function test_removing_the_font_state_without_the_font_sources_still_clears_the_records() { + $this->store_font_state(); + + $this->without_mvc_class( + 'Model_Font_Sources', + function () { + $this->model->remove_font_state(); + } + ); + + $this->assert_font_records_removed(); + $this->assertNotFalse( $this->get_back_off( 'Garuda.ttf' ), 'Left to expire, as the files it could be for are unknown' ); + } + + private function store_font_state(): void { + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure() ] ); + $this->set_back_off( 'Garuda.ttf' ); + $this->set_font_cleanup( $this->font_cleanup_state() ); + set_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS, [ 'garuda' ], DAY_IN_SECONDS ); + } + + private function assert_font_records_removed(): void { + $this->assertFalse( $this->get_font_failures() ); + $this->assertFalse( $this->get_font_cleanup() ); + $this->assertFalse( get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + } } diff --git a/tests/playwright/core/system-status/core-fonts.spec.ts b/tests/playwright/core/system-status/core-fonts.spec.ts index 9afe54900..0dea23ce7 100644 --- a/tests/playwright/core/system-status/core-fonts.spec.ts +++ b/tests/playwright/core/system-status/core-fonts.spec.ts @@ -2,17 +2,13 @@ import type { Admin } from '@wordpress/e2e-test-utils-playwright'; import { expect } from '@wordpress/e2e-test-utils-playwright'; import type { Page } from '@playwright/test'; import { test } from '@self:playwright/fixtures/test'; +import { failedFontsNotice as notice } from '@self:playwright/utils/notices'; import { snapshot } from '@self:playwright/utils/snapshot'; import { wpCli } from '@self:playwright/utils/wp-cli'; // Matches the failures tools/mu-plugins reports for requests sending the header below const FAILED_FONTS = ['FreeSerif.ttf', 'Sun-ExtB.ttf']; -const notice = (page: Page) => - page.locator('.notice.notice-warning', { - hasText: 'Gravity PDF could not download these fonts.', - }); - // The fonts in FAILED_FONTS fail for the requests sending this, having given up at `timestamp` (see tools/mu-plugins) const giveUpAt = (page: Page, timestamp: number) => page.setExtraHTTPHeaders({ 'X-GPDF-E2E-Failed-Fonts': String(timestamp) }); @@ -56,7 +52,7 @@ test.describe('Core Fonts', () => { // Clears the dismissals stored by an earlier run (see tools/mu-plugins) test.beforeAll(() => wpCli( - 'wp option delete gfpdf_e2e_failed_fonts_dismissal --quiet || true' + 'wp option delete gfpdf_e2e_failed_fonts_dismissed --quiet || true' ) ); diff --git a/tests/playwright/multisite/failed-fonts-notice.spec.ts b/tests/playwright/multisite/failed-fonts-notice.spec.ts new file mode 100644 index 000000000..c64aaca06 --- /dev/null +++ b/tests/playwright/multisite/failed-fonts-notice.spec.ts @@ -0,0 +1,141 @@ +import { expect } from '@wordpress/e2e-test-utils-playwright'; +import type { Browser } from '@playwright/test'; +import { test } from '@self:playwright/fixtures/test'; +import { SUBSITE } from '@self:playwright/utils/multisite'; +import { failedFontsNotice as notice } from '@self:playwright/utils/notices'; +import { snapshot } from '@self:playwright/utils/snapshot'; +import { wpCli } from '@self:playwright/utils/wp-cli'; + +const MAIN_SITE = '/wp-admin/index.php'; +const SUBSITE_ADMIN = `/${SUBSITE}/wp-admin/index.php`; +const NETWORK_ADMIN = '/wp-admin/network/index.php'; + +const gaveUp = { retry_at: 0, gave_up: 1, dismissed: false }; + +// The real record every site reads, as base64 because wpCli() runs its commands inside double quotes +const saveFailures = (failures: Record) => + wpCli( + `echo ${Buffer.from(JSON.stringify(failures)).toString('base64')} | base64 -d | wp site option update gfpdf_core_font_failures --format=json --quiet` + ); + +// Logs in on the subsite and lands on its dashboard +const logIn = async (browser: Browser, username: string) => { + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + const page = await context.newPage(); + + // As the page fixture does: wp-env can't reach Gravatar + await page.route('**://*.gravatar.com/**', (route) => route.abort()); + await page.goto(`/${SUBSITE}/wp-login.php`); + await page.locator('#user_login').fill(username); + await page.locator('#user_pass').fill('password'); + await page.locator('#wp-submit').click(); + await page.waitForURL(new RegExp(`/${SUBSITE}/wp-admin/`)); + + return page; +}; + +test.describe('Failed core fonts notice on a Multisite', () => { + // Every site shares the one record, and each test builds on the state the one before left it in + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(() => + saveFailures({ + 'FreeSerif.ttf': gaveUp, + 'Sun-ExtB.ttf': gaveUp, + // Still waiting on its retry, so not listed + 'Garuda.ttf': { + retry_at: 4102444800, + gave_up: 0, + dismissed: false, + }, + }) + ); + + test.afterAll(() => + wpCli('wp site option delete gfpdf_core_font_failures --quiet') + ); + + test('should list the fonts that gave up on every site and in the network admin', async ({ + page, + }, testinfo) => { + const failed = notice(page); + + for (const url of [MAIN_SITE, SUBSITE_ADMIN, NETWORK_ADMIN]) { + await page.goto(url); + await expect(failed.locator('li code')).toHaveText([ + 'FreeSerif.ttf', + 'Sun-ExtB.ttf', + ]); + } + + await expect( + failed.getByRole('button', { name: 'Dismiss Notice' }) + ).toBeVisible(); + + await snapshot(page, testinfo, failed); + }); + + test('should open the system report from the notice in the network admin', async ({ + page, + }) => { + await page.goto(NETWORK_ADMIN); + await notice(page) + .getByRole('button', { name: 'View the system report' }) + .click(); + + await expect(page).toHaveURL(/admin\.php\?page=gf_system_status/); + await expect( + page.getByRole('row', { name: /^Core Fonts installed/ }) + ).toBeVisible(); + }); + + test('should not show the notice to a subsite user who cannot edit the settings', async ({ + browser, + }) => { + const page = await logIn(browser, 'siteeditor'); + + await expect(page.locator('#wpbody-content h1')).toBeVisible(); + await expect(notice(page)).toHaveCount(0); + + await page.context().close(); + }); + + test('should hide the notice everywhere once a subsite administrator dismisses it', async ({ + browser, + page, + }) => { + const siteAdmin = await logIn(browser, 'siteadmin'); + + await notice(siteAdmin) + .getByRole('button', { name: 'Dismiss Notice' }) + .click(); + await expect(notice(siteAdmin)).toHaveCount(0); + + await siteAdmin.context().close(); + + for (const url of [MAIN_SITE, NETWORK_ADMIN]) { + await page.goto(url); + await expect(page.locator('#wpbody-content h1')).toBeVisible(); + await expect(notice(page)).toHaveCount(0); + } + }); + + test('should raise the notice again for only the font that fails after a dismissal', async ({ + page, + }) => { + saveFailures({ + 'FreeSerif.ttf': { ...gaveUp, dismissed: true }, + 'Sun-ExtB.ttf': { ...gaveUp, dismissed: true }, + 'Garuda.ttf': gaveUp, + }); + + for (const url of [MAIN_SITE, SUBSITE_ADMIN, NETWORK_ADMIN]) { + await page.goto(url); + await expect(notice(page).locator('li code')).toHaveText([ + 'Garuda.ttf', + ]); + } + }); +}); diff --git a/tests/playwright/visual-baselines/multisite-multisite-failed-fonts-notice-failed-core-fonts-notice-on-a-multisite-should-list-the-fonts-that-gave-up-on-every-site-and-in-the-network-admin.png b/tests/playwright/visual-baselines/multisite-multisite-failed-fonts-notice-failed-core-fonts-notice-on-a-multisite-should-list-the-fonts-that-gave-up-on-every-site-and-in-the-network-admin.png new file mode 100644 index 0000000000000000000000000000000000000000..09878aedabb37a7b5b3e91406cceb2e2d36501cb GIT binary patch literal 26412 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 G_P+oUr<_Ru literal 0 HcmV?d00001 diff --git a/tools/mu-plugins/gravitypdf.php b/tools/mu-plugins/gravitypdf.php index e17d18625..6722a6dc8 100644 --- a/tools/mu-plugins/gravitypdf.php +++ b/tools/mu-plugins/gravitypdf.php @@ -240,14 +240,15 @@ static function ( $value, $old_value ) { $gfpdf_e2e_own_dismissals( 'gfpdf_e2e_action_dismissal' ); } -/* A fixed set of failures, given up when the header says, and no writes over them */ +/* A fixed set of failures, given up when the header says; only the dismissal can change, kept apart from the real record */ if ( $gfpdf_e2e_fonts_gave_up > 0 ) { add_filter( 'pre_option_gfpdf_core_font_failures', static function () use ( $gfpdf_e2e_fonts_gave_up ) { $failure = [ - 'retry_at' => 0, - 'gave_up' => $gfpdf_e2e_fonts_gave_up, + 'retry_at' => 0, + 'gave_up' => $gfpdf_e2e_fonts_gave_up, + 'dismissed' => (int) get_option( 'gfpdf_e2e_failed_fonts_dismissed', 0 ) === $gfpdf_e2e_fonts_gave_up, ]; return [ @@ -259,14 +260,16 @@ static function () use ( $gfpdf_e2e_fonts_gave_up ) { add_filter( 'pre_update_option_gfpdf_core_font_failures', - static function ( $value, $old_value ) { + static function ( $value, $old_value ) use ( $gfpdf_e2e_fonts_gave_up ) { + if ( ( $value['FreeSerif.ttf']['dismissed'] ?? false ) === true ) { + update_option( 'gfpdf_e2e_failed_fonts_dismissed', $gfpdf_e2e_fonts_gave_up ); + } + return $old_value; }, 10, 2 ); - - $gfpdf_e2e_own_dismissals( 'gfpdf_e2e_failed_fonts_dismissal' ); } /* Adds an extension's warning to a settings save, for the spec checking its colour */ diff --git a/tools/playwright/config-multisite.ts b/tools/playwright/config-multisite.ts new file mode 100644 index 000000000..65f39609d --- /dev/null +++ b/tools/playwright/config-multisite.ts @@ -0,0 +1,51 @@ +import { defineConfig, devices } from '@playwright/test'; +import * as path from 'path'; +import config from './config'; + +import baseConfig = require('@wordpress/scripts/config/playwright.config.js'); + +// Read by wpCli(), so its commands run in this environment. Set here as workers load this file too +process.env.GPDF_WP_ENV = 'wp-env:e2e-multisite'; + +const BASE_URL = 'http://localhost:8704'; + +const STORAGE_STATE_PATH = path.join( + process.cwd(), + 'tmp/artifacts/storage-states/e2e-multisite.json' +); + +export default defineConfig({ + ...config, + + webServer: { + ...baseConfig.webServer, + command: 'yarn wp-env:e2e-multisite start', + name: 'Multisite', + port: 8704, + }, + + projects: [ + { + name: 'setup-multisite', + testDir: path.join(process.cwd(), 'tools/playwright'), + testMatch: /.*multisite-setup\.ts/, + use: { + baseURL: BASE_URL, + storageState: { cookies: [], origins: [] }, + }, + metadata: { storageStatePath: STORAGE_STATE_PATH }, + }, + + { + name: 'multisite', + dependencies: ['setup-multisite'], + testDir: path.join(process.cwd(), 'tests/playwright'), + testMatch: /multisite\/.*(test|spec).(js|ts|mjs)/, + use: { + ...devices['Desktop Chrome'], + baseURL: BASE_URL, + storageState: STORAGE_STATE_PATH, + }, + }, + ], +}); diff --git a/tools/playwright/multisite-setup.ts b/tools/playwright/multisite-setup.ts new file mode 100644 index 000000000..0fa14112c --- /dev/null +++ b/tools/playwright/multisite-setup.ts @@ -0,0 +1,32 @@ +import { test as setup } from '@playwright/test'; +import { SUBSITE } from '@self:playwright/utils/multisite'; +import { wpCli } from '@self:playwright/utils/wp-cli'; + +setup('setup', async ({ request }, testInfo) => { + const storageStatePath = testInfo.project.metadata + .storageStatePath as string; + const url = `${testInfo.project.use.baseURL}/${SUBSITE}/`.replace( + 'http://', + '' + ); + + process.env.WP_BASE_URL = testInfo.project.use.baseURL as string; + process.env.STORAGE_STATE_PATH = storageStatePath; + + const addUser = (name: string, role: string) => + `(wp user get ${name} --url=${url} --quiet || wp user create ${name} ${name}@example.com --role=${role} --user_pass=password --url=${url} --quiet)`; + + // Each command skips what an earlier local run already created + wpCli( + 'wp plugin activate gravityforms gravity-pdf --network --quiet', + `(wp site list --field=url | grep -q /${SUBSITE}/ || wp site create --slug=${SUBSITE} --title=Subsite --quiet)`, + addUser('siteadmin', 'administrator'), + addUser('siteeditor', 'editor') + ); + + const { RequestUtils } = + await import('@wordpress/e2e-test-utils-playwright'); + + // Authenticate and save the storageState to disk. + await new RequestUtils(request, { storageStatePath }).setupRest(); +}); diff --git a/tools/playwright/utils/multisite.ts b/tools/playwright/utils/multisite.ts new file mode 100644 index 000000000..adaaca5eb --- /dev/null +++ b/tools/playwright/utils/multisite.ts @@ -0,0 +1,2 @@ +// The subsite tools/playwright/multisite-setup.ts creates +export const SUBSITE = 'site2'; diff --git a/tools/playwright/utils/notices.ts b/tools/playwright/utils/notices.ts index 54dab3c45..d89f7e6c9 100644 --- a/tools/playwright/utils/notices.ts +++ b/tools/playwright/utils/notices.ts @@ -1,6 +1,15 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; +/** + * The notice listing the core fonts that failed to download + * @param page + */ +export const failedFontsNotice = (page: Page) => + page.locator('.notice.notice-warning', { + hasText: 'Gravity PDF could not download these fonts.', + }); + /** * Record any `.gf-notice` still visible outside `#gf-admin-notices-wrapper` on DOM ready, i.e. painted before it jumps * into place. Call before the navigation that shows the notice. diff --git a/tools/playwright/utils/wp-cli.ts b/tools/playwright/utils/wp-cli.ts index 96547dd07..dca72fd38 100644 --- a/tools/playwright/utils/wp-cli.ts +++ b/tools/playwright/utils/wp-cli.ts @@ -3,8 +3,11 @@ import { execSync } from 'node:child_process'; // Bounded: an unbounded execSync against a wedged Docker hangs the worker until the 6h job cap. const CLI_TIMEOUT = 120_000; +// Set by tools/playwright/config-multisite.ts +const WP_ENV = process.env.GPDF_WP_ENV ?? 'wp-env:e2e'; + /** - * Run one or more WP-CLI commands in the e2e wp-env container, stopping at the first that fails, and return their output + * Run one or more WP-CLI commands in the wp-env container under test, stopping at the first that fails, and return their output * * Pass every command to one call rather than calling this twice: each call pays a `wp-env run` round trip. * @param commands @@ -13,7 +16,7 @@ export function wpCli(...commands: string[]) { const command = commands.join(' && '); try { - return execSync(`yarn wp-env:e2e run cli bash -c "${command}"`, { + return execSync(`yarn ${WP_ENV} run cli bash -c "${command}"`, { stdio: ['ignore', 'pipe', 'pipe'], timeout: CLI_TIMEOUT, }).toString(); diff --git a/tools/wp-env/e2e-multisite.json b/tools/wp-env/e2e-multisite.json new file mode 100644 index 000000000..d5f64777c --- /dev/null +++ b/tools/wp-env/e2e-multisite.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://schemas.wp.org/trunk/wp-env.json", + "core": "WordPress/WordPress#7.1.3", + "phpVersion": "8.5", + "multisite": true, + "plugins": [], + "mappings": { + "wp-content/mu-plugins": "./tools/mu-plugins", + "wp-content/plugins/gravityforms": "./vendor/gravity/gravityforms", + "wp-content/plugins/gravity-pdf": "." + }, + "config": { + "WP_DEBUG": true, + "WP_DEBUG_DISPLAY": false, + "WP_DEBUG_LOG": "/var/www/html/wp-content/plugins/gravity-pdf/tmp/debug.log", + "TEST_SUITE": true, + "E2E_TEST_SUITE": true, + "SCRIPT_DEBUG": true, + "WP_HTTP_BLOCK_EXTERNAL": true + }, + "testsEnvironment": false, + "lifecycleScripts": { + "afterStart": "rm -rf ./tmp/artifacts", + "afterReset": "rm -rf ./tmp/artifacts" + } +} diff --git a/tools/wp-env/wp-version.mjs b/tools/wp-env/wp-version.mjs index f0670a18a..3911d82fd 100755 --- a/tools/wp-env/wp-version.mjs +++ b/tools/wp-env/wp-version.mjs @@ -17,13 +17,14 @@ import { readFileSync, writeFileSync } from 'node:fs'; const API = 'https://api.wordpress.org/core/version-check/1.7/?channel=beta&version=6.0'; -// integration.json is the canonical source for the current pin; the three configs are kept in lockstep. +// integration.json is the canonical source for the current pin; the configs are kept in lockstep. const CANONICAL = 'tools/wp-env/integration.json'; const CONFIGS = [ 'tools/wp-env/development.json', CANONICAL, 'tools/wp-env/e2e.json', + 'tools/wp-env/e2e-multisite.json', ]; const VERSION = /(\d+)\.(\d+)(?:\.(\d+))?(?:-([A-Za-z]+)(\d*))?/;