diff --git a/src/main.js b/src/main.js index 312fc51..9b3c29d 100644 --- a/src/main.js +++ b/src/main.js @@ -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', diff --git a/src/playground-plan.cjs b/src/playground-plan.cjs new file mode 100644 index 0000000..42dceed --- /dev/null +++ b/src/playground-plan.cjs @@ -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/ 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 }; diff --git a/src/project-type.cjs b/src/project-type.cjs index 7d8f28c..031e7a1 100644 --- a/src/project-type.cjs +++ b/src/project-type.cjs @@ -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/. + serve: { strategy: 'plugin-mount', pluginSlug: 'gutenberg' }, // 'repo-relative' — Gutenberg PR diffs are already repo-relative // (packages/…); no src-layout rewrite. diff --git a/src/server-runner.js b/src/server-runner.js index 85d792d..b6c7e5e 100644 --- a/src/server-runner.js +++ b/src/server-runner.js @@ -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. @@ -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 @@ -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 } } }); diff --git a/test/ipc-wiring.test.cjs b/test/ipc-wiring.test.cjs index 2ec1cb4..851c50b 100644 --- a/test/ipc-wiring.test.cjs +++ b/test/ipc-wiring.test.cjs @@ -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 } } @@ -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) => { @@ -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 } } diff --git a/test/playground-plan.test.cjs b/test/playground-plan.test.cjs new file mode 100644 index 0000000..931583f --- /dev/null +++ b/test/playground-plan.test.cjs @@ -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'); +}); diff --git a/test/runner-wiring.test.cjs b/test/runner-wiring.test.cjs index 9785c16..75a476e 100644 --- a/test/runner-wiring.test.cjs +++ b/test/runner-wiring.test.cjs @@ -83,8 +83,9 @@ function loadRunner(runnerPath, extraArgv) { const originalLoad = Module._load; const originalArgv = process.argv; - // The runners read argv[2] (a build / mount directory) and exit(1) without - // one, which would end main() before it ever reaches the CLI require. + // The runners read argv[2] and exit(1) without one, which would end main() + // before it ever reaches the CLI require. For the web runner that argument is + // a directory; for server-runner it is a JSON serve config (#251). process.argv = [process.execPath, runnerPath, ...extraArgv]; // Matched by request string, not resolved path: `@wp-playground/cli` and @@ -134,7 +135,7 @@ function assertPatchesPrecedeCli(events, runner) { } test('server-runner patches loopback and hides child windows before loading the Playground CLI', () => { - const { events } = loadRunner(SERVER_RUNNER, ['/tmp/does-not-need-to-exist']); + const { events } = loadRunner(SERVER_RUNNER, [JSON.stringify({ strategy: 'docroot', docroot: '/tmp/does-not-need-to-exist' })]); assertPatchesPrecedeCli(events, 'server-runner'); assert.equal(realPackageLoaded(), false, 'server-runner loaded a real electron/Playground package instead of the stub'); }); @@ -154,7 +155,7 @@ test('playground-web-runner patches loopback and hides child windows before load // for a file WordPress was never told to write, and the module exporting the // right constants would not have caught that on its own. test('server-runner passes the WordPress debug constants to Playground', () => { - const { cliOptions } = loadRunner(SERVER_RUNNER, ['/tmp/does-not-need-to-exist']); + const { cliOptions } = loadRunner(SERVER_RUNNER, [JSON.stringify({ strategy: 'docroot', docroot: '/tmp/does-not-need-to-exist' })]); assert.ok(cliOptions, 'runCLI was never called'); const constants = cliOptions.blueprint && cliOptions.blueprint.constants; @@ -169,9 +170,74 @@ test('server-runner passes the WordPress debug constants to Playground', () => { // the mail ones out: this is how a site's outgoing mail reaches the app's SMTP // catcher, and losing it is silent — mail simply stops arriving. test('the SMTP constants survive alongside them', () => { - const { cliOptions } = loadRunner(SERVER_RUNNER, ['/tmp/does-not-need-to-exist']); + const { cliOptions } = loadRunner(SERVER_RUNNER, [JSON.stringify({ strategy: 'docroot', docroot: '/tmp/does-not-need-to-exist' })]); const constants = cliOptions.blueprint.constants; assert.strictEqual(constants.WP_MAIL_SMTP_HOST, '127.0.0.1'); assert.strictEqual(typeof constants.WP_MAIL_SMTP_PORT, 'number'); }); + +// The join between the plan and the CLI, which nothing pinned before: the two +// strategies were each tested in isolation (planPlaygroundLaunch in +// test/playground-plan.test.cjs, the handler's config in test/ipc-wiring.test.cjs) +// while the spread that carries one into the other was unasserted. Drop +// `...launch` from server-runner.js and every other test stays green, but Core +// boots with no mount and Playground's default install mode — i.e. it serves a +// freshly downloaded stock WordPress and the contributor's build/ is not in it. +test('a docroot serve reaches the CLI as a pre-install mount, not a plain one', () => { + const { cliOptions } = loadRunner(SERVER_RUNNER, [JSON.stringify({ strategy: 'docroot', docroot: '/tmp/site/build' })]); + + assert.deepStrictEqual( + cliOptions['mount-before-install'], + [{ hostPath: '/tmp/site/build', vfsPath: '/wordpress' }], + 'Core must mount build/ as /wordpress before install, or the download unpacks over it' + ); + assert.deepStrictEqual(cliOptions.mount, [], 'Core mounts nothing after install'); + assert.strictEqual( + cliOptions.wordpressInstallMode, + 'install-from-existing-files-if-needed', + 'without this Playground downloads a second WordPress over the build' + ); +}); + +test('a plugin-mount serve reaches the CLI as a post-install mount plus an activate step', () => { + const { cliOptions } = loadRunner(SERVER_RUNNER, [JSON.stringify({ strategy: 'plugin-mount', pluginDir: '/tmp/gutenberg', pluginSlug: 'gutenberg' })]); + + assert.deepStrictEqual( + cliOptions.mount, + [{ hostPath: '/tmp/gutenberg', vfsPath: '/wordpress/wp-content/plugins/gutenberg' }], + 'Gutenberg must be mounted under wp-content/plugins, after the stock WordPress install' + ); + assert.deepStrictEqual(cliOptions['mount-before-install'], [], 'mounting the plugin before install would put it at the docroot'); + assert.strictEqual( + cliOptions.wordpressInstallMode, + undefined, + 'plugin-mount relies on Playground’s default install mode to download a WordPress' + ); + assert.deepStrictEqual( + cliOptions['additional-blueprint-steps'], + [{ step: 'activatePlugin', pluginPath: '/wordpress/wp-content/plugins/gutenberg' }], + 'a mounted but inactive plugin serves a stock WordPress with nothing of the contributor’s in it' + ); +}); + +// The mount is read-write and points at the source checkout, not a regenerable +// build/. Without these, Plugins → Delete on the mounted plugin unlinks the +// contributor's working tree — uncommitted work and .git included. +test('a plugin-mount serve makes the mounted checkout read-only from inside WordPress', () => { + const { cliOptions } = loadRunner(SERVER_RUNNER, [JSON.stringify({ strategy: 'plugin-mount', pluginDir: '/tmp/gutenberg', pluginSlug: 'gutenberg' })]); + const constants = cliOptions.blueprint.constants; + + assert.strictEqual(constants.DISALLOW_FILE_MODS, true, 'WordPress could delete or overwrite the mounted checkout'); + assert.strictEqual(constants.DISALLOW_FILE_EDIT, true, 'the plugin file editor could edit the contributor’s real source files'); +}); + +// Core keeps WordPress's defaults: its mount is build/, which the app rebuilds, +// and a contributor testing a Core ticket may legitimately install a plugin. +test('a docroot serve does not inherit those guards', () => { + const { cliOptions } = loadRunner(SERVER_RUNNER, [JSON.stringify({ strategy: 'docroot', docroot: '/tmp/site/build' })]); + const constants = cliOptions.blueprint.constants; + + assert.strictEqual(constants.DISALLOW_FILE_MODS, undefined); + assert.strictEqual(constants.DISALLOW_FILE_EDIT, undefined); +});