From 2795421f60773b761ff1780f6019b86e27eae748 Mon Sep 17 00:00:00 2001 From: csmcneill Date: Wed, 22 Jul 2026 19:59:30 -0500 Subject: [PATCH 1/7] 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/7] 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/7] 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( { From 89034a8ae9e5df5ac4504e297afbc3f27cf3ab5f Mon Sep 17 00:00:00 2001 From: csmcneill Date: Wed, 29 Jul 2026 20:12:32 -0500 Subject: [PATCH 4/7] Tests: Schedule the cron transition test clear of the future-post boundary. wp_insert_post() coerces a post submitted as `future` into `publish` unless its date is at least MINUTE_IN_SECONDS ahead of the server clock, compared at whole-second resolution. Scheduling exactly a minute out sits on that boundary and only holds when the insert lands inside the same clock second as the Date header it was derived from, which a loaded machine does not guarantee: on one CI attempt the reading was 23:03:37, the insert landed in 23:03:38, and the stored date of 23:04:37 was fifty-nine seconds out and published immediately. Schedule ninety seconds out instead, and derive the wait from the date_gmt the server actually stored, measured against the server clock after the insert, so no pre-insert estimate is carried into it. Co-Authored-By: Craft Agent --- tests/e2e/specs/scheduled-posts.test.js | 43 +++++++++++++++++++++---- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/tests/e2e/specs/scheduled-posts.test.js b/tests/e2e/specs/scheduled-posts.test.js index d1f4310625023..40dd3017e31bd 100644 --- a/tests/e2e/specs/scheduled-posts.test.js +++ b/tests/e2e/specs/scheduled-posts.test.js @@ -34,6 +34,17 @@ function toRestDateGmt( date ) { return date.toISOString().slice( 0, 19 ) + 'Z'; } +/** + * Reads a GMT date string returned by the REST API, which carries no timezone + * designator, as the UTC instant it denotes. + * + * @param {string} dateGmt A `date_gmt` value from the REST API. + * @return {Date} The instant it denotes. + */ +function fromRestDateGmt( dateGmt ) { + return new Date( `${ dateGmt.replace( /Z$/, '' ) }Z` ); +} + test.describe( 'Scheduled Posts', () => { test.beforeEach( async ( { requestUtils } ) => { await requestUtils.deleteAllPosts(); @@ -42,22 +53,40 @@ 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 + // The test spans real time by design (it waits out the post's own + // scheduled date, then polls for the transition), 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. + // Schedule the post far enough out to clear the coercion in + // wp_insert_post(): a post submitted as `future` is silently stored as + // `publish` unless its date is at least MINUTE_IN_SECONDS ahead of the + // server's clock, compared at whole-second resolution. Scheduling + // exactly a minute out would therefore depend on the insert landing in + // the same clock second as the reading it was derived from, which is + // not something a loaded machine guarantees. 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 ) ), + date_gmt: toRestDateGmt( new Date( serverNow.getTime() + 90_000 ) ), } ); - expect( post.status ).toBe( 'future' ); + expect( + post.status, + 'the post should be stored as scheduled; `publish` here means its date landed under a minute ahead of the server clock' + ).toBe( 'future' ); + + // Wait out the date the server actually stored, measured against the + // server's clock as it reads now, so the wait carries no estimate made + // before the post existed. The extra five seconds put the explicit + // drive that follows safely past the moment the event comes due. + const dueAt = fromRestDateGmt( post.date_gmt ); + const clockBeforeWait = await getServerNow( requestUtils ); + const waitMs = + Math.max( 0, dueAt.getTime() - clockBeforeWait.getTime() ) + 5_000; // Let the scheduled time pass with no requests to the site. Any // ordinary request that observes a due event spawns WordPress's @@ -68,7 +97,7 @@ test.describe( 'Scheduled Posts', () => { // 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 ); + setTimeout( resolve, waitMs ); } ); // Drive cron explicitly, once: the request runs due events From 5fa66afc33d0dba2e7bfff782d3c957a69853833 Mon Sep 17 00:00:00 2001 From: csmcneill Date: Wed, 29 Jul 2026 20:37:22 -0500 Subject: [PATCH 5/7] Tests: Give cron long enough to recover a starved run. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wp-cron.php runs every due event in timestamp order, and an install has a queue of unrelated core events already due ahead of anything a test creates. If one of those is slow, the drive is still working through them when its own lock expires, and it stops there. Sixty seconds after it started, the next request to reach the site reclaims that lock and finishes the queue, so the transition lands at exactly the sixty second mark — which made a sixty second poll budget a dead heat rather than a limit. Raise the budget past WP_CRON_LOCK_TIMEOUT and reissue the drive on a cadence just past it, so the recovery is deliberate rather than dependent on an unrelated request happening along. Do not wait on the drive itself: under PHP-FPM wp-cron.php calls fastcgi_finish_request() and answers immediately, while under mod_php the response waits for every due event, so awaiting it stalls the caller for as long as the slow event runs. A short timeout is enough, and its expiry can be ignored, because wp-cron.php sets ignore_user_abort( true ) and the run continues either way. Co-Authored-By: Craft Agent --- tests/e2e/specs/scheduled-posts.test.js | 103 ++++++++++++++++++------ 1 file changed, 78 insertions(+), 25 deletions(-) diff --git a/tests/e2e/specs/scheduled-posts.test.js b/tests/e2e/specs/scheduled-posts.test.js index 40dd3017e31bd..f2608b3137c6b 100644 --- a/tests/e2e/specs/scheduled-posts.test.js +++ b/tests/e2e/specs/scheduled-posts.test.js @@ -45,6 +45,46 @@ function fromRestDateGmt( dateGmt ) { return new Date( `${ dateGmt.replace( /Z$/, '' ) }Z` ); } +/** + * Asks WordPress to run its due cron events, without waiting for them. + * + * How long wp-cron.php holds the connection open depends on the server: under + * PHP-FPM it calls fastcgi_finish_request() and answers immediately, while + * under mod_php the response waits for every due event to finish. Since one + * slow event would then stall the caller for as long as it runs, the request is + * given a short timeout and its failure ignored — wp-cron.php sets + * ignore_user_abort( true ), so the run continues server-side either way, and + * what matters here is only that it was asked to start. + * + * @param {import('@wordpress/e2e-test-utils-playwright').RequestUtils} requestUtils The request utils fixture. + * @return {Promise} + */ +async function driveCron( requestUtils ) { + try { + await requestUtils.request.get( + new URL( '/wp-cron.php', requestUtils.baseURL ).toString(), + { timeout: 5_000 } + ); + } catch { + // Timed out holding the connection open; the run itself is unaffected. + } +} + +/** + * Reads a post's status, including statuses only visible to an editor. + * + * @param {import('@wordpress/e2e-test-utils-playwright').RequestUtils} requestUtils The request utils fixture. + * @param {number} postId The post ID. + * @return {Promise} The post status. + */ +async function readPostStatus( requestUtils, postId ) { + const post = await requestUtils.rest( { + path: `/wp/v2/posts/${ postId }`, + params: { context: 'edit' }, + } ); + return post.status; +} + test.describe( 'Scheduled Posts', () => { test.beforeEach( async ( { requestUtils } ) => { await requestUtils.deleteAllPosts(); @@ -53,10 +93,12 @@ test.describe( 'Scheduled Posts', () => { test( 'publishes a scheduled post when its time arrives', async ( { requestUtils, } ) => { - // The test spans real time by design (it waits out the post's own - // scheduled date, then polls for the transition), so triple the - // per-test budget: a genuine failure should surface as the poll's - // message, not as a generic test timeout. + // The test spans real time by design: it waits out the post's own + // scheduled date, then allows cron long enough to run it. Ninety + // seconds of scheduling plus the poll's budget below puts the failure + // path past the inherited per-test limit, so triple it — a genuine + // failure should surface as the poll's message rather than as a + // generic test timeout. test.slow(); // Schedule the post far enough out to clear the coercion in @@ -90,38 +132,49 @@ test.describe( 'Scheduled Posts', () => { // 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. + // loopback cron and takes the doing_cron lock — and where loopback + // requests cannot be made, the spawned run never executes, so the lock + // only shuts wp-cron.php out for the next minute. A quiet wait leaves + // the lock free, or stale, at the moment the event comes due, so the + // first drive below can claim it straight away. await new Promise( ( resolve ) => { setTimeout( resolve, waitMs ); } ); - // 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() - ); + // Drive cron explicitly rather than waiting for WordPress to spawn it: + // a spawned run is a loopback request to the site's own address, which + // not every containerised environment can make. + // + // Drive repeatedly, on a cadence just past WP_CRON_LOCK_TIMEOUT, since + // one drive is not guaranteed to reach this post's event. wp-cron.php + // runs every due event in timestamp order, and an install has a queue + // of unrelated core events — privacy cleanups, do_pings, transient + // expiry — already due ahead of anything created during a test. A slow + // event in that queue leaves the drive still working when its own lock + // expires a minute later, at which point it stops. A later drive + // resumes from there, because every event the previous one reached was + // unscheduled before it ran. + // + // This is why the budget below is well over a minute: the recovery path + // becomes available exactly when the lock expires, so a timeout equal + // to WP_CRON_LOCK_TIMEOUT is a dead heat rather than a limit. + const driveIntervalMs = 65_000; + let nextDriveAt = 0; await expect .poll( async () => { - const updated = await requestUtils.rest( { - path: `/wp/v2/posts/${ post.id }`, - params: { context: 'edit' }, - } ); - return updated.status; + if ( Date.now() >= nextDriveAt ) { + nextDriveAt = Date.now() + driveIntervalMs; + await driveCron( requestUtils ); + } + return readPostStatus( requestUtils, post.id ); }, { message: - 'the scheduled post should transition to publish once its date passes', - timeout: 60_000, + 'the scheduled post should transition to publish once its date passes and cron runs', + intervals: [ 1_000 ], + timeout: 150_000, } ) .toBe( 'publish' ); From 0b28beb02d7038761115fa4408b8fc8f513f06ab Mon Sep 17 00:00:00 2001 From: csmcneill Date: Wed, 29 Jul 2026 21:08:10 -0500 Subject: [PATCH 6/7] Tests: Keep the cron lock under the explicit drives' sole control. Every request the scheduled posts spec makes now carries `doing_wp_cron` except the drives themselves. spawn_cron() returns early when that argument is present, so only a drive can take the doing_cron lock. This matters more than it looks. An ordinary request that takes the lock holds it for WP_CRON_LOCK_TIMEOUT and then delegates the run to a loopback request, so where loopback cannot be made, a one-second status poll takes the lock every time it expires, delivers nothing, and leaves every later drive to find a lock it cannot claim. The transition then never happens, whatever the budget. Confirmed against a reproduction that starves the first drive with a slow event scheduled ahead of the post: the transition used to land at exactly sixty seconds, from the poll's own spawned run, and now lands with the drive on its cadence. Follow-up to [62838]. Co-Authored-By: Craft Agent --- tests/e2e/specs/scheduled-posts.test.js | 34 +++++++++++++++++-------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/tests/e2e/specs/scheduled-posts.test.js b/tests/e2e/specs/scheduled-posts.test.js index f2608b3137c6b..5411d560b07a0 100644 --- a/tests/e2e/specs/scheduled-posts.test.js +++ b/tests/e2e/specs/scheduled-posts.test.js @@ -11,9 +11,12 @@ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; * @return {Promise} The server's current time. */ async function getServerNow( requestUtils ) { - const response = await requestUtils.request.get( - new URL( '/', requestUtils.baseURL ).toString() - ); + // `doing_wp_cron` makes spawn_cron() return early, so reading the clock + // cannot take the doing_cron lock. See driveCron() for why that matters. + const url = new URL( '/', requestUtils.baseURL ); + url.searchParams.set( 'doing_wp_cron', '1' ); + + const response = await requestUtils.request.get( url.toString() ); const dateHeader = response.headers().date; if ( ! dateHeader ) { throw new Error( @@ -48,6 +51,14 @@ function fromRestDateGmt( dateGmt ) { /** * Asks WordPress to run its due cron events, without waiting for them. * + * This is the only request in the spec that may take the doing_cron lock; + * every other one carries `doing_wp_cron` so that spawn_cron() returns early. + * Keeping the lock under these drives' sole control is what makes the retry + * cadence below work: an ordinary request that takes the lock holds it for + * WP_CRON_LOCK_TIMEOUT and then delegates the run to a loopback request, so + * where loopback cannot be made, a stream of polling requests would take the + * lock in turn, deliver nothing, and shut every later drive out of it. + * * How long wp-cron.php holds the connection open depends on the server: under * PHP-FPM it calls fastcgi_finish_request() and answers immediately, while * under mod_php the response waits for every due event to finish. Since one @@ -80,7 +91,9 @@ async function driveCron( requestUtils ) { async function readPostStatus( requestUtils, postId ) { const post = await requestUtils.rest( { path: `/wp/v2/posts/${ postId }`, - params: { context: 'edit' }, + // `doing_wp_cron` makes spawn_cron() return early, so polling cannot + // take the doing_cron lock. See driveCron() for why that matters. + params: { context: 'edit', doing_wp_cron: '1' }, } ); return post.status; } @@ -130,13 +143,12 @@ test.describe( 'Scheduled Posts', () => { const waitMs = Math.max( 0, dueAt.getTime() - clockBeforeWait.getTime() ) + 5_000; - // 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 where loopback - // requests cannot be made, the spawned run never executes, so the lock - // only shuts wp-cron.php out for the next minute. A quiet wait leaves - // the lock free, or stale, at the moment the event comes due, so the - // first drive below can claim it straight away. + // Let the scheduled time pass with no requests to the site. Creating + // the post was itself an ordinary request, so it may have spawned cron + // and taken the doing_cron lock on the way out; a quiet wait longer + // than WP_CRON_LOCK_TIMEOUT leaves that lock stale by the time the + // event comes due, so the first drive below can claim it straight + // away rather than being turned away. await new Promise( ( resolve ) => { setTimeout( resolve, waitMs ); } ); From 9a348ed99f20243b9713888ca3e7744150e799cf Mon Sep 17 00:00:00 2001 From: csmcneill Date: Wed, 29 Jul 2026 22:19:55 -0500 Subject: [PATCH 7/7] Tests: Resolve the spec's URLs against the full base URL. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-relative URLs resolve against the origin alone, so on a subdirectory install — a WP_BASE_URL like http://localhost/wp — the spec's clock reads and cron drives would escape the install and address the server root. The e2e tooling already guards navigations against exactly this: the page fixture resolves root-relative paths against the full baseURL so tests work on subdirectory installs. That handling covers page.goto() alone, not the request context these helpers drive cron through. Resolve every URL the spec builds through one helper that normalizes the base URL's trailing slash and resolves paths relative to it, the same way the page fixture resolves navigations. Co-Authored-By: Craft Agent --- tests/e2e/specs/scheduled-posts.test.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/e2e/specs/scheduled-posts.test.js b/tests/e2e/specs/scheduled-posts.test.js index 5411d560b07a0..529ff5bd57703 100644 --- a/tests/e2e/specs/scheduled-posts.test.js +++ b/tests/e2e/specs/scheduled-posts.test.js @@ -3,6 +3,24 @@ */ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; +/** + * Resolves a path against the site's base URL, preserving any subdirectory + * the base URL carries. Root-relative URLs resolve against the origin + * alone — new URL( '/wp-cron.php', 'http://localhost/wp' ) addresses the + * server root, outside the install — so paths here are resolved relative + * to the base URL, normalized to end in a slash. + * + * @param {import('@wordpress/e2e-test-utils-playwright').RequestUtils} requestUtils The request utils fixture. + * @param {string} [path] A path under the WordPress root, without a leading slash. + * @return {URL} The resolved URL. + */ +function siteUrl( requestUtils, path = '' ) { + const baseURL = requestUtils.baseURL.endsWith( '/' ) + ? requestUtils.baseURL + : `${ requestUtils.baseURL }/`; + return new URL( path, baseURL ); +} + /** * Reads the server's clock from an HTTP Date header, so scheduling is * immune to clock skew between the test host and the server. @@ -13,7 +31,7 @@ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; async function getServerNow( requestUtils ) { // `doing_wp_cron` makes spawn_cron() return early, so reading the clock // cannot take the doing_cron lock. See driveCron() for why that matters. - const url = new URL( '/', requestUtils.baseURL ); + const url = siteUrl( requestUtils ); url.searchParams.set( 'doing_wp_cron', '1' ); const response = await requestUtils.request.get( url.toString() ); @@ -73,7 +91,7 @@ function fromRestDateGmt( dateGmt ) { async function driveCron( requestUtils ) { try { await requestUtils.request.get( - new URL( '/wp-cron.php', requestUtils.baseURL ).toString(), + siteUrl( requestUtils, 'wp-cron.php' ).toString(), { timeout: 5_000 } ); } catch {