diff --git a/README.md b/README.md
index 3402c40..40de533 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,13 @@ are additive so they cannot accidentally delete omitted site files. After a
deletion, the plugin blocks partial publishing until a full export removes old
files safely. Upload transfer is paged and processed one file
per Simply Static background step so a large upload does not become one
-unbounded WordPress request.
+unbounded WordPress request. Transient upload failures retry the same file up to
+three times, with delays of 2, 4, and 8 seconds between attempts. Streaming
+uploads use the WordPress CA bundle, the `http_request_args` certificate and
+timeout settings, and the configured WordPress proxy. TLS verification stays
+enabled. Permanent failures stop with a recovery action; sanitized file, host,
+error-code, and timing details appear under **Technical details**. Signed URLs
+and authorization headers are never included in these diagnostics.
Simply Static remains responsible for crawling WordPress and rewriting URLs.
Spacefast never reads drafts, WordPress credentials, or the database. Dynamic
diff --git a/includes/class-spacefast-client.php b/includes/class-spacefast-client.php
index a83295b..372e364 100644
--- a/includes/class-spacefast-client.php
+++ b/includes/class-spacefast-client.php
@@ -404,6 +404,9 @@ private function curl_upload( string $url, string $method, array $headers, strin
if ( ( 'https' !== $scheme && ! $development ) || ( ! $validated && ! $development ) ) {
return self::upload_error( false, 'unsafe_upload_url', 'Spacefast returned an unsafe upload URL.' );
}
+ if ( ( new WP_Http() )->block_request( $url ) ) {
+ return self::upload_error( false, 'upload_blocked', 'WordPress blocks this upload destination. Ask your host to allow the Spacefast upload host, then retry publishing.' );
+ }
$size = filesize( $file_path );
$stream = fopen( $file_path, 'rb' );
if ( false === $size || false === $stream ) {
@@ -423,6 +426,16 @@ private function curl_upload( string $url, string $method, array $headers, strin
$curl_headers[] = 'Content-Type: ' . self::content_type_for_file( $file_path );
}
$curl_headers[] = 'Content-Length: ' . $size;
+ $http_args = apply_filters( 'http_request_args', array(
+ 'method' => $method,
+ 'headers' => $headers,
+ 'body' => null,
+ 'redirection' => 0,
+ 'blocking' => true,
+ 'timeout' => 120,
+ 'sslverify' => true,
+ 'sslcertificates' => ABSPATH . WPINC . '/certificates/ca-bundle.crt',
+ ), $url );
$handle = curl_init( $url );
curl_setopt_array(
$handle,
@@ -438,19 +451,43 @@ private function curl_upload( string $url, string $method, array $headers, strin
? CURLPROTO_HTTP | CURLPROTO_HTTPS
: CURLPROTO_HTTPS,
CURLOPT_CONNECTTIMEOUT => 10,
- CURLOPT_TIMEOUT => 120,
+ CURLOPT_TIMEOUT => max( 1, min( 120, (int) $http_args['timeout'] ) ),
+ CURLOPT_CAINFO => $http_args['sslcertificates'],
+ CURLOPT_USERAGENT => 'Spacefast-WordPress/' . SPACEFAST_WORDPRESS_VERSION,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
),
);
+ $proxy = new WP_HTTP_Proxy();
+ if ( $proxy->is_enabled() && $proxy->send_through_proxy( $url ) ) {
+ curl_setopt( $handle, CURLOPT_PROXY, $proxy->host() );
+ curl_setopt( $handle, CURLOPT_PROXYPORT, $proxy->port() );
+ if ( $proxy->use_authentication() ) {
+ curl_setopt( $handle, CURLOPT_PROXYAUTH, CURLAUTH_ANY );
+ curl_setopt( $handle, CURLOPT_PROXYUSERPWD, $proxy->authentication() );
+ }
+ }
$result = curl_exec( $handle );
$status = (int) curl_getinfo( $handle, CURLINFO_RESPONSE_CODE );
- $error = curl_error( $handle );
+ $errno = curl_errno( $handle );
+ $duration_ms = (int) round( curl_getinfo( $handle, CURLINFO_TOTAL_TIME ) * 1000 );
curl_close( $handle );
fclose( $stream );
if ( false === $result ) {
- unset( $error );
- return self::upload_error( true, 'network_error', 'The generated file could not be uploaded.' );
+ $retryable = in_array( $errno, array(
+ CURLE_COULDNT_RESOLVE_PROXY, CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT,
+ CURLE_OPERATION_TIMEDOUT, CURLE_SEND_ERROR, CURLE_RECV_ERROR, CURLE_GOT_NOTHING, CURLE_PARTIAL_FILE,
+ ), true );
+ $failure = self::upload_error(
+ $retryable,
+ 'upload_curl_' . $errno,
+ in_array( $errno, array( CURLE_SSL_CACERT, CURLE_SSL_CACERT_BADFILE ), true )
+ ? 'The upload certificate could not be verified. Ask your host to check the PHP certificate configuration, then retry publishing.'
+ : 'The file upload did not finish. Retry publishing. If it fails again, share the upload details below with your host.'
+ );
+ // curl_error can contain signed URLs or proxy credentials. The error-code description cannot.
+ $failure['data'] = array( 'curlCode' => $errno, 'reason' => curl_strerror( $errno ), 'host' => $host, 'durationMs' => $duration_ms );
+ return $failure;
}
return self::upload_response( $status );
}
diff --git a/includes/class-spacefast-plugin.php b/includes/class-spacefast-plugin.php
index c1d35a8..b82e88a 100644
--- a/includes/class-spacefast-plugin.php
+++ b/includes/class-spacefast-plugin.php
@@ -951,11 +951,12 @@ static function ( array $state ): array {
);
}
- public static function static_publish_progress( string $status ): void {
+ public static function static_publish_progress( string $status, string $message = '' ): void {
if ( ! in_array( $status, array( 'uploading', 'finalizing' ), true ) ) return;
Spacefast_Sync_State::mutate(
- static function ( array $state ) use ( $status ): array {
+ static function ( array $state ) use ( $status, $message ): array {
$state['last_status'] = $status;
+ $state['last_message'] = $message;
$state['last_attempt_at'] = time();
return $state;
}
@@ -1162,14 +1163,14 @@ public static function render_admin(): void {
@@ -1187,7 +1188,7 @@ public static function render_admin(): void {
-
+
diff --git a/includes/class-spacefast-simply-static-task.php b/includes/class-spacefast-simply-static-task.php
index 769bbd8..6230d78 100644
--- a/includes/class-spacefast-simply-static-task.php
+++ b/includes/class-spacefast-simply-static-task.php
@@ -29,16 +29,16 @@ public function perform() {
$partial_export ? 'additive' : 'snapshot'
);
if ( ! $result['done'] ) {
- Spacefast_Plugin::static_publish_progress( $result['status'] );
+ Spacefast_Plugin::static_publish_progress( $result['status'], $result['message'] ?? '' );
$this->save_status_message(
- 'finalizing' === $result['status']
+ $result['message'] ?? ( 'finalizing' === $result['status']
? __( 'Spacefast is activating the uploaded version', 'spacefast-wordpress' )
: sprintf(
/* translators: 1: uploaded files, 2: files requested by Spacefast. */
__( 'Publishing to Spacefast: %1$d of %2$d files', 'spacefast-wordpress' ),
$result['uploaded'],
$result['total']
- )
+ ) )
);
return false;
}
diff --git a/includes/class-spacefast-static-publisher.php b/includes/class-spacefast-static-publisher.php
index d21e9c5..c178b77 100644
--- a/includes/class-spacefast-static-publisher.php
+++ b/includes/class-spacefast-static-publisher.php
@@ -8,16 +8,18 @@ final class Spacefast_Static_Publisher {
/**
* Advance one bounded step of a Simply Static publish.
*
- * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string}
+ * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string,message?:string}
*/
public static function step(
string $archive_dir,
?Spacefast_Client $client = null,
- string $publish_mode = 'snapshot'
+ string $publish_mode = 'snapshot',
+ ?int $now = null
): array {
if ( ! in_array( $publish_mode, array( 'additive', 'snapshot' ), true ) ) {
throw new InvalidArgumentException( 'Static publish mode is invalid.' );
}
+ $now = $now ?? time();
$root = self::archive_root( $archive_dir );
$client = $client ?? new Spacefast_Client();
$state = get_option( self::OPTION, array() );
@@ -89,6 +91,9 @@ public static function step(
$next_target = max( 0, (int) ( $state['next_target'] ?? 0 ) );
if ( isset( $targets[ $next_target ] ) && is_array( $targets[ $next_target ] ) ) {
+ if ( (int) ( $state['retry_at'] ?? 0 ) > $now ) {
+ return self::retry_progress( $state );
+ }
$target = $targets[ $next_target ];
$path = self::target_file( $root, (string) ( $target['path'] ?? '' ) );
$result = $client->upload_static_file( $target, $path );
@@ -96,8 +101,27 @@ public static function step(
if ( in_array( $result['code'], array( 'upload_http_401', 'upload_http_403' ), true ) ) {
return self::resume( $state, $client );
}
+ Spacefast_Sync_State::mutate(
+ static function ( array $sync ) use ( $result, $target, $state ): array {
+ $sync['upload_diagnostic'] = array_merge( $result['data'], array(
+ 'code' => $result['code'],
+ 'file' => $target['path'],
+ 'host' => (string) wp_parse_url( $target['url'], PHP_URL_HOST ),
+ 'versionId' => $state['version_id'],
+ ) );
+ return $sync;
+ }
+ );
+ $attempt = (int) ( $state['upload_attempts'] ?? 0 ) + 1;
+ if ( $result['retryable'] && $attempt < 4 ) {
+ $state['upload_attempts'] = $attempt;
+ $state['retry_at'] = $now + 2 ** $attempt;
+ update_option( self::OPTION, $state, false );
+ return self::retry_progress( $state );
+ }
throw new RuntimeException( $result['message'] );
}
+ unset( $state['upload_attempts'], $state['retry_at'] );
$state['next_target'] = $next_target + 1;
$state['uploaded'] = (int) ( $state['uploaded'] ?? 0 ) + 1;
update_option( self::OPTION, $state, false );
@@ -118,6 +142,18 @@ public static function step(
return self::begin_finalizing( $state );
}
+ /** @param array $state */
+ private static function retry_progress( array $state ): array {
+ return array(
+ 'done' => false,
+ 'version_id' => $state['version_id'],
+ 'uploaded' => $state['uploaded'],
+ 'total' => $state['total'],
+ 'status' => 'uploading',
+ 'message' => 'Upload interrupted. Retrying automatically from the last completed file.',
+ );
+ }
+
/**
* @return array
*/
@@ -203,7 +239,7 @@ private static function target_file( string $root, string $path ): string {
/**
* @param array $state State.
- * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string}
+ * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string,message?:string}
*/
private static function resume( array $state, Spacefast_Client $client ): array {
$pages = (int) ( $state['pages'] ?? 0 ) + 1;
@@ -236,7 +272,7 @@ private static function resume( array $state, Spacefast_Client $client ): array
/**
* @param array $state State.
- * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string}
+ * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string,message?:string}
*/
private static function begin_finalizing( array $state ): array {
$state['phase'] = 'finalizing';
@@ -254,7 +290,7 @@ private static function begin_finalizing( array $state ): array {
/**
* @param array $state State.
- * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string}
+ * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string,message?:string}
*/
private static function poll_version( array $state, Spacefast_Client $client ): array {
$polls = (int) ( $state['polls'] ?? 0 ) + 1;
diff --git a/includes/class-spacefast-sync-state.php b/includes/class-spacefast-sync-state.php
index 3a2d516..41b1c03 100644
--- a/includes/class-spacefast-sync-state.php
+++ b/includes/class-spacefast-sync-state.php
@@ -7,7 +7,7 @@ final class Spacefast_Sync_State {
const HOOK = 'spacefast_wordpress_deliver';
/**
- * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,last_build_id:string,last_version_id:string,last_change_at:int,last_attempt_at:int,last_success_at:int,last_settings_sync_at:int,active_generation:int,settings_pending:bool}
+ * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,upload_diagnostic:?array,last_build_id:string,last_version_id:string,last_change_at:int,last_attempt_at:int,last_success_at:int,last_settings_sync_at:int,active_generation:int,settings_pending:bool}
*/
public static function defaults(): array {
return array(
@@ -19,6 +19,7 @@ public static function defaults(): array {
'next_at' => 0,
'last_status' => 'idle',
'last_message' => '',
+ 'upload_diagnostic' => null,
'last_build_id' => '',
'last_version_id' => '',
'last_change_at' => 0,
@@ -31,7 +32,7 @@ public static function defaults(): array {
}
/**
- * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,last_build_id:string,last_version_id:string,last_change_at:int,last_attempt_at:int,last_success_at:int,last_settings_sync_at:int,active_generation:int,settings_pending:bool}
+ * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,upload_diagnostic:?array,last_build_id:string,last_version_id:string,last_change_at:int,last_attempt_at:int,last_success_at:int,last_settings_sync_at:int,active_generation:int,settings_pending:bool}
*/
public static function get(): array {
$value = get_option( self::OPTION, array() );
diff --git a/readme.txt b/readme.txt
index 021bb43..5bf8994 100644
--- a/readme.txt
+++ b/readme.txt
@@ -3,7 +3,7 @@ Contributors: spacefast
Tags: static site, headless cms, simply static, deployment
Requires at least: 6.5
Requires PHP: 8.1
-Stable tag: 0.5.7
+Stable tag: 0.5.8
License: GPLv2 or later
Publish a Simply Static export to Spacefast, or rebuild a headless site when WordPress content changes.
@@ -31,6 +31,11 @@ OAuth access is limited to the Team you authorize and the mode you choose. WordP
== Changelog ==
+= 0.5.8 =
+* Use WordPress certificate and proxy settings for streaming uploads.
+* Retry transient file uploads with bounded backoff and preserve safe diagnostic details.
+* Show the actual first-publish state and only offer the live site after successful publishing.
+
= 0.5.7 =
* Include public custom post type archive routes in Simply Static exports.
diff --git a/spacefast-wordpress.php b/spacefast-wordpress.php
index 0b65d4d..8b775fd 100644
--- a/spacefast-wordpress.php
+++ b/spacefast-wordpress.php
@@ -3,7 +3,7 @@
* Plugin Name: Spacefast
* Plugin URI: https://spacefast.com/
* Description: Publishes static WordPress exports or rebuilds a headless Spacefast site.
- * Version: 0.5.7
+ * Version: 0.5.8
* Update URI: https://github.com/spacefast/wordpress
* Requires at least: 6.5
* Requires PHP: 8.1
@@ -14,7 +14,7 @@
defined( 'ABSPATH' ) || exit;
-define( 'SPACEFAST_WORDPRESS_VERSION', '0.5.7' );
+define( 'SPACEFAST_WORDPRESS_VERSION', '0.5.8' );
define( 'SPACEFAST_WORDPRESS_FILE', __FILE__ );
require_once __DIR__ . '/includes/class-spacefast-settings.php';
diff --git a/tests/acceptance/upload-server.php b/tests/acceptance/upload-server.php
new file mode 100644
index 0000000..3ac56fa
--- /dev/null
+++ b/tests/acceptance/upload-server.php
@@ -0,0 +1,32 @@
+ array( 'local_cert' => $certificate, 'verify_peer' => false ) ) );
+$server = stream_socket_server( 'tcp://127.0.0.1:0', $errno, $error, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context );
+if ( false === $server ) throw new RuntimeException( $error );
+fwrite( STDOUT, stream_socket_get_name( $server, false ) . "\n" );
+fflush( STDOUT );
+$connection = stream_socket_accept( $server, 15 );
+if ( false === $connection ) exit( 1 );
+stream_set_timeout( $connection, 10 );
+if ( 'http' !== $certificate && ! @stream_socket_enable_crypto( $connection, true, STREAM_CRYPTO_METHOD_TLS_SERVER ) ) {
+ fclose( $connection );
+ fclose( $server );
+ exit( 0 );
+}
+$headers = '';
+while ( false !== ( $line = fgets( $connection ) ) && "\r\n" !== $line ) $headers .= $line;
+preg_match( '/Content-Length:\s*(\d+)/i', $headers, $length );
+if ( stripos( $headers, 'Expect: 100-continue' ) !== false ) fwrite( $connection, "HTTP/1.1 100 Continue\r\n\r\n" );
+$body = '';
+$remaining = (int) ( $length[1] ?? 0 );
+while ( $remaining > 0 ) {
+ $chunk = fread( $connection, min( 65536, $remaining ) );
+ if ( false === $chunk || '' === $chunk ) exit( 2 );
+ $body .= $chunk;
+ $remaining -= strlen( $chunk );
+}
+fwrite( $connection, "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n" );
+fclose( $connection );
+fclose( $server );
+fwrite( STDOUT, json_encode( array( 'sha256' => hash( 'sha256', $body ), 'request' => strtok( $headers, "\r\n" ) ) ) . "\n" );
diff --git a/tests/acceptance/upload.php b/tests/acceptance/upload.php
new file mode 100644
index 0000000..1f72469
--- /dev/null
+++ b/tests/acceptance/upload.php
@@ -0,0 +1,80 @@
+ $fixture_dir . '/openssl.cnf', 'private_key_bits' => 2048, 'digest_alg' => 'sha256' );
+$key = openssl_pkey_new( $config );
+$csr = openssl_csr_new( array( 'commonName' => 'localhost' ), $key, $config );
+$cert = openssl_csr_sign( $csr, null, $key, 1, $config );
+openssl_x509_export( $cert, $cert_pem );
+openssl_pkey_export( $key, $key_pem );
+file_put_contents( $fixture_dir . '/ca.pem', $cert_pem );
+file_put_contents( $fixture_dir . '/server.pem', $cert_pem . $key_pem );
+$file = $fixture_dir . '/asset.bin';
+$bytes = random_bytes( 256 * 1024 );
+file_put_contents( $file, $bytes );
+
+function spacefast_receiver( string $certificate ): array {
+ $process = proc_open( array( PHP_BINARY, __DIR__ . '/upload-server.php', $certificate ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pipes );
+ if ( ! is_resource( $process ) ) throw new RuntimeException( 'Receiver could not start.' );
+ stream_set_timeout( $pipes[1], 15 );
+ $address = trim( (string) fgets( $pipes[1] ) );
+ spacefast_accept( 1 === preg_match( '/^127\.0\.0\.1:\d+$/', $address ), 'Receiver did not reserve a port.' );
+ return array( $process, $pipes, $address );
+}
+function spacefast_receiver_finish( $process, array $pipes ): array {
+ $receipt = json_decode( (string) stream_get_contents( $pipes[1] ), true );
+ $errors = stream_get_contents( $pipes[2] );
+ foreach ( $pipes as $pipe ) fclose( $pipe );
+ spacefast_accept( 0 === proc_close( $process ), 'Upload receiver failed: ' . $errors );
+ return $receipt ?? array();
+}
+
+$allow_host = static fn( $external, $host ) => '127.0.0.1' === $host || $external;
+$allow_port = static fn( $ports, $host, $url ) => array_merge( $ports, array( (int) wp_parse_url( $url, PHP_URL_PORT ) ) );
+add_filter( 'http_request_host_is_external', $allow_host, 10, 2 );
+add_filter( 'http_allowed_safe_ports', $allow_port, 10, 3 );
+$client = new Spacefast_Client();
+try {
+ list( $process, $pipes, $address ) = spacefast_receiver( $fixture_dir . '/server.pem' );
+ $target = array( 'url' => 'https://' . $address . '/upload?secret=never-display', 'method' => 'PUT', 'headers' => array( 'Authorization' => 'Bearer never-display' ) );
+ $failed = $client->upload_static_file( $target, $file );
+ spacefast_receiver_finish( $process, $pipes );
+ spacefast_accept( false === $failed['ok'] && false === $failed['retryable'], 'Untrusted TLS must fail without disabling verification.' );
+ spacefast_accept( 60 === $failed['data']['curlCode'], 'Certificate failure lost its cURL code.' );
+ spacefast_accept( '127.0.0.1' === $failed['data']['host'] && isset( $failed['data']['durationMs'] ), 'Transport diagnostics lack host or duration.' );
+ spacefast_accept( ! str_contains( wp_json_encode( $failed ), 'never-display' ) && ! str_contains( wp_json_encode( $failed ), $fixture_dir ), 'Upload diagnostics expose a credential or local path.' );
+
+ $trust = static function ( array $args ) use ( $fixture_dir ): array {
+ $args['sslcertificates'] = $fixture_dir . '/ca.pem';
+ return $args;
+ };
+ add_filter( 'http_request_args', $trust );
+ try {
+ list( $process, $pipes, $address ) = spacefast_receiver( $fixture_dir . '/server.pem' );
+ $target['url'] = 'https://' . $address . '/upload?secret=never-display';
+ $uploaded = $client->upload_static_file( $target, $file );
+ $receipt = spacefast_receiver_finish( $process, $pipes );
+ spacefast_accept( true === $uploaded['ok'], 'Streaming upload did not honor WordPress certificate configuration: ' . wp_json_encode( $uploaded ) );
+ spacefast_accept( hash( 'sha256', $bytes ) === $receipt['sha256'], 'TLS upload changed or truncated bytes.' );
+ } finally {
+ remove_filter( 'http_request_args', $trust );
+ }
+
+ list( $process, $pipes, $address ) = spacefast_receiver( 'http' );
+ define( 'WP_PROXY_HOST', '127.0.0.1' );
+ define( 'WP_PROXY_PORT', (int) substr( $address, strrpos( $address, ':' ) + 1 ) );
+ $target['url'] = 'http://unresolvable.sf.localhost/upload';
+ $uploaded = $client->upload_static_file( $target, $file );
+ $receipt = spacefast_receiver_finish( $process, $pipes );
+ spacefast_accept( true === $uploaded['ok'], 'Streaming upload bypassed the WordPress proxy.' );
+ spacefast_accept( hash( 'sha256', $bytes ) === $receipt['sha256'], 'Proxy upload changed the file bytes.' );
+ spacefast_accept( 'PUT http://unresolvable.sf.localhost/upload HTTP/1.1' === $receipt['request'], 'The receiver was not used as an HTTP proxy.' );
+} finally {
+ remove_filter( 'http_request_host_is_external', $allow_host );
+ remove_filter( 'http_allowed_safe_ports', $allow_port );
+ foreach ( glob( $fixture_dir . '/*' ) as $path ) unlink( $path );
+ rmdir( $fixture_dir );
+}
+fwrite( STDOUT, "Spacefast real TLS and proxy upload acceptance: PASS\n" );
diff --git a/tests/acceptance/wordpress.php b/tests/acceptance/wordpress.php
index dc620bd..a97dc2f 100644
--- a/tests/acceptance/wordpress.php
+++ b/tests/acceptance/wordpress.php
@@ -102,6 +102,23 @@ function spacefast_accept( bool $condition, string $message ): void {
spacefast_accept( str_contains( $connected_markup, 'Open live site' ), 'Connected UI does not expose the live result.' );
spacefast_accept( str_contains( $connected_markup, 'Technical details' ), 'Connected UI cannot disclose its receipt on demand.' );
+Spacefast_Settings::merge( array( 'mode' => Spacefast_Settings::MODE_STATIC, 'verified_at' => 0 ) );
+Spacefast_Plugin::static_publish_failed( 'The file upload did not finish. Retry publishing.' );
+ob_start();
+Spacefast_Plugin::render_admin();
+$failed_markup = (string) ob_get_clean();
+spacefast_accept( str_contains( $failed_markup, 'Needs attention' ), 'First publish hides the failed stage.' );
+spacefast_accept( ! str_contains( $failed_markup, 'Open live site' ), 'An unpublished site is offered as live.' );
+spacefast_accept( ! str_contains( $failed_markup, 'Changes are waiting.' ), 'A failed upload is described as queued progress.' );
+spacefast_accept( str_contains( $failed_markup, 'Retry publish' ), 'A failed publish has no recovery action.' );
+Spacefast_Plugin::static_publish_completed( 'ver_acceptance', 'live' );
+ob_start();
+Spacefast_Plugin::render_admin();
+$live_markup = (string) ob_get_clean();
+spacefast_accept( str_contains( $live_markup, 'Open live site' ), 'Successful first publish does not expose the live page.' );
+
+require __DIR__ . '/upload.php';
+
Spacefast_Settings::merge( array( 'mode' => Spacefast_Settings::MODE_STATIC ) );
$change_recorded = new ReflectionProperty( Spacefast_Plugin::class, 'change_recorded' );
$change_recorded->setValue( null, false );
@@ -113,7 +130,7 @@ function spacefast_accept( bool $condition, string $message ): void {
);
$plugin = get_plugin_data( WP_PLUGIN_DIR . '/spacefast-wordpress/spacefast-wordpress.php', false, false );
-spacefast_accept( '0.5.7' === $plugin['Version'], 'Unexpected plugin version.' );
+spacefast_accept( '0.5.8' === $plugin['Version'], 'Unexpected plugin version.' );
spacefast_accept( 'https://github.com/spacefast/wordpress' === $plugin['UpdateURI'], 'Update URI is missing.' );
Spacefast_Settings::disconnect();
diff --git a/tests/behavior.php b/tests/behavior.php
index 8cd742c..d86feef 100644
--- a/tests/behavior.php
+++ b/tests/behavior.php
@@ -45,8 +45,8 @@ function add_action( string $hook, $callback, int $priority = 10, int $accepted_
function add_filter( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): void {
$GLOBALS['spacefast_hooks'][ $hook ] = array( $callback, $priority, $accepted_args );
}
-function wp_parse_url( string $value ) {
- return parse_url( $value );
+function wp_parse_url( string $value, int $component = -1 ) {
+ return parse_url( $value, $component );
}
function wp_check_filetype( string $path ): array {
return array(
@@ -913,6 +913,7 @@ static function ( string $url ) use ( &$invalid_receipt_requests ): array {
);
$static_requests = array();
+$upload_status = 503;
$static_client = new Spacefast_Client(
static function ( string $url, array $args ) use ( &$static_requests ): array {
$static_requests[] = array( $url, $args );
@@ -970,12 +971,18 @@ static function ( string $url, array $args ) use ( &$static_requests ): array {
)
);
},
- static function ( string $url, string $method, array $headers, string $file ) use ( &$static_requests ): array {
+ static function ( string $url, string $method, array $headers, string $file ) use ( &$static_requests, &$upload_status ): array {
$static_requests[] = array( $url, $method, $headers, $file );
- return response( 204 );
+ return response( $upload_status );
}
);
-$publish_step = Spacefast_Static_Publisher::step( $archive, $static_client );
+$publish_step = Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', 100 );
+check( 0 === $publish_step['uploaded'], 'a failed upload does not advance the cursor' );
+check( 'Upload interrupted. Retrying automatically from the last completed file.' === $publish_step['message'], 'transient errors keep the background export running' );
+$upload_status = 204;
+$waiting = Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', 101 );
+check( 0 === $waiting['uploaded'], 'backoff prevents uploading before the retry deadline' );
+$publish_step = Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', 102 );
check( false === $publish_step['done'], 'uploads a bounded target per background step' );
check( 1 === $publish_step['uploaded'], 'records completed generated-file uploads' );
$publish_done = Spacefast_Static_Publisher::step( $archive, $static_client );
@@ -1004,10 +1011,33 @@ static function ( string $url, string $method, array $headers, string $file ) us
'uploads the generated public-access declaration with the export'
);
check(
- str_ends_with( $static_requests[3][3], '/assets/app.js' ),
+ str_ends_with( $static_requests[4][3], '/assets/app.js' ),
'uploads the next opaque target after resuming'
);
+Spacefast_Static_Publisher::reset();
+$upload_status = 503;
+foreach ( array( 200, 202, 206 ) as $retry_time ) {
+ $retry = Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', $retry_time );
+ check( 0 === $retry['uploaded'], 'repeated failure preserves upload progress' );
+}
+try {
+ Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', 214 );
+ throw new LogicException( 'Upload retries did not stop.' );
+} catch ( RuntimeException $error ) {
+ check( 'Spacefast rejected a generated file upload.' === $error->getMessage(), 'retry exhaustion reports the upload failure' );
+}
+Spacefast_Static_Publisher::reset();
+check( 'upload_http_503' === Spacefast_Sync_State::get()['upload_diagnostic']['code'], 'upload evidence survives exporter cleanup' );
+$upload_status = 422;
+try {
+ Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', 300 );
+ throw new LogicException( 'Permanent rejection was retried.' );
+} catch ( RuntimeException $error ) {
+ check( 'Spacefast rejected a generated file upload.' === $error->getMessage(), 'permanent rejection fails immediately' );
+}
+Spacefast_Static_Publisher::reset();
+
Spacefast_Sync_State::save(
array_merge( Spacefast_Sync_State::defaults(), array( 'desired' => 2, 'active_generation' => 1 ) )
);