diff --git a/.github/workflows/plugin-check.yml b/.github/workflows/plugin-check.yml deleted file mode 100644 index f07af1cdc..000000000 --- a/.github/workflows/plugin-check.yml +++ /dev/null @@ -1,232 +0,0 @@ -name: WordPress Plugin Check - -on: - pull_request: - types: [opened, synchronize, reopened] - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - plugin-check: - name: WordPress.org Guidelines Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Neutralize wp-env override - run: | - echo '{}' > .wp-env.override.json - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 20 - cache: 'npm' - - - name: Install npm dependencies - run: npm ci - - - name: Build assets - run: npm run build - - - name: Install Composer dependencies - run: composer install --no-dev --optimize-autoloader - - - uses: wordpress/plugin-check-action@v1 - id: plugin-check - with: - categories: plugin_repo,security,general - exclude-directories: | - node_modules - vendor - build - tests - bin - .github - ignore-codes: | - WordPress.WP.I18n.TextDomainMismatch - textdomain_mismatch - hidden_files - WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound - WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound - WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound - WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound - WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound - WordPress.PHP.DevelopmentFunctions.error_log_trigger_error - WordPress.WP.EnqueuedResourceParameters.MissingVersion - include-experimental: true - repo-token: '' - - - name: Plugin Check Summary - if: always() - env: - RESULTS_FILE: ${{ runner.temp }}/plugin-check-results.txt - run: | - echo "## WordPress Plugin Check Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ ! -s "$RESULTS_FILE" ]; then - echo "No results file found or file is empty." >> $GITHUB_STEP_SUMMARY - echo "Check the action logs for details." >> $GITHUB_STEP_SUMMARY - exit 0 - fi - - PARSED=$(RESULTS_FILE="$RESULTS_FILE" python3 << 'PYEOF' - import json, os, re - - results_path = os.environ["RESULTS_FILE"] - - high_risk_codes = [ - "plugin_updater", "code_obfuscation", "no_unfiltered_uploads", - "trademarked_term", "trademarks" - ] - high_risk_messages = [ - r"Plugin Updater detected", r"Missing.*License.*Plugin Header", - r"restricted term", r"Unescaped parameter.*\$wpdb", - r"Use placeholders and.*\$wpdb->prepare" - ] - medium_risk_codes = [ - "missing_direct_file_access_protection", "trunk_stable_tag", - "mismatched_plugin_name", "application_detected" - ] - medium_risk_messages = [ - r"Missing.*\$domain.*parameter", r"has been deprecated", - r"wp_get_sites", r"cURL functions is highly discouraged" - ] - - high, medium, other = [], [], [] - - try: - with open(results_path, "r") as f: - content = f.read().strip() - - all_issues = [] - try: - data = json.loads(content) - if isinstance(data, list): - all_issues = data - elif isinstance(data, dict): - for fp, issues in data.items(): - if isinstance(issues, list): - for issue in issues: - issue['_file'] = fp - all_issues.append(issue) - except json.JSONDecodeError: - for line in content.split('\n'): - line = line.strip() - if not line: - continue - try: - parsed = json.loads(line) - if isinstance(parsed, list): - all_issues.extend(parsed) - elif isinstance(parsed, dict): - all_issues.append(parsed) - except json.JSONDecodeError: - continue - - for issue in all_issues: - code = issue.get('code', '') - msg = issue.get('message', '') - itype = issue.get('type', 'ERROR') - line_num = issue.get('line', 0) - file_path = issue.get('_file', '') - - prefix = "❌" if itype == "ERROR" else "⚠️" - location = "" - if file_path: - location = f" ({file_path}" - if line_num and line_num > 0: - location += f", line {line_num}" - location += ")" - elif line_num and line_num > 0: - location = f" (line {line_num})" - - readable = f"{prefix} {msg}{location}" - - is_high = code in high_risk_codes - if not is_high: - for p in high_risk_messages: - if re.search(p, msg, re.IGNORECASE): - is_high = True - break - - is_medium = code in medium_risk_codes - if not is_medium and not is_high: - for p in medium_risk_messages: - if re.search(p, msg, re.IGNORECASE): - is_medium = True - break - - if is_high: - high.append(readable) - elif is_medium: - medium.append(readable) - else: - other.append(readable) - - def dedup(lst): - seen = set() - result = [] - for item in lst: - if item not in seen: - seen.add(item) - result.append(item) - return result - - high, medium, other = dedup(high), dedup(medium), dedup(other) - - print("---HIGH---") - for i in high: print(i) - print("---MEDIUM---") - for i in medium: print(i) - print("---OTHER---") - for i in other: print(i) - print("---COUNTS---") - print(f"{len(high)}|{len(medium)}|{len(other)}") - - except Exception as e: - print(f"Parse error: {e}", file=__import__('sys').stderr) - print("---HIGH---\n---MEDIUM---\n---OTHER---\n---COUNTS---\n0|0|0") - PYEOF - ) - - HIGH_SECTION=$(echo "$PARSED" | sed -n '/^---HIGH---$/,/^---MEDIUM---$/p' | sed '1d;$d') - MEDIUM_SECTION=$(echo "$PARSED" | sed -n '/^---MEDIUM---$/,/^---OTHER---$/p' | sed '1d;$d') - OTHER_SECTION=$(echo "$PARSED" | sed -n '/^---OTHER---$/,/^---COUNTS---$/p' | sed '1d;$d') - COUNTS=$(echo "$PARSED" | tail -1) - OTHER_COUNT=$(echo "$COUNTS" | cut -d'|' -f3) - - echo "### 🚨 HIGH RISK — Can cause plugin closure or suspension" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [ -n "$HIGH_SECTION" ]; then - echo "$HIGH_SECTION" >> $GITHUB_STEP_SUMMARY - else - echo "✅ No high-risk issues found." >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - - echo "### ⚠️ MEDIUM RISK — Commonly flagged in wordpress.org reviews" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [ -n "$MEDIUM_SECTION" ]; then - echo "$MEDIUM_SECTION" >> $GITHUB_STEP_SUMMARY - else - echo "✅ No medium-risk issues found." >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - - echo "
" >> $GITHUB_STEP_SUMMARY - echo "📋 Other issues ($OTHER_COUNT) — click to expand" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [ -n "$OTHER_SECTION" ]; then - echo "$OTHER_SECTION" >> $GITHUB_STEP_SUMMARY - else - echo "No other issues." >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/pr-announcer-docs.yml b/.github/workflows/pr-announcer-docs.yml deleted file mode 100644 index 3ec9ace8c..000000000 --- a/.github/workflows/pr-announcer-docs.yml +++ /dev/null @@ -1,17 +0,0 @@ -on: - pull_request: - types: [closed] - branches: - - 'development' -jobs: - pr_announcer: - runs-on: ubuntu-latest - name: Announce pr - steps: - - name: Checking merged commit - uses: Codeinwp/action-pr-merged-announcer@main - env: - GITHUB_TOKEN: ${{ secrets.BOT_TOKEN }} - with: - destination_repo: "Codeinwp/docs" - issue_labels: "otter" diff --git a/inc/plugins/class-atomic-wind-blocks.php b/inc/plugins/class-atomic-wind-blocks.php index 9f99f4746..1ac4cd425 100644 --- a/inc/plugins/class-atomic-wind-blocks.php +++ b/inc/plugins/class-atomic-wind-blocks.php @@ -22,6 +22,27 @@ class Atomic_Wind_Blocks { */ private static $in_query = false; + /** + * Expected Atomic Wind blocks by post ID. + * + * @var array + */ + private $expected = array(); + + /** + * Rendered Atomic Wind blocks by post ID. ID 0 is unknown. + * + * @var array + */ + private $rendered = array(); + + /** + * Hashes of inlined CSS. + * + * @var array + */ + private $inlined = array(); + /** * Initialize the module. * @@ -47,7 +68,8 @@ public function run() { add_action( 'enqueue_block_assets', array( $this, 'enqueue_base_css' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_icons_data' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) ); - add_action( 'wp_enqueue_scripts', array( $this, 'output_cached_css' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'output_singular_css' ), 11 ); + add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue_style_builder' ) ); add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'register_frontend_animations' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'register_frontend_states' ) ); @@ -55,6 +77,8 @@ public function run() { add_filter( 'render_block', array( $this, 'render_post_fields' ), 20, 2 ); add_filter( 'render_block', array( $this, 'render_animation_attrs' ), 10, 2 ); add_filter( 'render_block', array( $this, 'render_state_attrs' ), 10, 2 ); + add_filter( 'render_block', array( $this, 'track_rendered_blocks' ), 10, 2 ); + add_action( 'wp_footer', array( $this, 'output_late_css' ), 19 ); add_action( 'template_redirect', array( $this, 'maybe_render_css_warm_page' ), 0 ); add_action( 'save_post', array( $this, 'clear_cached_css' ) ); add_filter( 'block_categories_all', array( $this, 'register_category' ) ); @@ -161,18 +185,24 @@ public function enqueue_tailwind_generator() { * @return void */ public function enqueue_base_css() { + $css = '[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}[class*="wp-block-atomic-wind-"] p{margin:0;}'; + + // Always reset frontend blocks, including those outside the main query. + if ( ! is_admin() ) { + wp_register_style( 'atomic-wind-base', false, [], OTTER_BLOCKS_VERSION ); + wp_add_inline_style( 'atomic-wind-base', $css ); + return; + } + global $post; - + if ( ! $post || ! $this->post_has_atomic_wind_blocks( $post ) ) { return; } - + wp_register_style( 'atomic-wind-base', false, [], OTTER_BLOCKS_VERSION ); wp_enqueue_style( 'atomic-wind-base' ); - $css = '[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}[class*="wp-block-atomic-wind-"] p{margin:0;}'; - if ( is_admin() ) { - $css .= '.editor-styles-wrapper .wp-block[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}.editor-styles-wrapper [class*="wp-block-atomic-wind-"] p{margin:0;}'; - } + $css .= '.editor-styles-wrapper .wp-block[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}.editor-styles-wrapper [class*="wp-block-atomic-wind-"] p{margin:0;}'; wp_add_inline_style( 'atomic-wind-base', $css ); } @@ -252,69 +282,189 @@ public function enqueue_editor_assets() { } /** - * Output cached Tailwind CSS or enqueue the generator + style-builder. + * Enqueue the frontend Tailwind fallback. * * @return void */ - public function output_cached_css() { - global $post; + private function enqueue_generator() { + $generator_asset = $this->build_path() . '/tailwind-generator-frontend.asset.php'; - if ( ! $post ) { + if ( ! file_exists( $generator_asset ) ) { return; } - if ( ! $this->post_has_atomic_wind_blocks( $post ) ) { + $gen = include $generator_asset; + wp_enqueue_script( + 'atomic-wind-tailwind-generator', + $this->plugin_url( 'build/atomic-wind/tailwind-generator-frontend.js' ), + $gen['dependencies'], + $gen['version'], + true + ); + } + + /** + * Load the queried post's CSS in the head on singular views. + * + * The post is guaranteed to render, so inlining its cached CSS early avoids + * a flash of unstyled content for the main content. Everything else (hooked + * layouts, embeds) stays on the render-tracked footer path. + * + * @return void + */ + public function output_singular_css() { + if ( ! is_singular() ) { return; } - $cached_css = get_post_meta( $post->ID, '_atomic_wind_css', true ); + $queried = get_queried_object(); - if ( $cached_css ) { - wp_register_style( 'atomic-wind-tailwind', false, [], OTTER_BLOCKS_VERSION ); - wp_enqueue_style( 'atomic-wind-tailwind' ); - wp_add_inline_style( 'atomic-wind-tailwind', $cached_css ); + if ( ! $queried instanceof \WP_Post || ! $this->post_has_atomic_wind_blocks( $queried ) ) { return; } - $generator_asset = $this->build_path() . '/tailwind-generator-frontend.asset.php'; + $this->expected[ $queried->ID ] = substr_count( $queried->post_content, '

E2E inner pattern content

', + ) + ); + + // One registered and one unresolvable reference — one insert covers both paths. + register_block_pattern( + 'otter-e2e/pattern-reference-fixture', + array( + 'title' => 'E2E Pattern Reference Fixture', + 'categories' => array( 'otter-blocks' ), + 'content' => '

E2E reference fixture heading

', + ) + ); + }, + 100 +); diff --git a/src/animation/frontend.js b/src/animation/frontend.js index 832cd46f1..55bc47b8e 100644 --- a/src/animation/frontend.js +++ b/src/animation/frontend.js @@ -160,7 +160,7 @@ const speed = [ 'none', 'slow', 'slower', 'fast', 'faster' ]; const elementsScroll = []; let scrollListenerAttached = false; -const processElement = ( element ) => { +const processElement = ( element, visible = isElementInViewport( element ) ) => { // Skip if already processed if ( element.classList.contains( 'o-anim-ready' ) ) { return; @@ -169,7 +169,7 @@ const processElement = ( element ) => { const classes = element.classList; element.animationClasses = []; - if ( ! isElementInViewport( element ) ) { + if ( ! visible ) { const animationClass = animations.find( ( i ) => { return Array.from( classes ).find( ( o ) => o === i ); }); @@ -283,8 +283,14 @@ const animateElements = () => { createCustomAnimationNode( elements ); + // Measure every element before processing marks any of them o-anim-ready: + const inViewport = new Map(); for ( const element of elements ) { - processElement( element ); + inViewport.set( element, isElementInViewport( element ) ); + } + + for ( const element of elements ) { + processElement( element, inViewport.get( element ) ); } attachScrollListener(); diff --git a/src/blocks/blocks/popup/style.scss b/src/blocks/blocks/popup/style.scss index 7db46eaaf..5e718a98a 100644 --- a/src/blocks/blocks/popup/style.scss +++ b/src/blocks/blocks/popup/style.scss @@ -122,6 +122,9 @@ $base-index: 99999 !default; position: absolute; right: var( --padding ); + // Keep the close button above positioned popup content, e.g. Section columns. + z-index: 2; + @media (max-width: 600px) { padding-bottom: 0px; } @@ -135,7 +138,6 @@ $base-index: 99999 !default; padding: 0 !important; border: none; cursor: pointer; - z-index: 2; &:hover { opacity: .75; diff --git a/src/blocks/plugins/patterns-library/library.js b/src/blocks/plugins/patterns-library/library.js index 8fa8859d1..342dda7c4 100644 --- a/src/blocks/plugins/patterns-library/library.js +++ b/src/blocks/plugins/patterns-library/library.js @@ -38,6 +38,26 @@ import { import { accentContent } from './accent'; +import { resolvePatternBlocks } from '../../../onboarding/utils.js'; + +// WP 7.0 stamps blocks inlined from wp:pattern refs with metadata.patternName +// and locks them into content-only mode. Inserts are unsynced copies — drop +// the stamp, keep the rest of metadata (bindings, labels). +const stripPatternAttribution = (blocks) => + blocks.map((block) => { + const metadata = { ...(block.attributes?.metadata || {}) }; + delete metadata.patternName; + + return { + ...block, + attributes: { + ...block.attributes, + metadata: Object.keys(metadata).length ? metadata : undefined, + }, + innerBlocks: stripPatternAttribution(block.innerBlocks || []), + }; + }); + const CLOUD_EMPTY_CATEGORY = 'cloud-empty'; // Section categories bucketed into meaningful sidebar groups. Mirrors the @@ -574,8 +594,12 @@ const Library = ({ onClose }) => { } // With Pro active the upsell banner removes itself right after - // insertion anyway — skip it up front. - const blocks = parse(accentContent(pattern, accent)).filter( + // insertion anyway — skip it up front. Inline wp:pattern refs while + // parsing: inserted raw they render locked, or not at all for + // unregistered slugs. + const blocks = stripPatternAttribution( + resolvePatternBlocks(parse(accentContent(pattern, accent)), allPatterns), + ).filter( (block) => UPSELL_BLOCK !== block.name || !Boolean(window.themeisleGutenberg?.hasPro), @@ -660,7 +684,7 @@ const Library = ({ onClose }) => { onClose(); }, - [ clientID, accent ], + [ clientID, accent, allPatterns ], ); const resetFilters = () => { diff --git a/src/blocks/test/e2e/blocks/animations.spec.js b/src/blocks/test/e2e/blocks/animations.spec.js index 10ed9e4a4..7d8fcc627 100644 --- a/src/blocks/test/e2e/blocks/animations.spec.js +++ b/src/blocks/test/e2e/blocks/animations.spec.js @@ -3,11 +3,46 @@ */ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; +/** + * Internal dependencies + */ +import { publishAndViewPost } from '../helpers/editor'; + test.describe( 'Animations', () => { test.beforeEach( async({ admin }) => { await admin.createNewPost(); }); + test( 'nested animated block plays on load inside a transform-animated parent', async({ editor, page }) => { + + await page.setViewportSize({ width: 1280, height: 900 }); + + await editor.insertBlock({ + name: 'core/cover', + attributes: { + overlayColor: 'black', + dimRatio: 100, + minHeight: 800, + contentPosition: 'top center', + className: 'animated slideInDown' + }, + innerBlocks: [ + { + name: 'core/heading', + attributes: { + content: 'Nested Animated Heading', + className: 'animated fadeInLeft delay-1s' + } + } + ] + }); + + await publishAndViewPost({ editor, page }); + + // Without any scrolling, the nested heading must animate in after its delay. + await expect( page.getByText( 'Nested Animated Heading' ) ).toBeVisible({ timeout: 10000 }); + }); + test( 'can add a typing animation"', async({ editor, page }) => { await editor.insertBlock({ name: 'core/paragraph', diff --git a/src/blocks/test/e2e/blocks/design-library.spec.js b/src/blocks/test/e2e/blocks/design-library.spec.js index 785c1ef30..d1430bec9 100644 --- a/src/blocks/test/e2e/blocks/design-library.spec.js +++ b/src/blocks/test/e2e/blocks/design-library.spec.js @@ -244,6 +244,39 @@ test.describe( 'Design Library', () => { page.locator( '.components-snackbar', { hasText: title }) ).toBeVisible(); }); + + test( 'expands wp:pattern references into editable blocks on insert', async({ page, editor }) => { + + // Fixture (otter-e2e-pattern-fixtures.php): a heading plus one + // registered and one missing wp:pattern reference. Inserts must + // arrive as plain editable blocks — refs inlined, missing ones + // dropped, never a locked core/pattern wrapper. See #2854. + const modal = await openLibrary( page ); + await waitForGrid( modal ); + + await modal.locator( '.o-library__search' ).fill( 'E2E Pattern Reference Fixture' ); + + const card = firstCard( modal ); + await card.hover(); + await card.getByRole( 'button', { name: 'Insert', exact: true }).click(); + + await expect( modal ).toHaveCount( 0 ); + await waitForEditorReady( page ); + + const blocks = await editor.getBlocks(); + const names = blocks.map( ( block ) => block.name ); + + expect( names ).toContain( 'core/heading' ); + expect( names ).toContain( 'core/paragraph' ); + expect( names ).not.toContain( 'core/pattern' ); + + // WP 7.0 stamps inlined blocks with metadata.patternName and locks + // them into content-only mode — inserts must shed it. + const stamped = blocks + .filter( ( block ) => block.attributes?.metadata?.patternName ) + .map( ( block ) => block.name ); + expect( stamped ).toEqual( [] ); + }); }); test.describe( 'Pro upsells', () => { diff --git a/src/blocks/test/e2e/blocks/form-turnstile.spec.js b/src/blocks/test/e2e/blocks/form-turnstile.spec.js index 3d3673712..22c615205 100644 --- a/src/blocks/test/e2e/blocks/form-turnstile.spec.js +++ b/src/blocks/test/e2e/blocks/form-turnstile.spec.js @@ -2,8 +2,8 @@ * Internal dependencies */ import { test, expect } from '../fixtures'; -import { getBlockByName, expectBlockByName, publishAndViewPost } from '../helpers/editor'; -import { getFormClientId, insertContactForm, insertFormCaptchaBlock } from '../helpers/forms'; +import { getBlockByName, expectBlockByName, publishAndViewPost, publishPostReliable } from '../helpers/editor'; +import { expectFormOptionSavedNotice, findSavedFormEmail, getFormClientId, getSavedFormEmails, insertContactForm, insertFormCaptchaBlock, prepareFormOptionsInspector, showFormOption } from '../helpers/forms'; const CAPTCHA_BLOCK = 'themeisle-blocks/form-captcha'; @@ -12,7 +12,11 @@ test.describe( 'Form Block - Captcha block', () => { test.beforeEach( async({ admin, otterUtils }) => { await otterUtils.setOptions({ themeisle_cloudflare_turnstile_site_key: 'turnstile-sitekey', - themeisle_cloudflare_turnstile_secret_key: 'turnstile-secret' + themeisle_cloudflare_turnstile_secret_key: 'turnstile-secret', + + // Start from a clean form-options state (see form.spec.js). + themeisle_blocks_form_emails: [], + themeisle_blocks_form_fields_option: [] }); await admin.createNewPost(); @@ -80,6 +84,55 @@ test.describe( 'Form Block - Captcha block', () => { await expect( page.locator( `#${formId} .o-form-server-response.o-success` ) ).toBeVisible({ timeout: 15000 }); }); + // Regression for #2919: `captchaProvider` was missing from the + // `themeisle_blocks_form_emails` REST schema, so the settings endpoint + // (which forces `additionalProperties: false`) rejected the entire + // form-options save whenever a captcha was present. + test( 'saves form options when a Turnstile captcha is present', async({ editor, page }) => { + const ccValue = 'otter@turnstile-form.com'; + + await insertContactForm({ editor, page }); + + const formClientId = await getFormClientId( page ); + expect( formClientId ).toBeTruthy(); + + await insertFormCaptchaBlock( page, formClientId, 'turnstile' ); + + await expect.poll( async() => { + const form = await getBlockByName( editor, 'themeisle-blocks/form' ); + return form?.innerBlocks?.filter( ({ name }) => CAPTCHA_BLOCK === name )?.length; + }).toBe( 1 ); + + // Inserting the captcha selects it; the Form Options panel only shows + // in the Form block's own inspector. + await page.evaluate( ( clientId ) => { + window.wp.data.dispatch( 'core/block-editor' ).selectBlock( clientId ); + }, formClientId ); + + await prepareFormOptionsInspector( editor, page ); + + await showFormOption( page, 'Show CC' ); + + const cc = page.getByPlaceholder( 'Send copies to' ); + await cc.fill( ccValue ); + + await publishPostReliable( editor, page ); + + // Without the schema fix the settings request fails with + // `rest_additional_properties_forbidden` and this notice never shows. + await expectFormOptionSavedNotice( page ); + + const formBlock = await expectBlockByName( editor, 'themeisle-blocks/form' ); + expect( formBlock.attributes.optionName ).toBeTruthy(); + + const databaseEmails = await getSavedFormEmails( page ); + const savedEmail = findSavedFormEmail( databaseEmails, formBlock.attributes.optionName ); + + expect( savedEmail ).toBeTruthy(); + expect( savedEmail?.cc ).toBe( ccValue ); + expect( savedEmail?.captchaProvider ).toBe( 'turnstile' ); + }); + test( 'keeps a single Captcha block per form', async({ editor, page }) => { await insertContactForm({ editor, page }); diff --git a/src/blocks/test/e2e/blocks/popup.spec.js b/src/blocks/test/e2e/blocks/popup.spec.js index f080d160e..5c0e339fb 100644 --- a/src/blocks/test/e2e/blocks/popup.spec.js +++ b/src/blocks/test/e2e/blocks/popup.spec.js @@ -115,4 +115,60 @@ test.describe( 'Popup', () => { await page.keyboard.press( 'Enter' ); await expect( page.getByText( 'Popup Content Test' ) ).toBeHidden(); }); + + test( 'inside close button receives clicks when content has positioned columns', async({ editor, page }) => { + + // Section columns are position:relative on desktop; without a z-index on + // the header they paint over the close button and swallow its clicks. + // See https://github.com/Codeinwp/otter-blocks/issues/2863 + await page.setViewportSize({ width: 1280, height: 800 }); + + await editor.insertBlock({ + name: 'themeisle-blocks/popup', + attributes: {}, + innerBlocks: [ + { + name: 'themeisle-blocks/advanced-columns', + attributes: { + columns: 2, + layout: 'equal' + }, + innerBlocks: [ + { + name: 'themeisle-blocks/advanced-column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + content: 'Popup Content Test' + } + } + ] + }, + { + name: 'themeisle-blocks/advanced-column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + content: 'Second Column' + } + } + ] + } + ] + } + ] + }); + + await publishAndViewPost({ editor, page }); + + await expect( page.getByText( 'Popup Content Test' ) ).toBeVisible(); + + // Playwright refuses to click covered elements, so this fails if the + // columns overlay the button. + await page.getByRole( 'button', { name: 'Close' }).click(); + + await expect( page.getByText( 'Popup Content Test' ) ).toBeHidden(); + }); }); diff --git a/tests/test-atomic-wind-blocks.php b/tests/test-atomic-wind-blocks.php index 6eda729dd..0c85a50bc 100644 --- a/tests/test-atomic-wind-blocks.php +++ b/tests/test-atomic-wind-blocks.php @@ -992,7 +992,7 @@ public function test_rest_save_style_preserves_backslash_selectors() { } // ------------------------------------------------------- - // Security: output_cached_css uses wp_add_inline_style + // Security: cached CSS uses wp_add_inline_style. // ------------------------------------------------------- public function test_cached_css_uses_inline_style_api() { @@ -1001,12 +1001,12 @@ public function test_cached_css_uses_inline_style_api() { $this->assertNotFalse( strpos( $source, 'wp_add_inline_style' ), - 'output_cached_css should use wp_add_inline_style instead of raw echo' + 'Cached CSS should use wp_add_inline_style instead of raw echo' ); $this->assertFalse( (bool) preg_match( '/echo.*\$cached_css/', $source ), - 'output_cached_css should not echo $cached_css directly' + 'Cached CSS should not be echoed directly' ); } @@ -1212,4 +1212,301 @@ public function test_warm_html_does_not_leak_globals() { $this->assertSame( $sentinel, $post, 'The global $post must be restored after rendering the warm page.' ); } + + // ------------------------------------------------------- + // Frontend CSS loading. + // ------------------------------------------------------- + + /** + * Read a private property. + * + * @param string $name Property name. + * @return mixed + */ + private function get_private_prop( $name ) { + $ref = new ReflectionProperty( Atomic_Wind_Blocks::class, $name ); + $ref->setAccessible( true ); + return $ref->getValue( $this->instance ); + } + + /** + * Reset style and script queues. + */ + private function reset_style_globals() { + $GLOBALS['wp_styles'] = new WP_Styles(); + $GLOBALS['wp_scripts'] = new WP_Scripts(); + } + + /** + * Get inline CSS for a style. + * + * @param string $handle Style handle. + * @return string + */ + private function inline_style_for( $handle ) { + $data = wp_styles()->get_data( $handle, 'after' ); + return is_array( $data ) ? implode( '', $data ) : (string) $data; + } + + public function test_track_rendered_blocks_increments_per_current_post() { + $this->reset_in_query( false ); + + global $post; + $post = get_post( $this->post_id ); + setup_postdata( $post ); + + $block = $this->make_block( 'atomic-wind/box' ); + $this->instance->track_rendered_blocks( '
', $block ); + $this->instance->track_rendered_blocks( '
', $block ); + + $rendered = $this->get_private_prop( 'rendered' ); + + wp_reset_postdata(); + + $this->assertSame( 2, $rendered[ $this->post_id ] ); + } + + public function test_track_rendered_blocks_skips_non_atomic() { + $this->reset_in_query( false ); + + $this->instance->track_rendered_blocks( '

', $this->make_block( 'core/paragraph' ) ); + + $this->assertSame( array(), $this->get_private_prop( 'rendered' ) ); + } + + public function test_track_rendered_blocks_skips_inside_query_loop() { + $this->reset_in_query( true ); + + $this->instance->track_rendered_blocks( '
', $this->make_block( 'atomic-wind/box' ) ); + + $this->assertSame( array(), $this->get_private_prop( 'rendered' ) ); + } + + public function test_output_late_css_inlines_multiple_rendered_posts() { + $this->reset_style_globals(); + $this->reset_in_query( false ); + + $a = $this->make_atomic_wind_post( 'flex' ); + $b = $this->make_atomic_wind_post( 'grid' ); + update_post_meta( $a, '_atomic_wind_css', '.flex{display:flex}' ); + update_post_meta( $b, '_atomic_wind_css', '.grid{display:grid}' ); + + global $post; + $post = get_post( $a ); + setup_postdata( $post ); + $this->instance->track_rendered_blocks( '
', $this->make_block( 'atomic-wind/box' ) ); + + $post = get_post( $b ); + setup_postdata( $post ); + $this->instance->track_rendered_blocks( '
', $this->make_block( 'atomic-wind/box' ) ); + + $this->instance->output_late_css(); + + $inline = $this->inline_style_for( 'atomic-wind-tailwind-late' ); + + wp_reset_postdata(); + + $this->assertStringContainsString( '.flex{display:flex}', $inline ); + $this->assertStringContainsString( '.grid{display:grid}', $inline ); + } + + public function test_output_late_css_dedupes_identical_blobs() { + $this->reset_style_globals(); + $this->reset_in_query( false ); + + $a = $this->make_atomic_wind_post( 'flex' ); + $b = $this->make_atomic_wind_post( 'flex' ); + update_post_meta( $a, '_atomic_wind_css', '.flex{display:flex}' ); + update_post_meta( $b, '_atomic_wind_css', '.flex{display:flex}' ); + + global $post; + $post = get_post( $a ); + setup_postdata( $post ); + $this->instance->track_rendered_blocks( '
', $this->make_block( 'atomic-wind/box' ) ); + + $post = get_post( $b ); + setup_postdata( $post ); + $this->instance->track_rendered_blocks( '
', $this->make_block( 'atomic-wind/box' ) ); + + $this->instance->output_late_css(); + + $inline = $this->inline_style_for( 'atomic-wind-tailwind-late' ); + + wp_reset_postdata(); + + $this->assertSame( 1, substr_count( $inline, '.flex{display:flex}' ) ); + } + + public function test_frontend_css_is_not_enqueued_until_a_block_renders() { + $this->reset_style_globals(); + + $post_id = $this->make_atomic_wind_post( 'flex' ); + update_post_meta( $post_id, '_atomic_wind_css', '.flex{display:flex}' ); + + $GLOBALS['wp_query']->posts = array( get_post( $post_id ) ); + + $this->instance->enqueue_base_css(); + $this->instance->maybe_enqueue_style_builder(); + + $this->assertFalse( wp_style_is( 'atomic-wind-base', 'enqueued' ) ); + $this->assertFalse( wp_style_is( 'atomic-wind-tailwind', 'enqueued' ) ); + } + + public function test_output_late_css_inlines_tracked_post_and_skips_generator() { + $this->reset_style_globals(); + $this->reset_in_query( false ); + $this->instance->enqueue_base_css(); + + $late = $this->make_atomic_wind_post( 'flex' ); + update_post_meta( $late, '_atomic_wind_css', '.late{color:blue}' ); + + global $post; + $post = get_post( $late ); + setup_postdata( $post ); + + // Render the post's single Atomic Wind block. + $this->instance->track_rendered_blocks( '
', $this->make_block( 'atomic-wind/box' ) ); + + $this->instance->output_late_css(); + + $inline = $this->inline_style_for( 'atomic-wind-tailwind-late' ); + + wp_reset_postdata(); + + $this->assertTrue( wp_style_is( 'atomic-wind-base', 'enqueued' ) ); + $this->assertStringContainsString( '.late{color:blue}', $inline ); + $this->assertFalse( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) ); + } + + public function test_output_late_css_collects_blocks_rendered_by_normal_footer_callbacks() { + $this->reset_style_globals(); + $this->reset_in_query( false ); + update_option( 'themeisle_blocks_settings_atomic_wind_blocks', true ); + + $late = $this->make_atomic_wind_post( 'flex' ); + update_post_meta( $late, '_atomic_wind_css', '.footer-callback{color:purple}' ); + + global $post; + $post = get_post( $late ); + setup_postdata( $post ); + + $render_footer_block = function () { + $this->instance->track_rendered_blocks( '
', $this->make_block( 'atomic-wind/box' ) ); + }; + + // Avoid unrelated core footer output. + remove_all_actions( 'wp_footer' ); + add_action( 'wp_footer', $render_footer_block, 10 ); + $this->instance->run(); + do_action( 'wp_footer' ); + remove_action( 'wp_footer', $render_footer_block, 10 ); + remove_action( 'wp_footer', array( $this->instance, 'output_late_css' ), 19 ); + + $inline = $this->inline_style_for( 'atomic-wind-tailwind-late' ); + + wp_reset_postdata(); + + $this->assertStringContainsString( '.footer-callback{color:purple}', $inline ); + } + + public function test_output_late_css_falls_back_to_generator_when_counts_exceed() { + $this->reset_style_globals(); + $this->reset_in_query( false ); + + $late = $this->make_atomic_wind_post( 'flex' ); + update_post_meta( $late, '_atomic_wind_css', '.late{color:blue}' ); + + global $post; + $post = get_post( $late ); + setup_postdata( $post ); + + // Render one block more than the post declares. + $this->instance->track_rendered_blocks( '
', $this->make_block( 'atomic-wind/box' ) ); + $this->instance->track_rendered_blocks( '
', $this->make_block( 'atomic-wind/box' ) ); + + $this->instance->output_late_css(); + + wp_reset_postdata(); + + $this->assertTrue( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) ); + } + + public function test_output_singular_css_inlines_queried_post_in_head() { + $this->reset_style_globals(); + + $singular = $this->make_atomic_wind_post( 'flex' ); + update_post_meta( $singular, '_atomic_wind_css', '.flex{display:flex}' ); + + $this->go_to( get_permalink( $singular ) ); + $this->instance->enqueue_base_css(); + $this->instance->output_singular_css(); + + $this->assertStringContainsString( '.flex{display:flex}', $this->inline_style_for( 'atomic-wind-tailwind' ) ); + $this->assertTrue( wp_style_is( 'atomic-wind-base', 'enqueued' ) ); + $this->assertFalse( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) ); + } + + public function test_output_singular_css_enqueues_generator_without_cache() { + $this->reset_style_globals(); + + $singular = $this->make_atomic_wind_post( 'flex' ); + + $this->go_to( get_permalink( $singular ) ); + $this->instance->output_singular_css(); + + $this->assertSame( '', $this->inline_style_for( 'atomic-wind-tailwind' ) ); + $this->assertTrue( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) ); + } + + public function test_output_singular_css_noop_on_non_singular_views() { + $this->reset_style_globals(); + + $listed = $this->make_atomic_wind_post( 'flex' ); + update_post_meta( $listed, '_atomic_wind_css', '.flex{display:flex}' ); + + $this->go_to( home_url( '/' ) ); + $this->instance->output_singular_css(); + + $this->assertSame( '', $this->inline_style_for( 'atomic-wind-tailwind' ) ); + $this->assertFalse( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) ); + } + + public function test_output_late_css_skips_post_already_inlined_at_head() { + $this->reset_style_globals(); + $this->reset_in_query( false ); + + $singular = $this->make_atomic_wind_post( 'flex' ); + update_post_meta( $singular, '_atomic_wind_css', '.flex{display:flex}' ); + + $this->go_to( get_permalink( $singular ) ); + $this->instance->output_singular_css(); + + // The post's single block renders and is tracked, as during a real request. + global $post; + $post = get_post( $singular ); + setup_postdata( $post ); + $this->instance->track_rendered_blocks( '
', $this->make_block( 'atomic-wind/box' ) ); + wp_reset_postdata(); + + $this->instance->output_late_css(); + + $this->assertSame( '', $this->inline_style_for( 'atomic-wind-tailwind-late' ) ); + $this->assertFalse( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) ); + } + + public function test_enqueue_base_css_registers_frontend_style_without_enqueuing_it() { + $this->reset_style_globals(); + + global $post; + $post = null; + + $this->instance->enqueue_base_css(); + + $this->assertTrue( wp_style_is( 'atomic-wind-base', 'registered' ) ); + $this->assertFalse( wp_style_is( 'atomic-wind-base', 'enqueued' ) ); + $this->assertStringContainsString( + 'wp-block-atomic-wind-', + $this->inline_style_for( 'atomic-wind-base' ) + ); + } } diff --git a/tests/test-options-settings.php b/tests/test-options-settings.php index 2928e323f..fc22b4bd7 100644 --- a/tests/test-options-settings.php +++ b/tests/test-options-settings.php @@ -61,6 +61,7 @@ public function test_form_emails_sanitize_callback_sanitizes_nested_data() { array( array( 'form' => ' form-id