Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .wp-env.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
".",
"https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip"
],
"themes": [ "./test/emptytheme" ],
"themes": [
"./test/emptytheme",
"https://downloads.wordpress.org/theme/twentytwentyone.zip"
],
"config": {
"WP_DEBUG": true,
"WP_DEBUG_LOG": true,
Expand Down
9 changes: 4 additions & 5 deletions inc/css/class-css-handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -496,18 +496,17 @@ public static function save_widgets_styles() {
public static function is_writable() {
global $wp_filesystem;
include_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();

$wp_upload_dir = wp_upload_dir( null, false );
$upload_dir = $wp_upload_dir['basedir'];

if ( ! function_exists( 'WP_Filesystem' ) ) {
return false;
}

$wp_upload_dir = wp_upload_dir( null, false );
$upload_dir = $wp_upload_dir['basedir'];

$writable = WP_Filesystem( false, $upload_dir );

return $writable && 'direct' === $wp_filesystem->method;
return $writable && $wp_filesystem instanceof \WP_Filesystem_Base && 'direct' === $wp_filesystem->method;
}

/**
Expand Down
167 changes: 167 additions & 0 deletions packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,19 @@
*/
const OPENAI_STUB_OPTION = 'otter_e2e_openai_stub';

/**
* When truthy, get_filesystem_method() reports a bogus method so WP_Filesystem()
* fails to initialize. Simulates hosts where the filesystem API is unavailable
* on the frontend (issue #2937) — CSS_Handler::is_writable() must degrade to
* `false` and the widgets CSS must fall back to an inline <style>.
*/
const FS_BLOCKED_OPTION = 'otter_e2e_fs_blocked';

/**
* Numeric index used for the seeded block widget instance (widget id `block-999`).
*/
const WIDGET_SEED_INDEX = 999;

/**
* Form record post type, mirrored from \ThemeIsle\GutenbergBlocks\Plugins\Form_Submissions.
*/
Expand Down Expand Up @@ -941,6 +954,104 @@ function stub_captcha_http_verification_for_e2e( $preempt, $request, $url ) {

add_filter( 'pre_http_request', __NAMESPACE__ . '\\stub_captcha_http_verification_for_e2e', 10, 3 );

/**
* Report an unknown filesystem method while the fs-blocked scenario is active.
*
* WP_Filesystem() then fails to locate the abstraction class file and bails out
* without initializing `$wp_filesystem`, which is the closest reproducible
* stand-in for the production condition behind issue #2937.
*
* @param string $method Detected filesystem method.
* @return string
*/
function block_filesystem_method_for_e2e( $method ) {
return get_option( FS_BLOCKED_OPTION ) ? 'otter_e2e_blocked' : $method;
}

add_filter( 'filesystem_method', __NAMESPACE__ . '\\block_filesystem_method_for_e2e', PHP_INT_MAX );

/**
* Seed a classic-widgets sidebar with an Otter block so the frontend
* widgets-CSS path (Block_Frontend::enqueue_widgets_css) is exercised.
*
* Clears the generated-stylesheet options so the next frontend request takes
* the "no CSS file yet" branch that calls CSS_Handler::is_writable().
*
* @param string $sidebar_id Sidebar to place the widget in.
* @return void
*/
function seed_otter_widget( $sidebar_id ) {
$markup = '<!-- wp:themeisle-blocks/progress-bar {"id":"wp-block-themeisle-blocks-progress-bar-e2e2937","title":"E2E Progress","percentage":75,"titleColor":"#123abc","height":36} -->' . "\n" .
'<div id="wp-block-themeisle-blocks-progress-bar-e2e2937" class="wp-block-themeisle-blocks-progress-bar"><div class="wp-block-themeisle-blocks-progress-bar__title">E2E Progress</div><div class="wp-block-themeisle-blocks-progress-bar__area"><div class="wp-block-themeisle-blocks-progress-bar__area__bar"></div></div></div>' . "\n" .
'<!-- /wp:themeisle-blocks/progress-bar -->';

$widget_blocks = get_option( 'widget_block', array() );

if ( ! is_array( $widget_blocks ) ) {
$widget_blocks = array();
}

$widget_blocks[ WIDGET_SEED_INDEX ] = array( 'content' => $markup );
$widget_blocks['_multiwidget'] = 1;
update_option( 'widget_block', $widget_blocks );

$sidebars = get_option( 'sidebars_widgets', array() );

if ( ! is_array( $sidebars ) ) {
$sidebars = array();
}

$existing = isset( $sidebars[ $sidebar_id ] ) && is_array( $sidebars[ $sidebar_id ] ) ? $sidebars[ $sidebar_id ] : array();

$sidebars[ $sidebar_id ] = array_values( array_unique( array_merge( array( 'block-' . WIDGET_SEED_INDEX ), $existing ) ) );
update_option( 'sidebars_widgets', $sidebars );

delete_option( 'themeisle_blocks_widgets_css_file' );
delete_option( 'themeisle_blocks_widgets_css' );
delete_option( 'themeisle_blocks_widgets_fonts' );
}

/**
* Remove the seeded widget and every widgets-CSS artifact it produced.
*
* @return void
*/
function cleanup_otter_widget() {
$widget_blocks = get_option( 'widget_block', array() );

if ( is_array( $widget_blocks ) && isset( $widget_blocks[ WIDGET_SEED_INDEX ] ) ) {
unset( $widget_blocks[ WIDGET_SEED_INDEX ] );
update_option( 'widget_block', $widget_blocks );
}

$sidebars = get_option( 'sidebars_widgets', array() );

if ( is_array( $sidebars ) ) {
foreach ( $sidebars as $sidebar_id => $widgets ) {
if ( is_array( $widgets ) ) {
$sidebars[ $sidebar_id ] = array_values( array_diff( $widgets, array( 'block-' . WIDGET_SEED_INDEX ) ) );
}
}
update_option( 'sidebars_widgets', $sidebars );
}

$file_name = get_option( 'themeisle_blocks_widgets_css_file' );

if ( $file_name ) {
$wp_upload_dir = wp_upload_dir( null, false );
$file_path = $wp_upload_dir['basedir'] . '/themeisle-gutenberg/' . $file_name . '.css';

if ( is_file( $file_path ) ) {
wp_delete_file( $file_path );
}
}

delete_option( 'themeisle_blocks_widgets_css_file' );
delete_option( 'themeisle_blocks_widgets_css' );
delete_option( 'themeisle_blocks_widgets_fonts' );
delete_option( FS_BLOCKED_OPTION );
}

add_action(
'rest_api_init',
function () {
Expand Down Expand Up @@ -1226,6 +1337,61 @@ function () {
)
);

register_rest_route(
REST_NAMESPACE,
'/filesystem',
array(
'methods' => \WP_REST_Server::CREATABLE,
'permission_callback' => __NAMESPACE__ . '\\require_admin',
'callback' => function ( \WP_REST_Request $request ) {
$mode = $request->get_param( 'mode' );

if ( ! in_array( $mode, array( 'blocked', 'ok' ), true ) ) {
return new \WP_Error(
'otter_e2e_invalid_fs_mode',
'Mode must be "blocked" or "ok".',
array( 'status' => 400 )
);
}

if ( 'blocked' === $mode ) {
update_option( FS_BLOCKED_OPTION, true, false );
} else {
delete_option( FS_BLOCKED_OPTION );
}

return rest_ensure_response( array( 'ok' => true ) );
},
)
);

register_rest_route(
REST_NAMESPACE,
'/widgets/seed',
array(
'methods' => \WP_REST_Server::CREATABLE,
'permission_callback' => __NAMESPACE__ . '\\require_admin',
'callback' => function ( \WP_REST_Request $request ) {
$sidebar_id = $request->get_param( 'sidebar' );
seed_otter_widget( is_string( $sidebar_id ) && '' !== $sidebar_id ? $sidebar_id : 'sidebar-1' );
return rest_ensure_response( array( 'ok' => true ) );
},
)
);

register_rest_route(
REST_NAMESPACE,
'/widgets/cleanup',
array(
'methods' => \WP_REST_Server::CREATABLE,
'permission_callback' => __NAMESPACE__ . '\\require_admin',
'callback' => function () {
cleanup_otter_widget();
return rest_ensure_response( array( 'ok' => true ) );
},
)
);

register_rest_route(
REST_NAMESPACE,
'/reset',
Expand All @@ -1249,6 +1415,7 @@ function () {
delete_option( MAIL_LOG_OPTION );
delete_option( CAPTCHA_MODE_OPTION );
delete_option( OPENAI_STUB_OPTION );
delete_option( FS_BLOCKED_OPTION );
cleanup_form_records();
return rest_ensure_response( array( 'ok' => true ) );
},
Expand Down
94 changes: 94 additions & 0 deletions src/blocks/test/e2e/blocks/widgets-css-frontend.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Internal dependencies
*/
import { test, expect } from '../fixtures';

/**
* Frontend widgets-CSS coverage for https://github.com/Codeinwp/otter-blocks/issues/2937.
*
* A frontend request with an active sidebar and no generated widget stylesheet
* reaches CSS_Handler::is_writable() from Block_Frontend::enqueue_widgets_css()
* at wp_footer. In production that request fataled when WP_Filesystem() was
* unavailable. The exact missing-function condition can only be recreated in
* the isolated PHPUnit sandbox (tests/test-css-handler.php); here the
* filesystem is blocked at the get_filesystem_method() level, which drives the
* same is_writable() → false branch and asserts the user-visible contract: the
* page must finish rendering and serve the widget CSS inline.
*
* Serial project: switches the active theme and mutates site-wide widget,
* option, and filesystem state.
*/

const WIDGET_SELECTOR = '.wp-block-themeisle-blocks-progress-bar';
const WIDGET_CSS_ID = '#wp-block-themeisle-blocks-progress-bar-e2e2937';

test.describe( 'Widgets CSS frontend', () => {
test.beforeAll( async({ requestUtils }) => {
// Classic theme with a registered sidebar; block themes register none,
// so the widgets-CSS path is unreachable on the default theme.
await requestUtils.activateTheme( 'twentytwentyone' );

await requestUtils.rest({
method: 'POST',
path: '/otter-e2e/v1/widgets/seed'
});
});

test.afterAll( async({ requestUtils }) => {
await requestUtils.rest({
method: 'POST',
path: '/otter-e2e/v1/widgets/cleanup'
});

await requestUtils.activateTheme( 'twentytwentythree' );
});

test( 'completes the page with inline widget CSS when the filesystem is unavailable', async({ page, otterUtils }) => {
await otterUtils.setFilesystemMode( 'blocked' );

try {
// Take the no-stylesheet branch on a fresh request.
await otterUtils.seedOtterWidget();

const response = await page.goto( '/' );

expect( response.status() ).toBe( 200 );

// The widget itself rendered inside the sidebar.
await expect( page.locator( WIDGET_SELECTOR ) ).toBeVisible();

const content = await page.content();

// The inline <style> fallback is echoed at wp_footer, after the
// is_writable() call that used to fatal — its presence proves the
// request completed past the crash point.
expect( content ).toContain( WIDGET_CSS_ID );
expect( content ).toContain( '--percentage' );
expect( content ).not.toContain( 'Fatal error' );

// No stylesheet file could be written, so none may be enqueued.
await expect( page.locator( 'link#otter-widgets-css' ) ).toHaveCount( 0 );
} finally {
await otterUtils.setFilesystemMode( 'ok' );
}
});

test( 'writes and enqueues the widgets CSS file when the filesystem is available', async({ page, otterUtils }) => {
await otterUtils.setFilesystemMode( 'ok' );

// Fresh no-stylesheet state; this request regenerates and saves the file.
await otterUtils.seedOtterWidget();

await page.goto( '/' );
await expect( page.locator( WIDGET_SELECTOR ) ).toBeVisible();

// The file was written during the previous request; this one enqueues it.
await page.reload();

await expect( page.locator( WIDGET_SELECTOR ) ).toBeVisible();

const stylesheet = page.locator( 'link#otter-widgets-css' );
await expect( stylesheet ).toHaveCount( 1 );
await expect( stylesheet ).toHaveAttribute( 'href', /themeisle-gutenberg\/widgets-/ );
});
});
15 changes: 15 additions & 0 deletions src/blocks/test/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ export type OtterUtils = {
/** Mint a `form-verification` nonce for API-driven submissions. */
getFormVerificationNonce: () => Promise<string>;

/**
* 'blocked' makes get_filesystem_method() report a bogus method so
* WP_Filesystem() fails to initialize (issue #2937 scenario); 'ok' restores it.
*/
setFilesystemMode: ( mode: 'blocked' | 'ok' ) => Promise<unknown>;

/** Seed a classic sidebar with an Otter block widget and clear the generated widgets-CSS options. */
seedOtterWidget: ( sidebar?: string ) => Promise<unknown>;

/** Remove the seeded widget, its CSS file/options, and the filesystem block. */
cleanupOtterWidget: () => Promise<unknown>;

/** All stored Submission Records with their Delivery Status meta. */
getFormRecords: () => Promise<FormRecord[]>;

Expand Down Expand Up @@ -90,6 +102,9 @@ export const test = base.extend<{ otterUtils: OtterUtils }>({
const response = ( await call( 'form/nonce' ) ) as { nonce: string };
return response.nonce;
},
setFilesystemMode: ( mode ) => call( 'filesystem', { mode }),
seedOtterWidget: ( sidebar ) => call( 'widgets/seed', sidebar ? { sidebar } : undefined ),
cleanupOtterWidget: () => call( 'widgets/cleanup' ),
getFormRecords: () => call( 'form/records' ) as Promise<FormRecord[]>,
cleanupFormRecords: () => call( 'form/records/cleanup' )
});
Expand Down
5 changes: 4 additions & 1 deletion src/blocks/test/e2e/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ const SERIAL_SPECS = [
'**/blocks/design-library.spec.js',

// Flips the site-wide atomic-wind blocks option.
'**/blocks/atomic-wind-list-view.spec.js'
'**/blocks/atomic-wind-list-view.spec.js',

// Switches the active theme and mutates site-wide widget + filesystem state.
'**/blocks/widgets-css-frontend.spec.js'
];

const config = defineConfig({
Expand Down
Loading
Loading