From f9e6667350dbe9c54235c93a9ef23f1a56f1a65b Mon Sep 17 00:00:00 2001 From: Palcimer Date: Sat, 8 Aug 2026 21:47:04 +0900 Subject: [PATCH 1/4] Generate a smoke-test script in `fedify init` projects Scaffolded projects had no quick way to confirm that their federation setup actually serves an actor. Verifying it meant starting the dev server by hand and looking an actor up separately. Added a smoke-test script that starts the dev server, reads the port, waits for the server to answer, and looks an actor up with `lookupObject()`. https://github.com/fedify-dev/fedify/issues/898 Assisted-by: Claude Code:claude-sonnet-5 --- packages/init/src/action/patch.ts | 9 +- packages/init/src/action/templates.ts | 24 ++- .../src/templates/defaults/smokeTest.ts.tpl | 141 ++++++++++++++++++ packages/init/src/types.ts | 2 + 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 packages/init/src/templates/defaults/smokeTest.ts.tpl diff --git a/packages/init/src/action/patch.ts b/packages/init/src/action/patch.ts index f32b1031d..88d51a80d 100644 --- a/packages/init/src/action/patch.ts +++ b/packages/init/src/action/patch.ts @@ -25,7 +25,12 @@ import { noticeFilesToCreate, noticeFilesToInsert, } from "./notice.ts"; -import { getImports, loadFederation, loadLogging } from "./templates.ts"; +import { + getImports, + loadFederation, + loadLogging, + loadTest, +} from "./templates.ts"; import { joinDir, stringifyEnvs } from "./utils.ts"; const jsonsCache = new Map>(); @@ -133,6 +138,7 @@ const getFiles = async < ...data, }), [data.initializer.loggingFile]: await loadLogging(data), + [data.initializer.testFile]: await loadTest(data), ".env": stringifyEnvs(data.env), ...data.initializer.files, }); @@ -183,6 +189,7 @@ const getJsons = < const getGeneratedFilePaths = (data: InitCommandData): string[] => [ data.initializer.federationFile, data.initializer.loggingFile, + data.initializer.testFile, ".env", ...Object.keys(data.initializer.files ?? {}), ...Object.keys(getJsons(data)), diff --git a/packages/init/src/action/templates.ts b/packages/init/src/action/templates.ts index f8a0ed773..612522dff 100644 --- a/packages/init/src/action/templates.ts +++ b/packages/init/src/action/templates.ts @@ -1,6 +1,6 @@ import { concat, entries, join, map, pipe, when } from "@fxts/core"; import { toMerged } from "es-toolkit"; -import { readTemplate } from "../lib.ts"; +import { getDevCommand, readTemplate } from "../lib.ts"; import type { InitCommandData, PackageManager } from "../types.ts"; import { replace } from "../utils.ts"; import { needsDenoDotenv } from "./utils.ts"; @@ -57,6 +57,28 @@ export const loadLogging = async ( replace(/\/\* project name \*\//, JSON.stringify(projectName)), ); +/** + * Loads the smoke-test script content for the initializer. + * + * Every framework shares the same *defaults/smokeTest.ts* template, so unlike + * {@link loadLogging} there is no per-framework template override. The + * template spawns the project's own dev server, so it needs the dev command + * for the chosen package manager baked in at generation time. + * + * @param param0 - {@link InitCommandData} containing `packageManager` + * @returns The complete smoke-test script content as a string + */ +export const loadTest = async ( + { packageManager }: InitCommandData, +) => + pipe( + await readTemplate("defaults/smokeTest.ts"), + replace( + /\/\* dev command \*\//, + JSON.stringify(getDevCommand(packageManager).split(" ")), + ), + ); + /** * Generates import statements for KV store and message queue dependencies. * Merges imports from both KV and MQ configurations and creates proper diff --git a/packages/init/src/templates/defaults/smokeTest.ts.tpl b/packages/init/src/templates/defaults/smokeTest.ts.tpl new file mode 100644 index 000000000..f744bb0bf --- /dev/null +++ b/packages/init/src/templates/defaults/smokeTest.ts.tpl @@ -0,0 +1,141 @@ +import { getDocumentLoader } from "@fedify/fedify"; +import { type Actor, isActor, lookupObject } from "@fedify/vocab"; +import { spawn } from "node:child_process"; + +const DEV_COMMAND: string[] = /* dev command */; +const HANDLE = "john"; +const STARTUP_TIMEOUT = 15_000; + +async function main(): Promise { + const [command, ...args] = DEV_COMMAND; + const server = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + server.on("error", () => {}); + + const exitOnSignal = () => { + stopServer(server); + process.exit(1); + }; + process.once("SIGINT", exitOnSignal); + process.once("SIGTERM", exitOnSignal); + + let output = ""; + const collectOutput = (chunk: Buffer) => { + output += chunk.toString("utf8"); + }; + server.stdout?.on("data", collectOutput); + server.stderr?.on("data", collectOutput); + + try { + const port = await determinePort(server); + const target = `http://localhost:${port}/users/${HANDLE}`; + await waitForServer(target); + console.log(`Server is up at http://localhost:${port}.`); + const actor = await checkActor(target); + console.log(actor); + console.log(`Smoke test passed: ${target} resolved to an actor.`); + } catch (error) { + console.error("Smoke test failed:", error instanceof Error ? error.message : error); + if (output.trim() !== "") { + console.error(`\nDev server output:\n${output}`); + } + process.exitCode = 1; + } finally { + stopServer(server); + } +} + +function determinePort(server: ReturnType): Promise { + const portPatterns = [ + /listening on.*:(\d+)/i, + /server.*:(\d+)/i, + /port\s*:?\s*(\d+)/i, + /https?:\/\/localhost:(\d+)/i, + /https?:\/\/0\.0\.0\.0:(\d+)/i, + /https?:\/\/127\.0\.0\.1:(\d+)/i, + /https?:\/\/[^:]+:(\d+)/i, + ]; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject( + new Error( + `Timeout: Could not determine port from server output within ${STARTUP_TIMEOUT}ms.`, + ), + ); + }, STARTUP_TIMEOUT); + + const onData = (chunk: Buffer) => { + const text = chunk.toString("utf8"); + for (const pattern of portPatterns) { + const match = text.match(pattern); + if (match && match[1]) { + const port = Number.parseInt(match[1], 10); + clearTimeout(timeout); + resolve(port); + return; + } + } + }; + + server.stdout?.on("data", onData); + server.stderr?.on("data", onData); + server.once("exit", (code) => { + clearTimeout(timeout); + reject(new Error(`The dev server exited early with code ${String(code)}.`)); + }); + }); +} + +async function waitForServer(url: string): Promise { + const startTime = Date.now(); + let lastStatus: number | undefined; + + while (Date.now() - startTime < STARTUP_TIMEOUT) { + try { + const response = await fetch(url, { + headers: { Accept: "application/activity+json" }, + signal: AbortSignal.timeout(1000), + }); + await response.body?.cancel(); + if (response.ok) return; + lastStatus = response.status; + } catch { + // Server not ready yet, continue waiting + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `The server did not become ready within ${STARTUP_TIMEOUT}ms.` + + (lastStatus == null ? "" : ` Last response status: ${lastStatus}.`), + ); +} + +async function checkActor(url: string): Promise { + const object = await lookupObject(url, { + documentLoader: getDocumentLoader({ allowPrivateAddress: true }), + }); + if (object == null) { + throw new Error(`Could not resolve an actor at ${url}.`); + } + if (!isActor(object)) { + throw new Error(`Expected an actor at ${url}, but got a non-actor object.`); + } + return object; +} + +function stopServer(server: ReturnType): void { + try { + if (server.pid != null) process.kill(-server.pid, "SIGKILL"); + } catch { + // Process group already exited. + } + try { + server.kill("SIGKILL"); + } catch { + // Process already exited. + } +} + +await main(); diff --git a/packages/init/src/types.ts b/packages/init/src/types.ts index b750bd76a..aa77cb40b 100644 --- a/packages/init/src/types.ts +++ b/packages/init/src/types.ts @@ -86,6 +86,8 @@ export interface WebFrameworkInitializer { federationFile: string; /** Relative path where the logging configuration file will be created. */ loggingFile: string; + /** Relative path where the smoke-test script file will be created. */ + testFile: string; /** Optional template path for the logging configuration file. */ loggingTemplate?: string; /** From e1338916c8d4b5c1b636beb657566faa6923ecb9 Mon Sep 17 00:00:00 2001 From: Palcimer Date: Sat, 8 Aug 2026 21:48:47 +0900 Subject: [PATCH 2/4] Add a `test` task to every `fedify init` framework Every framework now writes the smoke-test script and exposes it as a `test` task, so a scaffolded project can be verified with one command. The task runs the script with the runtime matching the package manager, and Node.js projects gain `tsx` as a dev dependency to execute it. https://github.com/fedify-dev/fedify/issues/898 Assisted-by: Claude Code:claude-sonnet-5 --- packages/init/src/webframeworks/astro.ts | 12 ++++++++- packages/init/src/webframeworks/bare-bones.ts | 11 +++++++- packages/init/src/webframeworks/elysia.ts | 11 +++++++- packages/init/src/webframeworks/express.ts | 11 +++++++- packages/init/src/webframeworks/hono.ts | 11 +++++++- packages/init/src/webframeworks/next.ts | 11 ++++++-- packages/init/src/webframeworks/nitro.ts | 15 ++++++++--- packages/init/src/webframeworks/nuxt.ts | 11 ++++++-- packages/init/src/webframeworks/solidstart.ts | 13 +++++++++- packages/init/src/webframeworks/sveltekit.ts | 14 +++++++++-- packages/init/src/webframeworks/utils.ts | 25 +++++++++++++++++++ 11 files changed, 130 insertions(+), 15 deletions(-) diff --git a/packages/init/src/webframeworks/astro.ts b/packages/init/src/webframeworks/astro.ts index 1d6933a98..af32ef3d4 100644 --- a/packages/init/src/webframeworks/astro.ts +++ b/packages/init/src/webframeworks/astro.ts @@ -3,7 +3,12 @@ import deps from "../json/deps.json" with { type: "json" }; import { PACKAGE_VERSION, readTemplate } from "../lib.ts"; import type { PackageManager, WebFrameworkDescription } from "../types.ts"; import { defaultDenoDependencies } from "./const.ts"; -import { getInstruction, pmToRt } from "./utils.ts"; +import { + getInstruction, + getTestDependencies, + getTestTask, + pmToRt, +} from "./utils.ts"; const astroNodeBunDevDependencies = { "@fedify/lint": PACKAGE_VERSION, @@ -73,9 +78,11 @@ const astroDescription: WebFrameworkDescription = { "@types/node": deps["npm:@types/node@22"], } : {}), + ...getTestDependencies(pm), }, federationFile: "src/federation.ts", loggingFile: "src/logging.ts", + testFile: "scripts/smokeTest.ts", format: pm === "deno" ? undefined : { tool: "prettier" }, files: { "astro.config.ts": await readTemplate( @@ -130,17 +137,20 @@ const TASKS = { dev: `${astroDenoCommand} dev`, build: `${astroDenoCommand} build`, preview: `${astroDenoCommand} preview`, + test: getTestTask("deno"), }, "bun": { dev: "bunx --bun astro dev", build: "bunx --bun astro build", preview: "bun ./dist/server/entry.mjs", + test: getTestTask("bun"), ...astroNodeBunDevToolTasks, }, "node": { dev: "dotenvx run -- astro dev", build: "dotenvx run -- astro build", preview: "dotenvx run -- astro preview", + test: getTestTask("npm"), ...astroNodeBunDevToolTasks, }, }; diff --git a/packages/init/src/webframeworks/bare-bones.ts b/packages/init/src/webframeworks/bare-bones.ts index 894440ffb..a1fa7be2c 100644 --- a/packages/init/src/webframeworks/bare-bones.ts +++ b/packages/init/src/webframeworks/bare-bones.ts @@ -3,7 +3,12 @@ import deps from "../json/deps.json" with { type: "json" }; import { readTemplate } from "../lib.ts"; import type { WebFrameworkDescription } from "../types.ts"; import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts"; -import { getInstruction, nodeBunDevToolTasks, pmToRt } from "./utils.ts"; +import { + getInstruction, + getTestTask, + nodeBunDevToolTasks, + pmToRt, +} from "./utils.ts"; const bareBonesDescription: WebFrameworkDescription = { label: "Bare-bones", @@ -19,6 +24,7 @@ const bareBonesDescription: WebFrameworkDescription = { }, federationFile: "src/federation.ts", loggingFile: "src/logging.ts", + testFile: "scripts/smokeTest.ts", files: { "src/main.ts": await readTemplate(`bare-bones/main/${pmToRt(pm)}.ts`), }, @@ -63,15 +69,18 @@ const TASKS = { deno: { dev: "deno run -A --watch ./src/main.ts", prod: "deno run -A ./src/main.ts", + test: getTestTask("deno"), }, bun: { dev: "bun run --hot ./src/main.ts", prod: "bun run ./src/main.ts", + test: getTestTask("bun"), ...nodeBunDevToolTasks, }, node: { dev: "dotenvx run -- tsx watch ./src/main.ts", prod: "dotenvx run -- node --import tsx ./src/main.ts", + test: getTestTask("npm"), ...nodeBunDevToolTasks, }, }; diff --git a/packages/init/src/webframeworks/elysia.ts b/packages/init/src/webframeworks/elysia.ts index 4334995a8..8a41db5f9 100644 --- a/packages/init/src/webframeworks/elysia.ts +++ b/packages/init/src/webframeworks/elysia.ts @@ -3,7 +3,12 @@ import deps from "../json/deps.json" with { type: "json" }; import { PACKAGE_VERSION, readTemplate } from "../lib.ts"; import type { WebFrameworkDescription } from "../types.ts"; import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts"; -import { getInstruction, nodeBunDevToolTasks, pmToRt } from "./utils.ts"; +import { + getInstruction, + getTestTask, + nodeBunDevToolTasks, + pmToRt, +} from "./utils.ts"; const elysiaDescription: WebFrameworkDescription = { label: "ElysiaJS", @@ -41,6 +46,7 @@ const elysiaDescription: WebFrameworkDescription = { }, federationFile: "src/federation.ts", loggingFile: "src/logging.ts", + testFile: "scripts/smokeTest.ts", files: { "src/index.ts": (await readTemplate( `elysia/index/${pmToRt(pm)}.ts`, @@ -68,16 +74,19 @@ const TASKS = { dev: "deno serve --allow-read --allow-env --allow-net --watch ./src/index.ts", prod: "deno serve --allow-read --allow-env --allow-net ./src/index.ts", + test: getTestTask("deno"), }, bun: { dev: "bun run --hot ./src/index.ts", prod: "bun run ./src/index.ts", + test: getTestTask("bun"), ...nodeBunDevToolTasks, }, node: { dev: "dotenvx run -- tsx watch src/index.ts", build: "tsc src/index.ts --outDir dist", start: "NODE_ENV=production dotenvx run -- node dist/index.js", + test: getTestTask("npm"), ...nodeBunDevToolTasks, }, }; diff --git a/packages/init/src/webframeworks/express.ts b/packages/init/src/webframeworks/express.ts index 9a09c1a57..d7d9a3543 100644 --- a/packages/init/src/webframeworks/express.ts +++ b/packages/init/src/webframeworks/express.ts @@ -3,7 +3,12 @@ import deps from "../json/deps.json" with { type: "json" }; import { PACKAGE_VERSION, readTemplate } from "../lib.ts"; import type { WebFrameworkDescription } from "../types.ts"; import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts"; -import { getInstruction, nodeBunDevToolTasks, pmToRt } from "./utils.ts"; +import { + getInstruction, + getTestTask, + nodeBunDevToolTasks, + pmToRt, +} from "./utils.ts"; const expressDescription: WebFrameworkDescription = { label: "Express", @@ -26,6 +31,7 @@ const expressDescription: WebFrameworkDescription = { }, federationFile: "src/federation.ts", loggingFile: "src/logging.ts", + testFile: "scripts/smokeTest.ts", files: { "src/app.ts": (await readTemplate("express/app.ts")) .replace(/\/\* logger \*\//, projectName), @@ -54,15 +60,18 @@ const TASKS = { "deno run --allow-read --allow-net --allow-env --allow-sys --watch ./src/index.ts", prod: "deno run --allow-read --allow-net --allow-env --allow-sys ./src/index.ts", + test: getTestTask("deno"), }, bun: { dev: "bun run --hot ./src/index.ts", prod: "bun run ./src/index.ts", + test: getTestTask("bun"), ...nodeBunDevToolTasks, }, node: { dev: "dotenvx run -- tsx watch ./src/index.ts", prod: "dotenvx run -- node --import tsx ./src/index.ts", + test: getTestTask("npm"), ...nodeBunDevToolTasks, }, }; diff --git a/packages/init/src/webframeworks/hono.ts b/packages/init/src/webframeworks/hono.ts index f10ffff2c..b600e2e06 100644 --- a/packages/init/src/webframeworks/hono.ts +++ b/packages/init/src/webframeworks/hono.ts @@ -5,7 +5,12 @@ import { PACKAGE_VERSION, readTemplate } from "../lib.ts"; import type { WebFrameworkDescription } from "../types.ts"; import { replace } from "../utils.ts"; import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts"; -import { getInstruction, nodeBunDevToolTasks, pmToRt } from "./utils.ts"; +import { + getInstruction, + getTestTask, + nodeBunDevToolTasks, + pmToRt, +} from "./utils.ts"; const honoDescription: WebFrameworkDescription = { label: "Hono", @@ -19,6 +24,7 @@ const honoDescription: WebFrameworkDescription = { }, federationFile: "src/federation.ts", loggingFile: "src/logging.ts", + testFile: "scripts/smokeTest.ts", files: { "src/app.tsx": pipe( await readTemplate("hono/app.tsx"), @@ -75,15 +81,18 @@ const TASKS = { deno: { dev: "deno run -A --watch ./src/index.ts", prod: "deno run -A ./src/index.ts", + test: getTestTask("deno"), }, bun: { dev: "bun run --hot ./src/index.ts", prod: "bun run ./src/index.ts", + test: getTestTask("bun"), ...nodeBunDevToolTasks, }, node: { dev: "dotenvx run -- tsx watch ./src/index.ts", prod: "dotenvx run -- node --import tsx ./src/index.ts", + test: getTestTask("npm"), ...nodeBunDevToolTasks, }, }; diff --git a/packages/init/src/webframeworks/next.ts b/packages/init/src/webframeworks/next.ts index 5c8e95bcc..6aa49e177 100644 --- a/packages/init/src/webframeworks/next.ts +++ b/packages/init/src/webframeworks/next.ts @@ -3,7 +3,12 @@ import deps from "../json/deps.json" with { type: "json" }; import { PACKAGE_VERSION, readTemplate } from "../lib.ts"; import type { PackageManager, WebFrameworkDescription } from "../types.ts"; import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts"; -import { getInstruction, getNodeBunDevToolTasks } from "./utils.ts"; +import { + getInstruction, + getNodeBunDevToolTasks, + getTestDependencies, + getTestTask, +} from "./utils.ts"; const nextDescription: WebFrameworkDescription = { label: "Next.js", @@ -23,9 +28,11 @@ const nextDescription: WebFrameworkDescription = { devDependencies: { "@types/node": deps["npm:@types/node@20"], ...defaultDevDependencies, + ...getTestDependencies(pm), }, federationFile: "federation/index.ts", loggingFile: "logging.ts", + testFile: "scripts/smokeTest.ts", format: { ignorePatterns: [".next/**"], }, @@ -33,7 +40,7 @@ const nextDescription: WebFrameworkDescription = { "instrumentation.ts": await readTemplate("next/instrumentation.ts"), "middleware.ts": await readTemplate("next/middleware.ts"), }, - tasks: getNodeBunDevToolTasks(pm), + tasks: { ...getNodeBunDevToolTasks(pm), test: getTestTask(pm) }, instruction: getInstruction(pm, 3000), }), }; diff --git a/packages/init/src/webframeworks/nitro.ts b/packages/init/src/webframeworks/nitro.ts index a7f3682da..3d6864d92 100644 --- a/packages/init/src/webframeworks/nitro.ts +++ b/packages/init/src/webframeworks/nitro.ts @@ -2,7 +2,12 @@ import { PACKAGE_MANAGER } from "../const.ts"; import { PACKAGE_VERSION, readTemplate } from "../lib.ts"; import type { PackageManager, WebFrameworkDescription } from "../types.ts"; import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts"; -import { getInstruction, getNodeBunDevToolTasks } from "./utils.ts"; +import { + getInstruction, + getNodeBunDevToolTasks, + getTestDependencies, + getTestTask, +} from "./utils.ts"; const nitroDescription: WebFrameworkDescription = { label: "Nitro", @@ -18,9 +23,13 @@ const nitroDescription: WebFrameworkDescription = { "@fedify/h3": PACKAGE_VERSION, ...(pm === "deno" && defaultDenoDependencies), }, - devDependencies: defaultDevDependencies, + devDependencies: { + ...defaultDevDependencies, + ...getTestDependencies(pm), + }, federationFile: "server/federation.ts", loggingFile: "server/logging.ts", + testFile: "scripts/smokeTest.ts", format: { ignorePatterns: [".output/**"], }, @@ -60,7 +69,7 @@ const nitroDescription: WebFrameworkDescription = { lib: ["ESNext", "DOM"], baseUrl: ".", }, - tasks: getNodeBunDevToolTasks(pm), + tasks: { ...getNodeBunDevToolTasks(pm), test: getTestTask(pm) }, instruction: getInstruction(pm, 3000), }), }; diff --git a/packages/init/src/webframeworks/nuxt.ts b/packages/init/src/webframeworks/nuxt.ts index ed66c3568..a7658586e 100644 --- a/packages/init/src/webframeworks/nuxt.ts +++ b/packages/init/src/webframeworks/nuxt.ts @@ -3,7 +3,12 @@ import deps from "../json/deps.json" with { type: "json" }; import { PACKAGE_VERSION, readTemplate } from "../lib.ts"; import type { PackageManager, WebFrameworkDescription } from "../types.ts"; import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts"; -import { getInstruction, getNodeBunDevToolTasks } from "./utils.ts"; +import { + getInstruction, + getNodeBunDevToolTasks, + getTestDependencies, + getTestTask, +} from "./utils.ts"; const nuxtDescription: WebFrameworkDescription = { label: "Nuxt", @@ -16,10 +21,12 @@ const nuxtDescription: WebFrameworkDescription = { ...defaultDevDependencies, "typescript": deps["npm:typescript"], "@types/node": deps["npm:@types/node@25"], + ...getTestDependencies(pm), }, federationFile: "server/federation.ts", loggingFile: "server/logging.ts", loggingTemplate: "nuxt/server/logging.ts", + testFile: "scripts/smokeTest.ts", format: { ignorePatterns: [".output/**"], }, @@ -30,7 +37,7 @@ const nuxtDescription: WebFrameworkDescription = { "nuxt/server/plugins/logging.ts", ), }, - tasks: getNodeBunDevToolTasks(pm), + tasks: { ...getNodeBunDevToolTasks(pm), test: getTestTask(pm) }, instruction: getInstruction(pm, 3000), }), }; diff --git a/packages/init/src/webframeworks/solidstart.ts b/packages/init/src/webframeworks/solidstart.ts index eae2a8ed9..36527a6f1 100644 --- a/packages/init/src/webframeworks/solidstart.ts +++ b/packages/init/src/webframeworks/solidstart.ts @@ -3,7 +3,13 @@ import deps from "../json/deps.json" with { type: "json" }; import { PACKAGE_VERSION, readTemplate } from "../lib.ts"; import type { WebFrameworkDescription } from "../types.ts"; import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts"; -import { getInstruction, nodeBunDevToolTasks, pmToRt } from "./utils.ts"; +import { + getInstruction, + getTestDependencies, + getTestTask, + nodeBunDevToolTasks, + pmToRt, +} from "./utils.ts"; const NPM_SOLIDSTART = `npm:@solidjs/start@${deps["npm:@solidjs/start"]}`; const solidstartDescription: WebFrameworkDescription = { @@ -16,9 +22,11 @@ const solidstartDescription: WebFrameworkDescription = { ...defaultDevDependencies, typescript: deps["npm:typescript"], "@types/node": deps["npm:@types/node@22"], + ...getTestDependencies(pm), }, federationFile: "src/federation.ts", loggingFile: "src/logging.ts", + testFile: "scripts/smokeTest.ts", format: { ignorePatterns: [".solid/**", ".vinxi/**"], }, @@ -94,17 +102,20 @@ const TASKS = { dev: "deno run -A npm:vinxi dev", build: "deno run -A npm:vinxi build", start: "deno run -A npm:vinxi start", + test: getTestTask("deno"), }, bun: { dev: "bunx vinxi dev", build: "bunx vinxi build", start: "bunx vinxi start", + test: getTestTask("bun"), ...nodeBunDevToolTasks, }, node: { dev: "vinxi dev", build: "vinxi build", start: "dotenvx run -- vinxi start", + test: getTestTask("npm"), ...nodeBunDevToolTasks, }, }; diff --git a/packages/init/src/webframeworks/sveltekit.ts b/packages/init/src/webframeworks/sveltekit.ts index 5ea808cf2..d38fc89b6 100644 --- a/packages/init/src/webframeworks/sveltekit.ts +++ b/packages/init/src/webframeworks/sveltekit.ts @@ -3,7 +3,13 @@ import deps from "../json/deps.json" with { type: "json" }; import { PACKAGE_VERSION, readTemplate } from "../lib.ts"; import type { PackageManager, WebFrameworkDescription } from "../types.ts"; import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts"; -import { getInstruction, nodeBunDevToolTasks, pmToRt } from "./utils.ts"; +import { + getInstruction, + getTestDependencies, + getTestTask, + nodeBunDevToolTasks, + pmToRt, +} from "./utils.ts"; const sveltekitDescription: WebFrameworkDescription = { label: "SvelteKit", @@ -22,14 +28,18 @@ const sveltekitDescription: WebFrameworkDescription = { ...(pmToRt(pm) === "deno" ? {} : { "@dotenvx/dotenvx": deps["npm:@dotenvx/dotenvx"] }), + ...getTestDependencies(pm), }, federationFile: "src/lib/federation.ts", loggingFile: "src/lib/logging.ts", + testFile: "scripts/smokeTest.ts", env: testMode ? { HOST: "127.0.0.1" } : {} as Record, files: { "src/hooks.server.ts": await readTemplate("sveltekit/hooks.server.ts"), }, - tasks: pmToRt(pm) === "deno" ? {} : { ...TASKS }, + tasks: pmToRt(pm) === "deno" + ? { test: getTestTask("deno") } + : { ...TASKS, test: getTestTask(pm) }, instruction: getInstruction(pm, 5173), }), }; diff --git a/packages/init/src/webframeworks/utils.ts b/packages/init/src/webframeworks/utils.ts index 1448a81e0..cee82f585 100644 --- a/packages/init/src/webframeworks/utils.ts +++ b/packages/init/src/webframeworks/utils.ts @@ -1,6 +1,7 @@ import type { Message } from "@optique/core"; import { commandLine, message } from "@optique/core/message"; import { getDevCommand } from "../lib.ts"; +import deps from "../json/deps.json" with { type: "json" }; import type { PackageManager } from "../types.ts"; export const nodeBunDevToolTasks = { @@ -13,6 +14,30 @@ export const getNodeBunDevToolTasks = ( pm: PackageManager, ): Record => pm === "deno" ? {} : nodeBunDevToolTasks; +const SMOKE_TEST_FILE = "scripts/smokeTest.ts"; + +/** + * Returns the `test` task command that runs the generated smoke-test + * script (`WebFrameworkInitializer.testFile`) with the runtime matching the + * given package manager. + */ +export const getTestTask = (pm: PackageManager): string => + pmToRt(pm) === "deno" + ? `deno run -A ${SMOKE_TEST_FILE}` + : pmToRt(pm) === "bun" + ? `bun run ${SMOKE_TEST_FILE}` + : `tsx ${SMOKE_TEST_FILE}`; + +/** + * Returns the dev dependencies the `test` task needs beyond what the + * framework already declares. Node.js runs the smoke-test script through + * `tsx`; Deno and Bun execute TypeScript natively. + */ +export const getTestDependencies = ( + pm: PackageManager, +): Record => + pmToRt(pm) === "node" ? { tsx: deps["npm:tsx"] } : {}; + /** * Generates the post-initialization instruction message that shows * the user how to start the dev server and look up an actor. From bc3bdf36be2518d4de84379de96cb2004b14fec2 Mon Sep 17 00:00:00 2001 From: Palcimer Date: Sat, 8 Aug 2026 21:56:01 +0900 Subject: [PATCH 3/4] Test that `fedify init` writes the smoke-test script Added a test covering that `patchFiles()` writes the script to the initializer's `testFile` path with the dev command baked in, and filled in `testFile` in the existing fixtures now that it is required. https://github.com/fedify-dev/fedify/issues/898 Assisted-by: Claude Code:claude-sonnet-5 --- packages/init/src/action/configs.test.ts | 1 + packages/init/src/action/patch.test.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/init/src/action/configs.test.ts b/packages/init/src/action/configs.test.ts index 04a77603e..c888ea843 100644 --- a/packages/init/src/action/configs.test.ts +++ b/packages/init/src/action/configs.test.ts @@ -35,6 +35,7 @@ function createInitData(): InitCommandData { initializer: { federationFile: "federation.ts", loggingFile: "logging.ts", + testFile: "scripts/smokeTest.ts", instruction: message`done`, tasks: {}, compilerOptions: {}, diff --git a/packages/init/src/action/patch.test.ts b/packages/init/src/action/patch.test.ts index c97a7644d..9241166f0 100644 --- a/packages/init/src/action/patch.test.ts +++ b/packages/init/src/action/patch.test.ts @@ -1,9 +1,9 @@ +import { message } from "@optique/core"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { message } from "@optique/core"; import type { InitCommandData } from "../types.ts"; import { assertNoGeneratedFileConflicts, @@ -92,6 +92,18 @@ test("patchFiles merges JSONC files containing only comments", async () => { }); }); +test("patchFiles writes the smoke-test script", async () => { + await withTempDir(async (dir) => { + await patchFiles(createInitData(dir, false)); + + const testScript = await readFile( + join(dir, "scripts", "smokeTest.ts"), + "utf8", + ); + assert.match(testScript, /\["npm","run","dev"\]/); + }); +}); + function createInitData( dir: string, allowNonEmpty: boolean, @@ -111,6 +123,7 @@ function createInitData( initializer: { federationFile: "src/federation.ts", loggingFile: "src/logging.ts", + testFile: "scripts/smokeTest.ts", instruction: message`done`, tasks: {}, compilerOptions: {}, From 27a7063e5f367b9efaae1f7679fb81e8701077c5 Mon Sep 17 00:00:00 2001 From: Palcimer Date: Sat, 8 Aug 2026 22:00:57 +0900 Subject: [PATCH 4/4] Add @fedify/init changes (smoke-test task) in CHANGES.md https://github.com/fedify-dev/fedify/issues/898 Assisted-by: Claude Code:claude-sonnet-5 --- CHANGES.md | 5 +++++ changes.d/init/smoke-test.md | 4 ++++ 2 files changed, 9 insertions(+) create mode 100644 changes.d/init/smoke-test.md diff --git a/CHANGES.md b/CHANGES.md index 73b65d407..77bae5b4d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -175,6 +175,10 @@ To be released. ### @fedify/init + - Added a `test` task to projects scaffolded by `fedify init`. It starts + the app, waits for it to become ready, and checks that it resolves a local + actor, giving projects a standard smoke test to run right after scaffolding + and whenever the app changes afterwards. [[#898]] - Fixed `fedify init`'s hydration test validation to run `format` before `format:check`, which previously caused the entire test suite to fail when the package manager is `npm` or `pnpm`: @@ -183,6 +187,7 @@ To be released. - Supported \[SvelteKit\] as a web framework option in `fedify init`. [[#892], [#971] by Jang Hanarae\] +[#898]: https://github.com/fedify-dev/fedify/issues/898 [#950]: https://github.com/fedify-dev/fedify/issues/950 [#952]: https://github.com/fedify-dev/fedify/pull/952 diff --git a/changes.d/init/smoke-test.md b/changes.d/init/smoke-test.md new file mode 100644 index 000000000..372185a6e --- /dev/null +++ b/changes.d/init/smoke-test.md @@ -0,0 +1,4 @@ + - Added a `test` task to projects scaffolded by `fedify init`. It starts + the app, waits for it to become ready, and checks that it resolves a local + actor, giving projects a standard smoke test to run right after scaffolding + and whenever the app changes afterwards. [[#898]]