From 2795421f60773b761ff1780f6019b86e27eae748 Mon Sep 17 00:00:00 2001 From: csmcneill Date: Wed, 22 Jul 2026 19:59:30 -0500 Subject: [PATCH 1/3] Tests: Add end-to-end coverage for scheduled post publishing. Exercises a true future-to-publish transition through WP-Cron: the test schedules a post by the server's clock, waits out the scheduled time without issuing requests (an ordinary request that observes the due event would take the doing_cron lock, and the local Docker environment cannot perform the loopback spawn that releases useful work from it), then drives wp-cron.php explicitly and asserts the transition. Also covers the Scheduled posts list view. See #52895. Co-Authored-By: Craft Agent --- tests/e2e/specs/scheduled-posts.test.js | 125 ++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/e2e/specs/scheduled-posts.test.js diff --git a/tests/e2e/specs/scheduled-posts.test.js b/tests/e2e/specs/scheduled-posts.test.js new file mode 100644 index 0000000000000..45e9e3a6a9ee4 --- /dev/null +++ b/tests/e2e/specs/scheduled-posts.test.js @@ -0,0 +1,125 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Reads the server's clock from an HTTP Date header, so scheduling is + * immune to clock skew between the test host and the server. + * + * @param {import('@wordpress/e2e-test-utils-playwright').RequestUtils} requestUtils The request utils fixture. + * @return {Promise} The server's current time. + */ +async function getServerNow( requestUtils ) { + const response = await requestUtils.request.get( + new URL( '/', requestUtils.baseURL ).toString() + ); + return new Date( response.headers().date ); +} + +/** + * Formats a date the way the REST API expects (Y-m-d\TH:i:s, UTC). + * + * @param {Date} date The date to format. + * @return {string} The formatted GMT date string. + */ +function toRestDateGmt( date ) { + return date.toISOString().slice( 0, 19 ); +} + +test.describe( 'Scheduled Posts', () => { + test.beforeEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts(); + } ); + + test( 'publishes a scheduled post when its time arrives', async ( { + requestUtils, + } ) => { + // Schedule the post shortly into the future, by the server's clock. + const serverNow = await getServerNow( requestUtils ); + const post = await requestUtils.createPost( { + title: 'Scheduled Post', + content: '

Published by WP-Cron.

', + status: 'future', + date_gmt: toRestDateGmt( new Date( serverNow.getTime() + 60_000 ) ), + } ); + + expect( post.status ).toBe( 'future' ); + + // Let the scheduled time pass with no requests to the site. Any + // ordinary request that observes a due event spawns WordPress's + // loopback cron and takes the doing_cron lock — and in an + // environment that cannot perform loopback requests, the spawned + // run never executes, so the lock only shuts out wp-cron.php for + // the next minute. A quiet wait leaves the lock free (or stale) the + // moment the event is due, letting the explicit wp-cron.php request + // below claim it and run the transition deterministically. + await new Promise( ( resolve ) => { + setTimeout( resolve, 65_000 ); + } ); + + await expect + .poll( + async () => { + // Drive cron explicitly: the request runs due events + // synchronously when it can claim the lock, with no + // dependency on loopback self-spawning. + await requestUtils.request.get( + new URL( + '/wp-cron.php', + requestUtils.baseURL + ).toString() + ); + const updated = await requestUtils.rest( { + path: `/wp/v2/posts/${ post.id }`, + params: { context: 'edit' }, + } ); + return updated.status; + }, + { + message: + 'the scheduled post should transition to publish once its date passes', + timeout: 60_000, + } + ) + .toBe( 'publish' ); + } ); + + test( 'lists a scheduled post in the Scheduled view', async ( { + admin, + page, + requestUtils, + } ) => { + // Schedule the post far enough out that it cannot publish mid-test. + const serverNow = await getServerNow( requestUtils ); + await requestUtils.createPost( { + title: 'Future Post', + status: 'future', + date_gmt: toRestDateGmt( + new Date( serverNow.getTime() + 2 * 60 * 60 * 1_000 ) + ), + } ); + + await admin.visitAdminPage( '/edit.php' ); + + // Switch to the Scheduled status view. + await page + .locator( '.subsubsub' ) + .getByRole( 'link', { name: /^Scheduled/ } ) + .click(); + + const listTable = page.getByRole( 'table', { + name: 'Table ordered by', + } ); + await expect( listTable ).toBeVisible(); + + // The scheduled post is listed with a scheduled date, not a + // published one. + await expect( + listTable.getByRole( 'link', { name: 'Future Post', exact: true } ) + ).toBeVisible(); + await expect( + listTable.getByRole( 'cell', { name: /^Scheduled/ } ) + ).toBeVisible(); + } ); +} ); From 4476c1b72c495f589311b644e0c74858037a8d0c Mon Sep 17 00:00:00 2001 From: csmcneill Date: Thu, 23 Jul 2026 17:33:10 -0500 Subject: [PATCH 2/3] Tests: Address review feedback on the scheduled posts spec. - Send date_gmt with an explicit UTC designator. - Fail clearly when the server response has no Date header. - Drive wp-cron.php once, before the status poll, so polling cannot spawn cron and re-arm the doing_cron lock against a later drive. Co-Authored-By: Craft Agent --- tests/e2e/specs/scheduled-posts.test.js | 31 +++++++++++++++---------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/e2e/specs/scheduled-posts.test.js b/tests/e2e/specs/scheduled-posts.test.js index 45e9e3a6a9ee4..88305d2dcf227 100644 --- a/tests/e2e/specs/scheduled-posts.test.js +++ b/tests/e2e/specs/scheduled-posts.test.js @@ -14,17 +14,24 @@ async function getServerNow( requestUtils ) { const response = await requestUtils.request.get( new URL( '/', requestUtils.baseURL ).toString() ); - return new Date( response.headers().date ); + const dateHeader = response.headers().date; + if ( ! dateHeader ) { + throw new Error( + 'The response had no Date header to read the server clock from.' + ); + } + return new Date( dateHeader ); } /** - * Formats a date the way the REST API expects (Y-m-d\TH:i:s, UTC). + * Formats a date for the REST API with an explicit UTC designator, so + * the value cannot be reinterpreted against another timezone. * * @param {Date} date The date to format. * @return {string} The formatted GMT date string. */ function toRestDateGmt( date ) { - return date.toISOString().slice( 0, 19 ); + return date.toISOString().slice( 0, 19 ) + 'Z'; } test.describe( 'Scheduled Posts', () => { @@ -58,18 +65,18 @@ test.describe( 'Scheduled Posts', () => { setTimeout( resolve, 65_000 ); } ); + // Drive cron explicitly, once: the request runs due events + // synchronously after claiming the lock, with no dependency on + // loopback self-spawning. It stays outside the poll below so + // polling cannot itself observe a due event, spawn_cron(), and + // re-arm the doing_cron lock against a later explicit drive. + await requestUtils.request.get( + new URL( '/wp-cron.php', requestUtils.baseURL ).toString() + ); + await expect .poll( async () => { - // Drive cron explicitly: the request runs due events - // synchronously when it can claim the lock, with no - // dependency on loopback self-spawning. - await requestUtils.request.get( - new URL( - '/wp-cron.php', - requestUtils.baseURL - ).toString() - ); const updated = await requestUtils.rest( { path: `/wp/v2/posts/${ post.id }`, params: { context: 'edit' }, From 51967eacc1cf69895e2b8d3ee056410b81f73074 Mon Sep 17 00:00:00 2001 From: csmcneill Date: Thu, 23 Jul 2026 17:45:13 -0500 Subject: [PATCH 3/3] Tests: Give the scheduled post transition test a slow-test budget. The spec spans real time by design (a 65-second quiet wait plus up to 60 seconds of polling), which can exceed the default 100-second per-test timeout on the failure path. test.slow() keeps a genuine failure surfacing as the poll's message instead of a test timeout. Co-Authored-By: Craft Agent --- tests/e2e/specs/scheduled-posts.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/e2e/specs/scheduled-posts.test.js b/tests/e2e/specs/scheduled-posts.test.js index 88305d2dcf227..d1f4310625023 100644 --- a/tests/e2e/specs/scheduled-posts.test.js +++ b/tests/e2e/specs/scheduled-posts.test.js @@ -42,6 +42,12 @@ test.describe( 'Scheduled Posts', () => { test( 'publishes a scheduled post when its time arrives', async ( { requestUtils, } ) => { + // The test spans real time by design (a 65-second quiet wait, + // then up to 60 seconds of polling), so triple the per-test + // budget: a genuine failure should surface as the poll's + // message, not as a generic test timeout. + test.slow(); + // Schedule the post shortly into the future, by the server's clock. const serverNow = await getServerNow( requestUtils ); const post = await requestUtils.createPost( {