Skip to content
Closed
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
24 changes: 21 additions & 3 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2375,15 +2375,33 @@ function playgroundLogScope(sitePath) {
ipcMain.handle('playground:start', async (event, sitePath) => {
// Ensure a per-site SMTP server is running alongside the dev server and get its port
const smtp = await ensureSmtpServerForSite(sitePath).catch(() => null);

// How this site is served depends on its project type (#251). Core's build/
// is a whole WordPress, served as the docroot; a Gutenberg checkout is a
// plugin, mounted into a stock WordPress Playground installs. The runner turns
// this into the Playground mount/install options.
//
// Read before the in-flight guard, never between it and the assignment below:
// every await in that window lets a second start slip past the guard and
// overwrite the entry, orphaning the first runner — unreachable to both
// playground:stop and the before-quit sweep, which only walk the map.
const serve = projectTypeForSite(await readSiteMeta(sitePath)).serve;

if (playgroundServers[sitePath]?.child) {
return { ok: true, url: playgroundServers[sitePath].url };
}
const buildDir = path.join(sitePath, 'build');
const runnerPath = path.join(__dirname, 'server-runner.js');

const serveConfig = serve.strategy === 'plugin-mount'
? { strategy: 'plugin-mount', pluginDir: sitePath, pluginSlug: serve.pluginSlug }
: { strategy: 'docroot', docroot: buildDir };
const serveCwd = serve.strategy === 'plugin-mount' ? sitePath : buildDir;

const logScope = playgroundLogScope(sitePath);
logEvent(logScope, `starting server for ${buildDir} (smtp port ${(smtp && smtp.port) ? smtp.port : 25})`);
const child = spawnRunner(runnerPath, [buildDir], {
cwd: buildDir,
logEvent(logScope, `starting ${serve.strategy} server for ${serveCwd} (smtp port ${(smtp && smtp.port) ? smtp.port : 25})`);
const child = spawnRunner(runnerPath, [JSON.stringify(serveConfig)], {
cwd: serveCwd,
extraEnv: {
// Provide SMTP settings to the server runner so it can configure WP constants
WP_MAIL_SMTP_HOST: '127.0.0.1',
Expand Down
77 changes: 77 additions & 0 deletions src/playground-plan.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use strict';

// Plans the Playground launch options that differ by serve strategy (#251).
//
// Kept pure and dependency-free — no `@wp-playground/cli`, no fs — so the
// branching can be unit tested without booting WASM PHP. server-runner.js calls
// this, merges the result with the blueprint constants (debug + SMTP), and
// spawns the CLI. The actual boot stays an integration concern.
//
// The two strategies mirror @wp-playground/cli's own `--auto-mount` handling
// (see run-cli's plugin vs WordPress-docroot branches):
//
// - 'docroot' — the site's build/ already IS a WordPress install
// (wordpress-develop / Core). Mount it before install as /wordpress and skip
// the download with `install-from-existing-files-if-needed`; a fresh download
// would unpack a second WordPress over the mount.
//
// - 'plugin-mount' — the site is a plugin, not a WordPress (Gutenberg). Leave
// the install mode at Playground's default so it downloads and installs a
// stock WordPress, then mount the built checkout as a plugin under
// wp-content/plugins/<slug> and activate it — exactly what the CLI does for a
// plugin passed to --auto-mount (a `mount` plus an `activatePlugin` step).

const WORDPRESS_VFS_ROOT = '/wordpress';
const PLUGINS_VFS_BASE = `${WORDPRESS_VFS_ROOT}/wp-content/plugins`;

// Returns the runCLI option fragment for a serve strategy. Keys are the exact
// ones @wp-playground/cli reads: `mount`, `mount-before-install`,
// `additional-blueprint-steps`, and (docroot only) `wordpressInstallMode`.
function planPlaygroundLaunch(config) {
const cfg = config || {};

if (cfg.strategy === 'plugin-mount') {
if (!cfg.pluginDir) throw new Error('plugin-mount serve needs a pluginDir');
const slug = cfg.pluginSlug || 'plugin';
const vfsPath = `${PLUGINS_VFS_BASE}/${slug}`;
return {
// No wordpressInstallMode: Playground's default downloads and installs
// a stock WordPress for the plugin to live in.
mount: [{ hostPath: cfg.pluginDir, vfsPath }],
'mount-before-install': [],
'additional-blueprint-steps': [{ step: 'activatePlugin', pluginPath: vfsPath }]
};
}

// 'docroot' (default): the build dir is the whole WordPress install.
if (!cfg.docroot) throw new Error('docroot serve needs a docroot');
return {
mount: [],
'mount-before-install': [{ hostPath: cfg.docroot, vfsPath: WORDPRESS_VFS_ROOT }],
'additional-blueprint-steps': [],
wordpressInstallMode: 'install-from-existing-files-if-needed'
};
}

// The wp-config constants a strategy needs on top of the shared debug/SMTP set.
//
// Only 'plugin-mount' asks for any, and it asks for the two that make the
// mounted directory read-only from inside WordPress. The mount is a read-write
// NODEFS mount of the *source checkout* — not a regenerable build/ — so
// Plugins → Delete on the mounted plugin, or the plugin file editor, writes
// straight through to the contributor's working tree, uncommitted work and .git
// included. Core's docroot strategy exposes only build/, which the app rebuilds,
// so it keeps WordPress's defaults.
//
// The cost is real and deliberate: DISALLOW_FILE_MODS also blocks installing a
// second plugin or theme into the preview. Losing an afternoon of uncommitted
// work is worse than restarting a preview you can restart.
function planServeConstants(config) {
const cfg = config || {};
if (cfg.strategy === 'plugin-mount') {
return { DISALLOW_FILE_MODS: true, DISALLOW_FILE_EDIT: true };
}
return {};
}

module.exports = { planPlaygroundLaunch, planServeConstants, WORDPRESS_VFS_ROOT, PLUGINS_VFS_BASE };
5 changes: 3 additions & 2 deletions src/project-type.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,9 @@ const PROJECT_TYPES = {
},

// 'plugin-mount' — Gutenberg is a plugin, so Playground boots a stock
// WordPress and mounts the built checkout as an active plugin.
serve: { strategy: 'plugin-mount' },
// WordPress and mounts the built checkout as an active plugin under
// wp-content/plugins/<pluginSlug>.
serve: { strategy: 'plugin-mount', pluginSlug: 'gutenberg' },

// 'repo-relative' — Gutenberg PR diffs are already repo-relative
// (packages/…); no src-layout rewrite.
Expand Down
59 changes: 34 additions & 25 deletions src/server-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { hideChildWindows } = require('./hide-child-windows');
const { bindLoopbackOnly } = require('./bind-loopback');
const { formatErrorChain } = require('./error-chain');
const { WP_DEBUG_CONSTANTS } = require('./wp-debug-constants');
const { planPlaygroundLaunch, planServeConstants } = require('./playground-plan.cjs');

// Must run before the Playground CLI is required, so anything it spawns is
// covered too.
Expand All @@ -16,40 +17,45 @@ bindLoopbackOnly();
const { writeFiles: playgroundWriteFiles } = require('@php-wasm/universal');

async function main() {
const buildDir = process.argv[2];
if (!buildDir) {
console.error('No build directory provided');
// The parent passes a JSON serve config (#251): either the Core docroot to
// mount as WordPress, or a Gutenberg plugin checkout to mount into a stock
// WordPress. planPlaygroundLaunch turns the strategy into the runCLI mount /
// install-mode / blueprint-step options; the SMTP + debug constants are added
// here because they come from this process's environment.
const raw = process.argv[2];
if (!raw) {
console.error('No serve config provided');
process.exit(1);
}
let serveConfig;
try {
serveConfig = JSON.parse(raw);
} catch (e) {
console.error(`Invalid serve config: ${String(e && e.message ? e.message : e)}`);
process.exit(1);
}
const absBuild = path.resolve(buildDir);

try {
const launch = planPlaygroundLaunch(serveConfig);
const serveConstants = planServeConstants(serveConfig);
const { runCLI } = require('@wp-playground/cli');
console.log("Running CLI");
const result = await runCLI({
command: 'server',
// Mount the build directory before install as /wordpress to use existing build
'mount-before-install': [ { hostPath: absBuild, vfsPath: '/wordpress' } ],
// The mounted build/ already is WordPress, so Playground must not go
// looking for one. Left unset this defaults to `download-and-install`:
// it fetches a WordPress release and unpacks it over the mount, failing
// on every file that is already there. That wasted pass is what makes
// startup take minutes on Windows.
//
// Only `download-and-install` downloads, so any other value skips it —
// but not all of them are safe here. `do-not-attempt-installing` (the
// mode Playground also calls `mount-only`) additionally skips setting up
// the SQLite integration plugin, and a wordpress-develop build/ carries
// no database driver of its own, so WordPress would boot with nothing to
// connect to. `install-from-existing-files-if-needed` skips the download
// and still prepares SQLite.
//
// Passed as `wordpressInstallMode` rather than the equivalent `mode`
// option because `mode` is only read on the Blueprint v2 code path, and
// the blueprint below is v1. Passing both is an error.
wordpressInstallMode: 'install-from-existing-files-if-needed',
// The mount layout and install mode that fit this site's project type.
// For Core, `mount-before-install` puts the build/ at /wordpress and
// `wordpressInstallMode: install-from-existing-files-if-needed` skips the
// download (a fresh unpack over the mount is what made startup take
// minutes on Windows; `do-not-attempt-installing` would skip SQLite too
// and leave WordPress with no database driver). For Gutenberg, `mount`
// puts the checkout under wp-content/plugins and the default install mode
// downloads a stock WordPress for it to run in.
...launch,
verbosity: 'debug',
blueprint: {
// Passed as a v1 blueprint (constants + steps). `wordpressInstallMode`
// above is used rather than the v2-only `mode`; passing both is an
// error.
constants: {
// Debug first, mail second. This is the only point at which
// constants can be set: Playground generates the wp-config.php
Expand All @@ -60,7 +66,10 @@ async function main() {
'WP_MAIL_SMTP_AUTH': String(process.env.WP_MAIL_SMTP_AUTH || 'false') === 'true',
'WP_MAIL_SMTP_SECURE': process.env.WP_MAIL_SMTP_SECURE || '', // '', 'ssl', or 'tls'
'WP_MAIL_SMTP_USER': process.env.WP_MAIL_SMTP_USER || '',
'WP_MAIL_SMTP_PASS': process.env.WP_MAIL_SMTP_PASS || ''
'WP_MAIL_SMTP_PASS': process.env.WP_MAIL_SMTP_PASS || '',
// Last, so a strategy that has to protect the host directory it
// mounted cannot be overridden by the shared sets above.
...serveConstants
}
}
});
Expand Down
68 changes: 68 additions & 0 deletions test/ipc-wiring.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1610,10 +1610,12 @@ test('playground:start spawns the server runner with the environment npm-runner
const env = { PATH: '/shims' };
const buildChildEnv = spy(() => env);
const cp = stubbedSpawn();
const settings = fakeSettingsStore({ sites: ['/sites/wp'], siteMeta: { '/sites/wp': {} } });
const main = loadMain({
stubs: {
...silentLogging(),
...noSmtpServer(),
...settings.stubs,
'child_process': { spawn: cp.spawn },
'./npm-runner': { buildChildEnv }
}
Expand All @@ -1631,6 +1633,70 @@ test('playground:start spawns the server runner with the environment npm-runner
assert.equal(buildChildEnv.calls[0][0].extraEnv.WP_MAIL_SMTP_HOST, '127.0.0.1');
assert.equal(buildChildEnv.calls[0][0].extraEnv.WP_MAIL_SMTP_PORT, '25');
assertCrossPlatformSpawnOptions(cp.spawned[0].options, 'playground:start');

// A site with no project type is Core: served as a docroot, the build/ dir
// mounted as WordPress (#251).
const serveConfig = JSON.parse(cp.spawned[0].args[1]);
assert.equal(serveConfig.strategy, 'docroot');
assert.equal(path.basename(serveConfig.docroot), 'build');
});

// A Gutenberg site is a plugin, not a WordPress: the runner is handed the
// checkout to mount into a stock WordPress, not a docroot to serve (#251).
test('playground:start serves a Gutenberg site as a mounted plugin', async (t) => {
const cp = stubbedSpawn();
const settings = fakeSettingsStore({ sites: ['/sites/gb'], siteMeta: { '/sites/gb': { projectType: 'gutenberg' } } });
const main = loadMain({
stubs: {
...silentLogging(),
...noSmtpServer(),
...settings.stubs,
'child_process': { spawn: cp.spawn },
'./npm-runner': { buildChildEnv: () => ({ PATH: '/shims' }) }
}
});

await reachSpawn(t, cp, main.invoke('playground:start', '/sites/gb'));

const serveConfig = JSON.parse(cp.spawned[0].args[1]);
assert.equal(serveConfig.strategy, 'plugin-mount');
assert.equal(serveConfig.pluginDir, '/sites/gb', 'the checkout root is the plugin, not its build/ subdir');
assert.equal(serveConfig.pluginSlug, 'gutenberg');
});

// Two starts for the same site must produce one server. The guard that enforces
// this reads a map the handler only writes *after* it spawns, so it holds only
// while nothing awaits in between — resolving the site's project type (#251) put
// an await squarely in that window. The loser of the race would otherwise
// overwrite the map entry and orphan a live PHP-WASM server: playground:stop and
// the before-quit sweep both walk the map, so it would survive the app quitting,
// still holding its port.
test('two concurrent playground:start calls for one site spawn a single server', async (t) => {
const cp = stubbedSpawn();
const settings = fakeSettingsStore({ sites: ['/sites/wp'], siteMeta: { '/sites/wp': {} } });
const main = loadMain({
stubs: {
...silentLogging(),
...noSmtpServer(),
...settings.stubs,
'child_process': { spawn: cp.spawn },
'./npm-runner': { buildChildEnv: () => ({ PATH: '/shims' }) }
}
});

const both = Promise.all([
main.invoke('playground:start', '/sites/wp'),
main.invoke('playground:start', '/sites/wp')
]);
for (let turn = 0; turn < 100 && cp.spawned.length === 0; turn++) {
await new Promise(setImmediate);
}
t.after(async () => {
for (const child of cp.children) child.emit('close', 0, null);
await both.catch(() => {});
});

assert.equal(cp.spawned.length, 1, 'the second start spawned a second server, orphaning the first');
});

test('playground-web:start spawns its runner through npm-runner too', async (t) => {
Expand Down Expand Up @@ -1658,10 +1724,12 @@ test('playground-web:start spawns its runner through npm-runner too', async (t)
test('playground:stop ends the server tree rather than signalling the child', async (t) => {
const cp = stubbedSpawn();
const killChildTree = spy();
const settings = fakeSettingsStore({ sites: ['/sites/wp'], siteMeta: { '/sites/wp': {} } });
const main = loadMain({
stubs: {
...silentLogging(),
...noSmtpServer(),
...settings.stubs,
'child_process': { spawn: cp.spawn },
'./kill-tree': { killChildTree }
}
Expand Down
66 changes: 66 additions & 0 deletions test/playground-plan.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'use strict';

// planPlaygroundLaunch is the pure seam that turns a serve strategy into the
// @wp-playground/cli mount/install options (#251). Booting WASM PHP is an
// integration concern; this pins the option shape both strategies produce so a
// regression in either is caught without a real WordPress.
//
// The expected shapes mirror @wp-playground/cli's own --auto-mount handling: a
// plugin is a `mount` under wp-content/plugins plus an `activatePlugin` step
// with the default (download-and-install) WordPress; a WordPress docroot is a
// `mount-before-install` at /wordpress with install-from-existing-files.

const test = require('node:test');
const assert = require('node:assert/strict');
const { planPlaygroundLaunch, PLUGINS_VFS_BASE } = require('../src/playground-plan.cjs');
const { getProjectType } = require('../src/project-type.cjs');

test('docroot strategy mounts the build dir as WordPress and skips the download', () => {
const plan = planPlaygroundLaunch({ strategy: 'docroot', docroot: '/sites/wp/build' });

assert.deepEqual(plan['mount-before-install'], [{ hostPath: '/sites/wp/build', vfsPath: '/wordpress' }]);
assert.deepEqual(plan.mount, []);
assert.deepEqual(plan['additional-blueprint-steps'], []);
// The mounted build/ already is WordPress; a fresh download would unpack a
// second one over the mount.
assert.equal(plan.wordpressInstallMode, 'install-from-existing-files-if-needed');
});

test('plugin-mount strategy mounts the checkout as an active plugin in a stock WordPress', () => {
const plan = planPlaygroundLaunch({ strategy: 'plugin-mount', pluginDir: '/sites/gb', pluginSlug: 'gutenberg' });

assert.deepEqual(plan.mount, [{ hostPath: '/sites/gb', vfsPath: '/wordpress/wp-content/plugins/gutenberg' }]);
assert.deepEqual(plan['mount-before-install'], []);
assert.deepEqual(plan['additional-blueprint-steps'], [
{ step: 'activatePlugin', pluginPath: '/wordpress/wp-content/plugins/gutenberg' }
]);
// No install-mode override: Playground downloads and installs a real
// WordPress for the plugin to run in.
assert.equal(plan.wordpressInstallMode, undefined);
});

test('the plugin is mounted under the plugins base path', () => {
const plan = planPlaygroundLaunch({ strategy: 'plugin-mount', pluginDir: '/x', pluginSlug: 'my-plugin' });
assert.equal(plan.mount[0].vfsPath, `${PLUGINS_VFS_BASE}/my-plugin`);
});

test('an unknown strategy is treated as docroot', () => {
// Defense in depth against a serve config that lost its strategy: fall back to
// the Core behaviour rather than throwing on a missing branch.
const plan = planPlaygroundLaunch({ docroot: '/x/build' });
assert.deepEqual(plan['mount-before-install'], [{ hostPath: '/x/build', vfsPath: '/wordpress' }]);
});

test('each strategy validates the path it needs', () => {
assert.throws(() => planPlaygroundLaunch({ strategy: 'plugin-mount' }), /pluginDir/);
assert.throws(() => planPlaygroundLaunch({ strategy: 'docroot' }), /docroot/);
assert.throws(() => planPlaygroundLaunch({}), /docroot/);
});

// The registry drives which strategy each project type gets — this ties the
// serve plan back to the project-type config the handler actually reads.
test('the project-type registry selects the strategy per target', () => {
assert.equal(getProjectType('core').serve.strategy, 'docroot');
assert.equal(getProjectType('gutenberg').serve.strategy, 'plugin-mount');
assert.equal(getProjectType('gutenberg').serve.pluginSlug, 'gutenberg');
});
Loading