From f8635a91bbbfd3edb2625d4bca688ccc9aa41c63 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 13:37:07 -0700 Subject: [PATCH 1/4] test(docs): verify:docs runs the CLI in an isolated home Failing tests for a throwaway CLI home built from the real one: a copy of modules/, a link to the Lucee express runtime, no cache, and the CLI pointed at it through LUCLI_JAVA_ARGS (-Dlucli.home) and LUCLI_HOME. Refs #4422 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- .../verify-docs/test/isolated-home.test.mjs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs diff --git a/web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs b/web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs new file mode 100644 index 0000000000..291b1cf25e --- /dev/null +++ b/web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, lstatSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createIsolatedHome, isolatedEnv, sourceHome } from '../lib/isolated-home.mjs'; + +// verify:docs runs every {test:cli} block with a real `wheels` binary. Against the +// developer's own CLI home those blocks change it: `wheels packages registry refresh` +// deletes /cache/packages, and fixture apps register servers. The harness +// therefore runs the CLI in a throwaway home built from the real one. + +function fakeSource() { + const source = mkdtempSync(join(tmpdir(), 'verify-docs-source-')); + mkdirSync(join(source, 'modules', 'wheels'), { recursive: true }); + writeFileSync(join(source, 'modules', 'wheels', 'Module.cfc'), 'component {}'); + mkdirSync(join(source, 'express')); + mkdirSync(join(source, 'cache', 'packages'), { recursive: true }); + writeFileSync(join(source, 'cache', 'packages', 'manifest.json'), '{}'); + return source; +} + +test('the isolated home is a new directory with a copy of the modules', () => { + const source = fakeSource(); + const home = createIsolatedHome({ source }); + try { + assert.notEqual(home, source); + assert.equal(readFileSync(join(home, 'modules', 'wheels', 'Module.cfc'), 'utf8'), 'component {}'); + // A block that changes modules changes the copy, not the real ones. + writeFileSync(join(home, 'modules', 'wheels', 'Module.cfc'), 'changed'); + assert.equal(readFileSync(join(source, 'modules', 'wheels', 'Module.cfc'), 'utf8'), 'component {}'); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(source, { recursive: true, force: true }); + } +}); + +test('the Lucee express runtime is linked, and the real cache is left out', () => { + const source = fakeSource(); + const home = createIsolatedHome({ source }); + try { + assert.ok(lstatSync(join(home, 'express')).isSymbolicLink()); + assert.equal(existsSync(join(home, 'cache')), false); + rmSync(home, { recursive: true, force: true }); + assert.ok(existsSync(join(source, 'cache', 'packages', 'manifest.json'))); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(source, { recursive: true, force: true }); + } +}); + +test('the CLI is pointed at the isolated home through LUCLI_JAVA_ARGS and LUCLI_HOME', () => { + // The Homebrew launcher overwrites LUCLI_HOME, so the JVM property carries the home. + const env = isolatedEnv('/tmp/iso-home', { LUCLI_JAVA_ARGS: '-Xmx1g', PATH: '/bin' }); + assert.equal(env.LUCLI_HOME, '/tmp/iso-home'); + assert.equal(env.LUCLI_JAVA_ARGS, '-Xmx1g -Dlucli.home=/tmp/iso-home'); + assert.equal(env.PATH, '/bin'); + assert.equal(isolatedEnv('/tmp/iso-home', {}).LUCLI_JAVA_ARGS, '-Dlucli.home=/tmp/iso-home'); +}); + +test('the real home defaults to LUCLI_HOME, then ~/.wheels', () => { + assert.equal(sourceHome({ LUCLI_HOME: '/x/home' }), '/x/home'); + assert.match(sourceHome({}), /[\\/]\.wheels$/); +}); From 1f97b5cafe1d0cb490e24a19ee77ad588104bc31 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 13:46:42 -0700 Subject: [PATCH 2/4] fix(docs): run verify:docs and its harness tests in a throwaway CLI home Every {test:cli} block ran the installed wheels CLI in the developer's own home, so `wheels packages registry refresh` deleted ~/.wheels/cache/packages and fixture apps went through the same server registry. verify:docs and test:docs-harness now build a temporary home: modules/ copied (so CI's checkout overlay is used), express/ linked, nothing else. The CLI is pointed at it with LUCLI_JAVA_ARGS=-Dlucli.home (the Homebrew launcher resets LUCLI_HOME) and LUCLI_HOME, and it is removed on exit. One serial `wheels system paths` sets up the fresh home before blocks run in parallel, which otherwise race on it. The run prints the home it used. Closes #4422 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- web/sites/guides/package.json | 2 +- .../scripts/verify-docs/lib/isolated-home.mjs | 64 +++++++++++++++++++ .../verify-docs/test/isolated-home-setup.mjs | 6 ++ .../verify-docs/test/isolated-home.test.mjs | 23 ++++++- .../scripts/verify-docs/verify-docs.mjs | 15 ++++- 5 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 web/sites/guides/scripts/verify-docs/lib/isolated-home.mjs create mode 100644 web/sites/guides/scripts/verify-docs/test/isolated-home-setup.mjs diff --git a/web/sites/guides/package.json b/web/sites/guides/package.json index 4b246a3a0d..490b28c0ed 100644 --- a/web/sites/guides/package.json +++ b/web/sites/guides/package.json @@ -11,7 +11,7 @@ "check": "astro check", "test": "vitest run --passWithNoTests", "verify:docs": "node scripts/verify-docs/verify-docs.mjs", - "test:docs-harness": "node --test --test-concurrency=1 scripts/verify-docs/test/*.test.mjs" + "test:docs-harness": "node --import ./scripts/verify-docs/test/isolated-home-setup.mjs --test --test-concurrency=1 scripts/verify-docs/test/*.test.mjs" }, "dependencies": { "astro": "^6.3.1", diff --git a/web/sites/guides/scripts/verify-docs/lib/isolated-home.mjs b/web/sites/guides/scripts/verify-docs/lib/isolated-home.mjs new file mode 100644 index 0000000000..1f0f268f2b --- /dev/null +++ b/web/sites/guides/scripts/verify-docs/lib/isolated-home.mjs @@ -0,0 +1,64 @@ +import { cpSync, existsSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** + * verify:docs runs every {test:cli} block with a real `wheels` binary. In the + * developer's own CLI home those blocks change it: `wheels packages registry + * refresh` deletes /cache/packages, and fixture apps register servers + * (#4422). So the harness runs the CLI in a throwaway home built from the real + * one, and removes it when the run ends. + */ + +/** The CLI home the installed binary uses: LUCLI_HOME, else ~/.wheels. */ +export function sourceHome(env = process.env) { + return env.LUCLI_HOME || join(homedir(), '.wheels'); +} + +/** + * A new home with a copy of the source's modules/ (the wheels module, which CI + * overlays from the checkout, and BaseModule.cfc) and a link to its express/ + * Lucee runtime, which is large and only read. Everything else (cache, servers, + * settings) starts empty. + */ +export function createIsolatedHome({ source = sourceHome(), parent = tmpdir() } = {}) { + const home = mkdtempSync(join(parent, 'wheels-verify-docs-home-')); + if (existsSync(join(source, 'modules'))) { + cpSync(join(source, 'modules'), join(home, 'modules'), { recursive: true, verbatimSymlinks: true }); + } + if (existsSync(join(source, 'express'))) { + symlinkSync(join(source, 'express'), join(home, 'express')); + } + return home; +} + +/** + * The environment that points the CLI at `home`. The Homebrew launcher + * overwrites LUCLI_HOME, so the home also goes in as the lucli.home JVM + * property, which LuCLI reads first. + */ +export function isolatedEnv(home, env = process.env) { + const javaArgs = [env.LUCLI_JAVA_ARGS, `-Dlucli.home=${home}`].filter(Boolean).join(' '); + return { ...env, LUCLI_HOME: home, LUCLI_JAVA_ARGS: javaArgs }; +} + +/** + * Builds the isolated home and points this process's environment at it, so + * every `wheels` the harness spawns (which inherit the environment) uses it. + * Returns { home, source }; the home is removed when the process exits. A + * process whose parent already did this (the harness unit tests' files, run + * by `node --test`) keeps the parent's home. + */ +export function enterIsolatedHome() { + if (process.env.WHEELS_VERIFY_DOCS_HOME) { + return { home: process.env.WHEELS_VERIFY_DOCS_HOME, source: process.env.WHEELS_VERIFY_DOCS_SOURCE_HOME }; + } + const source = sourceHome(); + const home = createIsolatedHome({ source }); + Object.assign(process.env, isolatedEnv(home), { + WHEELS_VERIFY_DOCS_HOME: home, + WHEELS_VERIFY_DOCS_SOURCE_HOME: source, + }); + process.on('exit', () => rmSync(home, { recursive: true, force: true })); + return { home, source }; +} diff --git a/web/sites/guides/scripts/verify-docs/test/isolated-home-setup.mjs b/web/sites/guides/scripts/verify-docs/test/isolated-home-setup.mjs new file mode 100644 index 0000000000..4e5ebaad2d --- /dev/null +++ b/web/sites/guides/scripts/verify-docs/test/isolated-home-setup.mjs @@ -0,0 +1,6 @@ +// Preloaded by `pnpm test:docs-harness` (node --import): the harness tests spawn +// `wheels` and create fixture apps too, so they run in the same kind of throwaway +// CLI home as verify:docs (#4422). Not a test file: the *.test.mjs glob skips it. +import { enterIsolatedHome } from '../lib/isolated-home.mjs'; + +enterIsolatedHome(); diff --git a/web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs b/web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs index 291b1cf25e..a880c6d29c 100644 --- a/web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs +++ b/web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs @@ -3,7 +3,7 @@ import { strict as assert } from 'node:assert'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, lstatSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createIsolatedHome, isolatedEnv, sourceHome } from '../lib/isolated-home.mjs'; +import { createIsolatedHome, enterIsolatedHome, isolatedEnv, sourceHome } from '../lib/isolated-home.mjs'; // verify:docs runs every {test:cli} block with a real `wheels` binary. Against the // developer's own CLI home those blocks change it: `wheels packages registry refresh` @@ -62,3 +62,24 @@ test('the real home defaults to LUCLI_HOME, then ~/.wheels', () => { assert.equal(sourceHome({ LUCLI_HOME: '/x/home' }), '/x/home'); assert.match(sourceHome({}), /[\\/]\.wheels$/); }); + +test('a process that inherits an isolated home keeps it', () => { + const prev = { ...process.env }; + process.env.WHEELS_VERIFY_DOCS_HOME = '/tmp/parent-home'; + process.env.WHEELS_VERIFY_DOCS_SOURCE_HOME = '/x/source'; + try { + assert.deepEqual(enterIsolatedHome(), { home: '/tmp/parent-home', source: '/x/source' }); + } finally { + for (const k of ['WHEELS_VERIFY_DOCS_HOME', 'WHEELS_VERIFY_DOCS_SOURCE_HOME']) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + } +}); + +test('harness test processes run in the isolated home', () => { + // test:docs-harness preloads test/isolated-home-setup.mjs. + assert.ok(process.env.WHEELS_VERIFY_DOCS_HOME, 'WHEELS_VERIFY_DOCS_HOME is not set'); + assert.equal(process.env.LUCLI_HOME, process.env.WHEELS_VERIFY_DOCS_HOME); + assert.match(process.env.LUCLI_JAVA_ARGS, /-Dlucli\.home=/); +}); diff --git a/web/sites/guides/scripts/verify-docs/verify-docs.mjs b/web/sites/guides/scripts/verify-docs/verify-docs.mjs index ff20d8daec..6f1f12b1b7 100644 --- a/web/sites/guides/scripts/verify-docs/verify-docs.mjs +++ b/web/sites/guides/scripts/verify-docs/verify-docs.mjs @@ -2,7 +2,8 @@ import { readdir, stat } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { wheelsBinaryAttestation } from './lib/exec.mjs'; +import { runExec, wheelsBinaryAttestation } from './lib/exec.mjs'; +import { enterIsolatedHome } from './lib/isolated-home.mjs'; import { extractExamples } from './lib/extract.mjs'; import { printReport } from './lib/report.mjs'; import { loadAllowlist, applyAllowlist, bodyHash } from './lib/allowlist.mjs'; @@ -37,6 +38,18 @@ async function collectMdx(target) { } async function main() { + // Run the CLI in a throwaway home so the blocks can't change the developer's + // own (#4422). Before the first spawn: every `wheels` inherits this env. + const { home, source } = enterIsolatedHome(); + console.log(`verify-docs: CLI home: ${home} (modules copied from ${source})`); + // The first JVM run in a fresh home sets up LuCLI's Lucee context there; let one + // command do that before blocks run in parallel, which otherwise race on it. + const warm = await runExec('wheels', ['system', 'paths']); + if (warm.code !== 0) { + console.error(`verify-docs: could not start the CLI in ${home} (exit ${warm.code}):\n${warm.stderr || warm.stdout}`); + process.exit(2); + } + // State up front WHICH wheels binary this run attests to (#3042). console.log(`verify-docs: ${await wheelsBinaryAttestation()}`); From 39e09615b8724c21a9bed74ba4663454977cb104 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 13:56:24 -0700 Subject: [PATCH 3/4] fix(docs): remove the verify:docs CLI home on Ctrl-C, keep downloads out of the real home - SIGINT / SIGTERM now exit normally, so the exit handler removes the throwaway home instead of leaving it in the temp directory. - express/ is a copy-on-write copy of the real runtimes, not a link, so a Lucee download or a JDBC driver staged into lib/ext stays in the throwaway home. Closes #4427. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- .../scripts/verify-docs/lib/isolated-home.mjs | 24 +++++++-- .../verify-docs/test/isolated-home.test.mjs | 53 +++++++++++++++++-- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/web/sites/guides/scripts/verify-docs/lib/isolated-home.mjs b/web/sites/guides/scripts/verify-docs/lib/isolated-home.mjs index 1f0f268f2b..b4d76e403e 100644 --- a/web/sites/guides/scripts/verify-docs/lib/isolated-home.mjs +++ b/web/sites/guides/scripts/verify-docs/lib/isolated-home.mjs @@ -1,4 +1,4 @@ -import { cpSync, existsSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'; +import { constants, cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -17,9 +17,13 @@ export function sourceHome(env = process.env) { /** * A new home with a copy of the source's modules/ (the wheels module, which CI - * overlays from the checkout, and BaseModule.cfc) and a link to its express/ - * Lucee runtime, which is large and only read. Everything else (cache, servers, - * settings) starts empty. + * overlays from the checkout, and BaseModule.cfc) and of its express/ Lucee + * runtimes. Everything else (cache, servers, settings) starts empty. + * + * express/ is copied, not linked: the CLI downloads a missing Lucee version into + * it and stages JDBC drivers into a runtime's lib/ext, and through a link both + * would land in the real home (#4427). The copy asks for a copy-on-write clone, + * which is near-instant on APFS and btrfs; elsewhere it is an ordinary copy. */ export function createIsolatedHome({ source = sourceHome(), parent = tmpdir() } = {}) { const home = mkdtempSync(join(parent, 'wheels-verify-docs-home-')); @@ -27,7 +31,11 @@ export function createIsolatedHome({ source = sourceHome(), parent = tmpdir() } cpSync(join(source, 'modules'), join(home, 'modules'), { recursive: true, verbatimSymlinks: true }); } if (existsSync(join(source, 'express'))) { - symlinkSync(join(source, 'express'), join(home, 'express')); + cpSync(join(source, 'express'), join(home, 'express'), { + recursive: true, + verbatimSymlinks: true, + mode: constants.COPYFILE_FICLONE, + }); } return home; } @@ -60,5 +68,11 @@ export function enterIsolatedHome() { WHEELS_VERIFY_DOCS_SOURCE_HOME: source, }); process.on('exit', () => rmSync(home, { recursive: true, force: true })); + // An 'exit' handler doesn't run when the process is killed by a signal, so a + // Ctrl-C (or a CI cancel) left the home behind (#4427). Exit normally on those + // signals instead, with the conventional 128 + signal number status. + for (const [signal, status] of [['SIGINT', 130], ['SIGTERM', 143]]) { + process.once(signal, () => process.exit(status)); + } return { home, source }; } diff --git a/web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs b/web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs index a880c6d29c..3ec4bc1ec2 100644 --- a/web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs +++ b/web/sites/guides/scripts/verify-docs/test/isolated-home.test.mjs @@ -1,6 +1,8 @@ import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, lstatSync, rmSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createIsolatedHome, enterIsolatedHome, isolatedEnv, sourceHome } from '../lib/isolated-home.mjs'; @@ -14,7 +16,8 @@ function fakeSource() { const source = mkdtempSync(join(tmpdir(), 'verify-docs-source-')); mkdirSync(join(source, 'modules', 'wheels'), { recursive: true }); writeFileSync(join(source, 'modules', 'wheels', 'Module.cfc'), 'component {}'); - mkdirSync(join(source, 'express')); + mkdirSync(join(source, 'express', '7.0.0.1', 'lib', 'ext'), { recursive: true }); + writeFileSync(join(source, 'express', '7.0.0.1', 'lib', 'ext', 'lucee.jar'), 'jar'); mkdirSync(join(source, 'cache', 'packages'), { recursive: true }); writeFileSync(join(source, 'cache', 'packages', 'manifest.json'), '{}'); return source; @@ -35,11 +38,27 @@ test('the isolated home is a new directory with a copy of the modules', () => { } }); -test('the Lucee express runtime is linked, and the real cache is left out', () => { +test('the Lucee express runtime is a copy, so writes and downloads stay in the isolated home', () => { + const source = fakeSource(); + const home = createIsolatedHome({ source }); + try { + assert.equal(lstatSync(join(home, 'express')).isSymbolicLink(), false); + assert.equal(readFileSync(join(home, 'express', '7.0.0.1', 'lib', 'ext', 'lucee.jar'), 'utf8'), 'jar'); + // A new runtime download, and a driver staged into an existing runtime, land in the copy only. + mkdirSync(join(home, 'express', '7.9.9.9')); + writeFileSync(join(home, 'express', '7.0.0.1', 'lib', 'ext', 'sqlite.jar'), 'driver'); + assert.equal(existsSync(join(source, 'express', '7.9.9.9')), false); + assert.equal(existsSync(join(source, 'express', '7.0.0.1', 'lib', 'ext', 'sqlite.jar')), false); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(source, { recursive: true, force: true }); + } +}); + +test('the real cache is left out', () => { const source = fakeSource(); const home = createIsolatedHome({ source }); try { - assert.ok(lstatSync(join(home, 'express')).isSymbolicLink()); assert.equal(existsSync(join(home, 'cache')), false); rmSync(home, { recursive: true, force: true }); assert.ok(existsSync(join(source, 'cache', 'packages', 'manifest.json'))); @@ -83,3 +102,31 @@ test('harness test processes run in the isolated home', () => { assert.equal(process.env.LUCLI_HOME, process.env.WHEELS_VERIFY_DOCS_HOME); assert.match(process.env.LUCLI_JAVA_ARGS, /-Dlucli\.home=/); }); + +for (const signal of ['SIGINT', 'SIGTERM']) { + test(`the isolated home is removed when the run is stopped with ${signal}`, { timeout: 30_000 }, async () => { + const source = fakeSource(); + const lib = fileURLToPath(new URL('../lib/isolated-home.mjs', import.meta.url)); + const env = { ...process.env, LUCLI_HOME: source }; + delete env.WHEELS_VERIFY_DOCS_HOME; + delete env.WHEELS_VERIFY_DOCS_SOURCE_HOME; + const child = spawn(process.execPath, [ + '--input-type=module', '-e', + `const { enterIsolatedHome } = await import(${JSON.stringify(lib)}); console.log(enterIsolatedHome().home); setInterval(() => {}, 1000);`, + ], { env, stdio: ['ignore', 'pipe', 'inherit'] }); + try { + const home = await new Promise((resolve, reject) => { + child.stdout.once('data', (d) => resolve(String(d).trim())); + child.once('exit', () => reject(new Error('child exited before reporting its home'))); + }); + assert.ok(existsSync(home), 'the child created its home'); + const exited = new Promise((resolve) => child.once('exit', resolve)); + child.kill(signal); + await exited; + assert.equal(existsSync(home), false, `${signal} left ${home} behind`); + } finally { + try { child.kill('SIGKILL'); } catch {} + rmSync(source, { recursive: true, force: true }); + } + }); +} From 8f53233da28025aff1de249a1164858449dcd23d Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 13:58:58 -0700 Subject: [PATCH 4/4] fix(docs): start the fixture server before tutorial CLI steps that need it The mini-tutorial's step 1 runs wheels reload, but the tutorial session only started the fixture's server for HTTP asserts, so the reload refused and the harness test failed on every run. CLI steps whose command talks to the dev server (reload, routes, test, migrate, seed, console) now start it first. The harness workflow step is blocking again. Closes #4426. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- .github/workflows/docs-verify.yml | 6 ++---- .../scripts/verify-docs/drivers/tutorial.mjs | 14 ++++++++++++++ .../scripts/verify-docs/test/tutorial.test.mjs | 10 ++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs-verify.yml b/.github/workflows/docs-verify.yml index d7ca2f2755..06eb592cf2 100644 --- a/.github/workflows/docs-verify.yml +++ b/.github/workflows/docs-verify.yml @@ -140,10 +140,8 @@ jobs: # lets `wheels new` succeed, which makes fixture cwds real. See # #2178 for the full root-cause write-up. # - # Still soft-fail: surfaces the remaining tutorial-driver fixture - # issues (e.g., blog-tutorial's lucee.json emission). Drop - # continue-on-error once those are fixed. - continue-on-error: true + # Blocking: the last known failure, the mini-tutorial reloading before + # any server was started, is fixed (#4426). env: WHEELS_FRAMEWORK_PATH: ${{ github.workspace }}/vendor/wheels run: | diff --git a/web/sites/guides/scripts/verify-docs/drivers/tutorial.mjs b/web/sites/guides/scripts/verify-docs/drivers/tutorial.mjs index 3e6b789228..d6795f134a 100644 --- a/web/sites/guides/scripts/verify-docs/drivers/tutorial.mjs +++ b/web/sites/guides/scripts/verify-docs/drivers/tutorial.mjs @@ -160,10 +160,24 @@ export class TutorialSession { async applyCliExample(ex) { await this.ensureInitialised(); + // A reader following the tutorial has the dev server running by the time a step reloads, + // lists live routes, migrates or runs tests, and those commands refuse without this + // project's own server (#4426). Start the fixture's server first, as for HTTP asserts. + if (needsServer(ex.attrs.cmd)) { + await this.ensureServer(); + } return await runCliInFixture(ex); } } +// `wheels` commands that talk to the project's running dev server. +const SERVER_COMMANDS = new Set(['reload', 'routes', 'test', 'migrate', 'seed', 'console']); + +export function needsServer(cmd) { + const [program, verb] = String(cmd ?? '').trim().split(/\s+/); + return program === 'wheels' && SERVER_COMMANDS.has(verb); +} + async function runCliInFixture(ex) { const cmd = ex.attrs.cmd; if (!cmd) return { ok: false, message: 'missing required attr: cmd' }; diff --git a/web/sites/guides/scripts/verify-docs/test/tutorial.test.mjs b/web/sites/guides/scripts/verify-docs/test/tutorial.test.mjs index ddb5dadee0..6985d95e76 100644 --- a/web/sites/guides/scripts/verify-docs/test/tutorial.test.mjs +++ b/web/sites/guides/scripts/verify-docs/test/tutorial.test.mjs @@ -91,3 +91,13 @@ test('tutorial driver walks mini-tutorial end to end', { timeout: 300_000 }, asy await session.stopServer(); } }); + +test('cli steps that need the dev server start it first', async () => { + const { needsServer } = await import('../drivers/tutorial.mjs'); + for (const cmd of ['wheels reload', 'wheels routes', 'wheels test', 'wheels migrate latest', 'wheels seed', 'wheels console']) { + assert.equal(needsServer(cmd), true, cmd); + } + for (const cmd of ['wheels generate model Post', 'wheels start', 'wheels stop', 'wheels new app', 'npm test', '']) { + assert.equal(needsServer(cmd), false, cmd); + } +});