Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions changes.d/init/smoke-test.md
Original file line number Diff line number Diff line change
@@ -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]]
1 change: 1 addition & 0 deletions packages/init/src/action/configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function createInitData(): InitCommandData {
initializer: {
federationFile: "federation.ts",
loggingFile: "logging.ts",
testFile: "scripts/smokeTest.ts",
instruction: message`done`,
tasks: {},
compilerOptions: {},
Expand Down
15 changes: 14 additions & 1 deletion packages/init/src/action/patch.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"\]/);
});
});
Comment on lines +95 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Expect tests for Deno and Node.js or Bun smoke-test generation.
rg -n -C 4 \
  'smokeTest|packageManager: "(deno|bun|npm|pnpm|yarn)"|test task|run.*dev' \
  packages/init/src --glob '*.test.ts'

Repository: fedify-dev/fedify

Length of output: 15893


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant patch.test.ts slices:"
sed -n '1,150p' packages/init/src/action/patch.test.ts

echo
echo "Tests mentioning smokeTest/runs/patchFiles/packageManager deno:"
rg -n -C 3 --glob '*.test.ts' \
  'smokeTest|patchFiles|packageManager:\s*"?deno"?"|packageManager:\s*"?bun"?"|packageManager:\s*"?npm"?"|run.*dev|test task|tasks:\s*' packages/init/src/action packages/init/src/action/patch.test.ts

Repository: fedify-dev/fedify

Length of output: 25321


Add smoke-test command coverage for Deno.

patchFiles has one smoke-test case that checks ["npm","run","dev"], but no case uses packageManager: "deno" or checks the Deno command. Add a Deno case, or ensure another patch test covers smokeTest.ts command substitution for Deno.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/init/src/action/patch.test.ts` around lines 95 - 105, Add coverage
for the Deno branch of patchFiles by creating init data with packageManager set
to "deno", reading the generated smokeTest.ts, and asserting it contains the
expected Deno command substitution. Keep the existing npm smoke-test coverage
unchanged.


function createInitData(
dir: string,
allowNonEmpty: boolean,
Expand All @@ -111,6 +123,7 @@ function createInitData(
initializer: {
federationFile: "src/federation.ts",
loggingFile: "src/logging.ts",
testFile: "scripts/smokeTest.ts",
instruction: message`done`,
tasks: {},
compilerOptions: {},
Expand Down
9 changes: 8 additions & 1 deletion packages/init/src/action/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, object>>();
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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)),
Expand Down
24 changes: 23 additions & 1 deletion packages/init/src/action/templates.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
*/
Comment on lines +60 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the JSDoc from loadTest.

loadTest is an internal helper in the initializer action layer. Keep the implementation comment-free unless it becomes part of the package entry-point API.

Based on learnings, internal helpers that are not re-exported from a package entry point should not have JSDoc.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/init/src/action/templates.ts` around lines 60 - 70, Remove the JSDoc
block immediately above the internal loadTest helper in templates.ts, leaving
the loadTest implementation unchanged.

Source: Learnings

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
Expand Down
141 changes: 141 additions & 0 deletions packages/init/src/templates/defaults/smokeTest.ts.tpl
Original file line number Diff line number Diff line change
@@ -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<void> {
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<typeof spawn>): Promise<number> {
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;
}
}
};
Comment on lines +69 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Buffer server output before matching the port.

data chunks do not preserve line boundaries. If a server writes http://localhost:5173 across two chunks, none of these patterns match and the smoke test fails after 15 seconds although the server is ready.

Proposed fix
+    let portOutput = "";
     const onData = (chunk: Buffer) => {
-      const text = chunk.toString("utf8");
+      portOutput += chunk.toString("utf8");
       for (const pattern of portPatterns) {
-        const match = text.match(pattern);
+        const match = portOutput.match(pattern);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
}
};
let portOutput = "";
const onData = (chunk: Buffer) => {
portOutput += chunk.toString("utf8");
for (const pattern of portPatterns) {
const match = portOutput.match(pattern);
if (match && match[1]) {
const port = Number.parseInt(match[1], 10);
clearTimeout(timeout);
resolve(port);
return;
}
}
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/init/src/templates/defaults/smokeTest.ts.tpl` around lines 69 - 80,
Update the onData handler to accumulate decoded server output across chunks
before applying portPatterns, so port values split between chunks are matched
correctly. Preserve the existing port parsing, timeout cleanup, resolve
behavior, and return once a match is found.


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<void> {
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<Actor> {
const object = await lookupObject(url, {
documentLoader: getDocumentLoader({ allowPrivateAddress: true }),
});
Comment on lines +115 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the local Fedify implementation for timeout or abort support.
rg -n -C 5 --glob '*.ts' \
  'lookupObject|function getDocumentLoader|const getDocumentLoader' .

Repository: fedify-dev/fedify

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== template excerpt =="
sed -n '80,140p' packages/init/src/templates/defaults/smokeTest.ts.tpl

echo
echo "== lookup API excerpt =="
sed -n '90,285p' packages/vocab/src/lookup.ts

echo
echo "== all lookupObject calls in smoke template =="
rg -n "lookupObject|signal|AbortController|setTimeout|checkActor|test\\(" packages/init/src/templates/defaults/smokeTest.ts.tpl

Repository: fedify-dev/fedify

Length of output: 8802


Apply the startup timeout to actor resolution.

waitForServer() uses AbortSignal.timeout(1000), but checkActor() calls lookupObject() without passing any signal. Actor retrieval can still hang after readiness succeeds; pass the same abortable request signal to lookupObject().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/init/src/templates/defaults/smokeTest.ts.tpl` around lines 115 -
118, Update checkActor to accept or obtain the startup abort signal used by
waitForServer, and pass that signal in the lookupObject options so actor
resolution is bounded by the same 1000ms timeout. Preserve the existing
documentLoader configuration.

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<typeof spawn>): 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();
2 changes: 2 additions & 0 deletions packages/init/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
12 changes: 11 additions & 1 deletion packages/init/src/webframeworks/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
},
};
11 changes: 10 additions & 1 deletion packages/init/src/webframeworks/bare-bones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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`),
},
Expand Down Expand Up @@ -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,
},
};
Loading