Commit 2300175e18a for woocommerce

commit 2300175e18a9b82c2ff10c3beb4c26f45b94149b
Author: Rafael Meneses <meneses.tio@gmail.com>
Date:   Fri Nov 13 14:30:00 2026 -0300

    Add Jetpack Beta and Jurassic Ninja options to the PR build comment (#70951)

    * feat(ci): Add Jetpack Beta and Jurassic Ninja options to the PR build comment

    The "Build Live Branch" workflow builds a zip for every pull request,
    reports it to the Beta Download webhook, and comments on the pull
    request with a WordPress Playground link.

    The comment only mentioned Playground. The same build can already be
    installed through Jetpack Beta, and Jurassic Ninja can launch a site on
    it, but nothing told a reviewer so. A reviewer who needs a real site,
    with a public address and MySQL, had no pointer from the pull request.

    List the three ways in one "Test the build" comment: Jetpack Beta by PR
    number or branch name, a Jurassic Ninja link for the branch, and the
    Playground link. The Jurassic Ninja option says to log in first,
    because the link sends logged-out visitors to the homepage without
    creating a site. Links are encoded so a branch name or the Blueprint
    cannot end a Markdown link early. A comment posted under the old
    heading is updated in place instead of getting a second comment.

    Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

    * feat(ci): Add a sample product to the PR Playground preview

    The Playground link in the pull request comment opens a store with the
    setup wizard skipped and no products.

    Most storefront, cart and checkout changes cannot be looked at without
    a product, so a reviewer had to create one by hand in every preview,
    and a preview does not keep it.

    Create one published simple product when the store has none, in a
    runPHP step after WooCommerce is installed.

    Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

    * feat(ci): Add HPOS order storage to the PR Playground preview

    The Playground preview linked from the PR build comment installs
    WooCommerce through a Blueprint. A store installed that way starts with
    woocommerce_custom_orders_table_enabled set to "no", so it keeps orders
    in the posts tables.

    New stores use High-Performance Order Storage, so a reviewer testing an
    order change in the preview was testing it on the storage most new
    stores do not use.

    Set the option to "yes" in the Blueprint step that already sets site
    options. WooCommerce creates the order tables when the option changes.
    Compatibility mode stays off, as on a new store.

    Checked by running the Blueprint against a PR build with the Playground
    CLI: HPOS is in use, the order tables exist, and a new order is stored
    in wc_orders.

    Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>

diff --git a/.github/workflows/pr-build-live-branch.yml b/.github/workflows/pr-build-live-branch.yml
index d37347ae98b..b6112519a9f 100644
--- a/.github/workflows/pr-build-live-branch.yml
+++ b/.github/workflows/pr-build-live-branch.yml
@@ -122,7 +122,7 @@ jobs:
                     --form-string "plugins=$PLUGIN_DATA" \
                     --form-string "secret=$SECRET"

-            - name: Comment on PR with WordPress Playground details
+            - name: Comment on PR with ways to test the build
               uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
               if: steps.prepare.outputs.plugin-data != '{}' && ! github.event.pull_request.head.repo.fork
               with:
diff --git a/.github/workflows/scripts/generate-playground-blueprint.js b/.github/workflows/scripts/generate-playground-blueprint.js
index 1095681f659..4194a2e885c 100644
--- a/.github/workflows/scripts/generate-playground-blueprint.js
+++ b/.github/workflows/scripts/generate-playground-blueprint.js
@@ -1,5 +1,21 @@
 const https = require( 'https' );

+const COMMENT_HEADING = 'Test the build';
+// Heading of the comment this script posted before it listed more than Playground.
+const LEGACY_COMMENT_HEADING = 'Test using WordPress Playground';
+
+// A store with no products has little to review, and a preview has nobody to add one.
+const CREATE_SAMPLE_PRODUCT_PHP = `<?php
+require '/wordpress/wp-load.php';
+if ( ! wc_get_products( array( 'limit' => 1 ) ) ) {
+	$product = new WC_Product_Simple();
+	$product->set_name( 'Sample product' );
+	$product->set_regular_price( '10' );
+	$product->set_status( 'publish' );
+	$product->save();
+}
+`;
+
 const generateWordpressPlaygroundBlueprint = ( runId, prNumber ) => {
 	const defaultSchema = {
 		landingPage: '/wp-admin/admin.php?page=wc-admin',
@@ -40,8 +56,14 @@ const generateWordpressPlaygroundBlueprint = ( runId, prNumber ) => {
 					woocommerce_onboarding_profile: {
 						skipped: true,
 					},
+					// A store installed by a Blueprint starts on legacy order storage.
+					woocommerce_custom_orders_table_enabled: 'yes',
 				},
 			},
+			{
+				step: 'runPHP',
+				code: CREATE_SAMPLE_PRODUCT_PHP,
+			},
 			{
 				step: 'login',
 				username: 'admin',
@@ -54,6 +76,80 @@ const generateWordpressPlaygroundBlueprint = ( runId, prNumber ) => {
 	return defaultSchema;
 };

+/**
+ * Encodes a value for use inside the destination of a Markdown link.
+ *
+ * encodeURIComponent() leaves parentheses and a few other characters as they
+ * are, and a parenthesis can end a Markdown link early.
+ *
+ * @param {string} value Value to encode.
+ * @return {string} Encoded value.
+ */
+const encodeForMarkdownLink = ( value ) =>
+	encodeURIComponent( value ).replace(
+		/[!'()*]/g,
+		( character ) =>
+			`%${ character.charCodeAt( 0 ).toString( 16 ).toUpperCase() }`
+	);
+
+/**
+ * Builds the Playground link for a Blueprint.
+ *
+ * The Blueprint is encoded so that no character in it can end the Markdown
+ * link early.
+ *
+ * @param {Object} blueprint Playground Blueprint.
+ * @return {string} Link that opens the Blueprint in WordPress Playground.
+ */
+const generatePlaygroundUrl = ( blueprint ) =>
+	`https://playground.wordpress.net/#${ encodeForMarkdownLink(
+		JSON.stringify( blueprint )
+	) }`;
+
+/**
+ * Builds the Jurassic Ninja link that launches a site on a branch's build.
+ *
+ * Jurassic Ninja installs the build through Jetpack Beta, which serves the
+ * builds this workflow reports to the Beta Download webhook.
+ *
+ * @param {string} branch Head branch of the pull request.
+ * @return {string} Link that launches the site.
+ */
+const generateJurassicNinjaUrl = ( branch ) =>
+	`https://jurassic.ninja/create/?jetpack-beta&nojetpack&branches.woocommerce=${ encodeForMarkdownLink(
+		branch
+	).replace( /%2F/g, '/' ) }`;
+
+const generateCommentBody = ( { prNumber, branch, playgroundUrl } ) => `
+## ${ COMMENT_HEADING }
+
+### Option 1. Jetpack Beta
+
+- Install and activate [Jetpack Beta](https://jetpack.com/beta/#download-and-install).
+- Use this build by searching for PR number \`${ prNumber }\` or branch name \`${ branch }\` in your-test.site/wp-admin/admin.php?page=jetpack-beta&plugin=woocommerce
+
+### Option 2. Jurassic Ninja - available for logged-in A12s
+
+:warning: Log in at [jurassic.ninja](https://jurassic.ninja/) first. Without a login, the link below takes you to the Jurassic Ninja homepage and no site is created.
+
+:rocket: [Launch a JN site with this branch](${ generateJurassicNinjaUrl(
+	branch
+) }) :rocket:
+
+### Option 3. WordPress Playground - runs in your browser
+
+:test_tube: [Test this pull request with WordPress Playground](${ playgroundUrl }): a store with the setup wizard skipped and a sample product.
+
+:information_source: [WordPress Playground](https://developer.wordpress.org/playground/) is an experimental project that creates a full WordPress instance entirely within the browser. The site runs on SQLite and has no public address, so webhooks and payment provider callbacks cannot reach it.
+
+Note that the Playground URL is valid for 30 days from when this comment was last updated. You can update it by closing/reopening the PR or pushing a commit that changes plugin code.
+`;
+
+const isBuildComment = ( comment ) =>
+	comment.user.type === 'Bot' &&
+	( comment.body.includes( COMMENT_HEADING ) ||
+		comment.body.includes( LEGACY_COMMENT_HEADING ) );
+
 async function run( { github, context, core } ) {
 	const commentInfo = {
 		owner: context.repo.owner,
@@ -66,10 +162,7 @@ async function run( { github, context, core } ) {
 	let existingCommentId = null;

 	for ( const currentComment of comments ) {
-		if (
-			currentComment.user.type === 'Bot' &&
-			currentComment.body.includes( 'Test using WordPress Playground' )
-		) {
+		if ( isBuildComment( currentComment ) ) {
 			existingCommentId = currentComment.id;
 			break;
 		}
@@ -80,19 +173,11 @@ async function run( { github, context, core } ) {
 		context.issue.number
 	);

-	const url = `https://playground.wordpress.net/#${ JSON.stringify(
-		defaultSchema
-	) }`;
-
-	const body = `
-## Test using WordPress Playground
-The changes in this pull request can be previewed and tested using a [WordPress Playground](https://developer.wordpress.org/playground/) instance.
-[WordPress Playground](https://developer.wordpress.org/playground/) is an experimental project that creates a full WordPress instance entirely within the browser.
-
-[Test this pull request with WordPress Playground](${ url }).
-
-Note that this URL is valid for 30 days from when this comment was last updated. You can update it by closing/reopening the PR or pushing a commit that changes plugin code.
-`;
+	const body = generateCommentBody( {
+		prNumber: context.issue.number,
+		branch: context.payload.pull_request.head.ref,
+		playgroundUrl: generatePlaygroundUrl( defaultSchema ),
+	} );

 	if ( existingCommentId ) {
 		await github.rest.issues.updateComment( {
@@ -107,4 +192,11 @@ Note that this URL is valid for 30 days from when this comment was last updated.
 	}
 }

-module.exports = { run };
+module.exports = {
+	run,
+	generateWordpressPlaygroundBlueprint,
+	generatePlaygroundUrl,
+	generateJurassicNinjaUrl,
+	generateCommentBody,
+	isBuildComment,
+};
diff --git a/.github/workflows/scripts/generate-playground-blueprint.test.js b/.github/workflows/scripts/generate-playground-blueprint.test.js
new file mode 100644
index 00000000000..f8136b34a2e
--- /dev/null
+++ b/.github/workflows/scripts/generate-playground-blueprint.test.js
@@ -0,0 +1,154 @@
+const assert = require( 'node:assert/strict' );
+const test = require( 'node:test' );
+
+const {
+	run,
+	generateWordpressPlaygroundBlueprint,
+	generatePlaygroundUrl,
+	generateJurassicNinjaUrl,
+	generateCommentBody,
+} = require( './generate-playground-blueprint' );
+
+const RUN_ID = 123456;
+const PR_NUMBER = 789;
+const BRANCH = 'fix/some-bug';
+
+const createGithub = ( comments ) => {
+	const calls = { created: [], updated: [] };
+	return {
+		calls,
+		rest: {
+			issues: {
+				listComments: async () => ( { data: comments } ),
+				createComment: async ( args ) => calls.created.push( args ),
+				updateComment: async ( args ) => calls.updated.push( args ),
+			},
+		},
+	};
+};
+
+const context = {
+	repo: { owner: 'woocommerce', repo: 'woocommerce' },
+	issue: { number: PR_NUMBER },
+	runId: RUN_ID,
+	payload: { pull_request: { head: { ref: BRANCH } } },
+};
+
+test( 'blueprint installs the build of the run and creates a sample product', () => {
+	const blueprint = generateWordpressPlaygroundBlueprint( RUN_ID, PR_NUMBER );
+
+	assert.equal(
+		blueprint.steps[ 0 ].pluginData.url,
+		'https://playground.wordpress.net/plugin-proxy.php?org=woocommerce&repo=woocommerce&workflow=Build%20Live%20Branch&artifact=plugins-123456&pr=789'
+	);
+	const stepNames = blueprint.steps.map( ( step ) => step.step );
+	assert.deepEqual( stepNames, [
+		'installPlugin',
+		'installPlugin',
+		'setSiteOptions',
+		'runPHP',
+		'login',
+	] );
+	assert.match( blueprint.steps[ 3 ].code, /new WC_Product_Simple\(\)/ );
+} );
+
+test( 'blueprint skips the setup wizard and stores orders in HPOS tables', () => {
+	const blueprint = generateWordpressPlaygroundBlueprint( RUN_ID, PR_NUMBER );
+
+	assert.deepEqual( blueprint.steps[ 2 ].options, {
+		woocommerce_onboarding_profile: { skipped: true },
+		woocommerce_custom_orders_table_enabled: 'yes',
+	} );
+} );
+
+test( 'playground link cannot end a Markdown link early and decodes to the blueprint', () => {
+	const blueprint = generateWordpressPlaygroundBlueprint( RUN_ID, PR_NUMBER );
+	const url = generatePlaygroundUrl( blueprint );
+	const prefix = 'https://playground.wordpress.net/#';
+
+	assert.ok( url.startsWith( prefix ) );
+	assert.doesNotMatch( url, /[\s()']/ );
+	assert.deepEqual(
+		JSON.parse( decodeURIComponent( url.slice( prefix.length ) ) ),
+		blueprint
+	);
+} );
+
+test( 'jurassic ninja link keeps slashes and encodes other characters', () => {
+	assert.equal(
+		generateJurassicNinjaUrl( 'fix/some-bug' ),
+		'https://jurassic.ninja/create/?jetpack-beta&nojetpack&branches.woocommerce=fix/some-bug'
+	);
+	assert.equal(
+		generateJurassicNinjaUrl( 'fix/a&b#c(d)' ),
+		'https://jurassic.ninja/create/?jetpack-beta&nojetpack&branches.woocommerce=fix/a%26b%23c%28d%29'
+	);
+} );
+
+test( 'comment lists the three options for the pull request', () => {
+	const body = generateCommentBody( {
+		prNumber: PR_NUMBER,
+		branch: BRANCH,
+		playgroundUrl: 'https://playground.wordpress.net/#blueprint',
+	} );
+
+	assert.ok( body.includes( '## Test the build' ) );
+	assert.ok(
+		body.includes(
+			'searching for PR number `789` or branch name `fix/some-bug` in your-test.site/wp-admin/admin.php?page=jetpack-beta&plugin=woocommerce'
+		)
+	);
+	assert.ok(
+		body.includes(
+			'[Launch a JN site with this branch](https://jurassic.ninja/create/?jetpack-beta&nojetpack&branches.woocommerce=fix/some-bug)'
+		)
+	);
+	assert.ok(
+		body.includes(
+			'[Test this pull request with WordPress Playground](https://playground.wordpress.net/#blueprint)'
+		)
+	);
+} );
+
+test( 'creates the comment when the pull request has none', async () => {
+	const github = createGithub( [
+		{ id: 1, user: { type: 'User' }, body: '## Test the build' },
+		{ id: 2, user: { type: 'Bot' }, body: 'Unrelated bot comment' },
+	] );
+
+	await run( { github, context } );
+
+	assert.equal( github.calls.updated.length, 0 );
+	assert.equal( github.calls.created.length, 1 );
+	assert.equal( github.calls.created[ 0 ].issue_number, 789 );
+	assert.ok( github.calls.created[ 0 ].body.includes( '## Test the build' ) );
+} );
+
+test( 'updates the comment it posted before', async () => {
+	const github = createGithub( [
+		{ id: 5, user: { type: 'Bot' }, body: '## Test the build\n...' },
+	] );
+
+	await run( { github, context } );
+
+	assert.equal( github.calls.created.length, 0 );
+	assert.equal( github.calls.updated.length, 1 );
+	assert.equal( github.calls.updated[ 0 ].comment_id, 5 );
+} );
+
+test( 'updates a comment posted under the previous heading', async () => {
+	const github = createGithub( [
+		{
+			id: 7,
+			user: { type: 'Bot' },
+			body: '## Test using WordPress Playground\n...',
+		},
+	] );
+
+	await run( { github, context } );
+
+	assert.equal( github.calls.created.length, 0 );
+	assert.equal( github.calls.updated.length, 1 );
+	assert.equal( github.calls.updated[ 0 ].comment_id, 7 );
+	assert.ok( github.calls.updated[ 0 ].body.includes( '## Test the build' ) );
+} );