Skip to content

Commit b66cedd

Browse files
authored
Merge pull request #990 from Palcimer/issue-898-add-a-smoke-test-to-fedify-init
Add a smoke test to generated `fedify init` apps
2 parents b4b6b20 + 1dbfccd commit b66cedd

19 files changed

Lines changed: 367 additions & 20 deletions

File tree

‎CHANGES.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ To be released.
175175

176176
### @fedify/init
177177

178+
- Added a `test` task to projects scaffolded by `fedify init`. It starts
179+
the app, waits for it to become ready, and checks that it resolves a local
180+
actor, giving projects a standard smoke test to run right after scaffolding
181+
and whenever the app changes afterwards. [[#898], [#990] by Jang Hanarae\]
178182
- Added runtime version verification to `fedify init`. It checks that the
179183
selected Deno, Bun, or Node.js meets Fedify's minimum version, or a higher
180184
version required by a framework (such as Astro's Node.js 22.12), before
@@ -189,10 +193,12 @@ To be released.
189193
- Supported \[SvelteKit\] as a web framework option in
190194
`fedify init`. [[#892], [#971] by Jang Hanarae\]
191195

196+
[#898]: https://github.com/fedify-dev/fedify/issues/898
192197
[#950]: https://github.com/fedify-dev/fedify/issues/950
193198
[#952]: https://github.com/fedify-dev/fedify/pull/952
194199
[#964]: https://github.com/fedify-dev/fedify/issues/964
195200
[#981]: https://github.com/fedify-dev/fedify/pull/981
201+
[#990]: https://github.com/fedify-dev/fedify/pull/990
196202

197203
### @fedify/interaction-controls
198204

‎changes.d/init/smoke-test.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
links:
3+
'#990': https://github.com/fedify-dev/fedify/pull/990
4+
---
5+
- Added a `test` task to projects scaffolded by `fedify init`. It starts
6+
the app, waits for it to become ready, and checks that it resolves a local
7+
actor, giving projects a standard smoke test to run right after scaffolding
8+
and whenever the app changes afterwards. [[#898], [#990] by Jang Hanarae]

‎packages/init/src/action/configs.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1+
import { message } from "@optique/core";
12
import assert from "node:assert/strict";
23
import { execFile } from "node:child_process";
34
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
45
import { tmpdir } from "node:os";
56
import { join } from "node:path";
67
import test from "node:test";
78
import { promisify } from "node:util";
8-
import { message } from "@optique/core";
99
import { kvStores, messageQueues, PACKAGE_VERSION } from "../lib.ts";
1010
import type { InitCommandData } from "../types.ts";
11-
import bareBonesDescription from "../webframeworks/bare-bones.ts";
1211
import astroDescription from "../webframeworks/astro.ts";
12+
import bareBonesDescription from "../webframeworks/bare-bones.ts";
1313
import nextDescription from "../webframeworks/next.ts";
1414
import nitroDescription from "../webframeworks/nitro.ts";
1515
import nuxtDescription from "../webframeworks/nuxt.ts";
@@ -35,6 +35,7 @@ function createInitData(): InitCommandData {
3535
initializer: {
3636
federationFile: "federation.ts",
3737
loggingFile: "logging.ts",
38+
testFile: "scripts/smoke.test.ts",
3839
instruction: message`done`,
3940
tasks: {},
4041
compilerOptions: {},

‎packages/init/src/action/patch.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
import { message } from "@optique/core";
12
import assert from "node:assert/strict";
23
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
34
import { tmpdir } from "node:os";
45
import { join } from "node:path";
56
import test from "node:test";
6-
import { message } from "@optique/core";
77
import type { InitCommandData } from "../types.ts";
88
import {
99
assertNoGeneratedFileConflicts,
@@ -92,6 +92,18 @@ test("patchFiles merges JSONC files containing only comments", async () => {
9292
});
9393
});
9494

95+
test("patchFiles writes the smoke-test script", async () => {
96+
await withTempDir(async (dir) => {
97+
await patchFiles(createInitData(dir, false));
98+
99+
const testScript = await readFile(
100+
join(dir, "scripts", "smoke.test.ts"),
101+
"utf8",
102+
);
103+
assert.match(testScript, /\["npm", "run", "dev"\]/);
104+
});
105+
});
106+
95107
function createInitData(
96108
dir: string,
97109
allowNonEmpty: boolean,
@@ -111,6 +123,7 @@ function createInitData(
111123
initializer: {
112124
federationFile: "src/federation.ts",
113125
loggingFile: "src/logging.ts",
126+
testFile: "scripts/smoke.test.ts",
114127
instruction: message`done`,
115128
tasks: {},
116129
compilerOptions: {},

‎packages/init/src/action/patch.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ import {
2525
noticeFilesToCreate,
2626
noticeFilesToInsert,
2727
} from "./notice.ts";
28-
import { getImports, loadFederation, loadLogging } from "./templates.ts";
28+
import {
29+
getImports,
30+
loadFederation,
31+
loadLogging,
32+
loadTest,
33+
} from "./templates.ts";
2934
import { joinDir, stringifyEnvs } from "./utils.ts";
3035

3136
const jsonsCache = new Map<string, Record<string, object>>();
@@ -133,6 +138,7 @@ const getFiles = async <
133138
...data,
134139
}),
135140
[data.initializer.loggingFile]: await loadLogging(data),
141+
[data.initializer.testFile]: await loadTest(data),
136142
".env": stringifyEnvs(data.env),
137143
...data.initializer.files,
138144
});
@@ -183,6 +189,7 @@ const getJsons = <
183189
const getGeneratedFilePaths = (data: InitCommandData): string[] => [
184190
data.initializer.federationFile,
185191
data.initializer.loggingFile,
192+
data.initializer.testFile,
186193
".env",
187194
...Object.keys(data.initializer.files ?? {}),
188195
...Object.keys(getJsons(data)),

‎packages/init/src/action/templates.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { concat, entries, join, map, pipe, when } from "@fxts/core";
22
import { toMerged } from "es-toolkit";
3-
import { readTemplate } from "../lib.ts";
3+
import { getDevCommand, readTemplate } from "../lib.ts";
44
import type { InitCommandData, PackageManager } from "../types.ts";
55
import { replace } from "../utils.ts";
66
import { needsDenoDotenv } from "./utils.ts";
@@ -57,6 +57,31 @@ export const loadLogging = async (
5757
replace(/\/\* project name \*\//, JSON.stringify(projectName)),
5858
);
5959

60+
/**
61+
* Loads the smoke-test script content for the initializer.
62+
*
63+
* Every framework shares the same *defaults/smoke.test.ts* template, so unlike
64+
* {@link loadLogging} there is no per-framework template override. The
65+
* template spawns the project's own dev server, so it needs the dev command
66+
* for the chosen package manager baked in at generation time.
67+
*
68+
* @param param0 - {@link InitCommandData} containing `packageManager`
69+
* @returns The complete smoke-test script content as a string
70+
*/
71+
export const loadTest = async (
72+
{ packageManager }: InitCommandData,
73+
) =>
74+
pipe(
75+
await readTemplate("defaults/smoke.test.ts"),
76+
replace(
77+
/\/\* dev command \*\//,
78+
JSON.stringify(getDevCommand(packageManager).split(" ")).replaceAll(
79+
",",
80+
", ",
81+
),
82+
),
83+
);
84+
6085
/**
6186
* Generates import statements for KV store and message queue dependencies.
6287
* Merges imports from both KV and MQ configurations and creates proper
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { getDocumentLoader } from "@fedify/fedify";
2+
import { type Actor, isActor, lookupObject } from "@fedify/vocab";
3+
import { spawn, spawnSync } from "node:child_process";
4+
import type { Readable } from "node:stream";
5+
6+
const DEV_COMMAND: string[] = /* dev command */;
7+
const HANDLE = "john";
8+
const STARTUP_TIMEOUT = 15_000;
9+
const IS_WINDOWS = process.platform === "win32";
10+
11+
async function main(): Promise<void> {
12+
const [command, ...args] = DEV_COMMAND;
13+
const server = spawn(command, args, {
14+
stdio: ["ignore", "pipe", "pipe"],
15+
shell: IS_WINDOWS,
16+
windowsHide: true,
17+
detached: !IS_WINDOWS,
18+
});
19+
server.on("error", () => {});
20+
21+
const exitOnSignal = () => {
22+
stopServer(server);
23+
process.exit(1);
24+
};
25+
process.once("SIGINT", exitOnSignal);
26+
process.once("SIGTERM", exitOnSignal);
27+
28+
let output = "";
29+
const collectOutput = (stream: Readable | null) => {
30+
const decoder = new TextDecoder();
31+
stream?.on("data", (chunk: Buffer) => {
32+
output += decoder.decode(chunk, { stream: true });
33+
});
34+
};
35+
collectOutput(server.stdout);
36+
collectOutput(server.stderr);
37+
38+
try {
39+
const port = await determinePort(server);
40+
const target = `http://localhost:${port}/users/${HANDLE}`;
41+
await waitForServer(target);
42+
console.log(`Server is up at http://localhost:${port}.`);
43+
const actor = await checkActor(target);
44+
console.log(actor);
45+
console.log(`Smoke test passed: ${target} resolved to an actor.`);
46+
} catch (error) {
47+
console.error("Smoke test failed:", error instanceof Error ? error.message : error);
48+
if (output.trim() !== "") {
49+
console.error(`\nDev server output:\n${output}`);
50+
}
51+
process.exitCode = 1;
52+
} finally {
53+
stopServer(server);
54+
}
55+
}
56+
57+
function stripEscape(text: string): string {
58+
return text.replace(new RegExp("\\u001B\\[[0-9;]*[A-Za-z]", "g"), "");
59+
}
60+
61+
function determinePort(server: ReturnType<typeof spawn>): Promise<number> {
62+
const portPatterns = [
63+
/listening on.*:(\d+)/i,
64+
/server.*:(\d+)/i,
65+
/https?:\/\/localhost:(\d+)/i,
66+
/https?:\/\/0\.0\.0\.0:(\d+)/i,
67+
/https?:\/\/127\.0\.0\.1:(\d+)/i,
68+
/https?:\/\/[^:]+:(\d+)/i,
69+
];
70+
return new Promise((resolve, reject) => {
71+
const timeout = setTimeout(() => {
72+
reject(
73+
new Error(
74+
`Timeout: Could not determine port from server output within ${STARTUP_TIMEOUT}ms.`,
75+
),
76+
);
77+
}, STARTUP_TIMEOUT);
78+
79+
const findPort = (text: string) => {
80+
for (const pattern of portPatterns) {
81+
const match = text.match(pattern);
82+
if (match && match[1]) {
83+
const port = Number.parseInt(match[1], 10);
84+
if (port > 0 && port < 65536) return port;
85+
}
86+
}
87+
return null;
88+
};
89+
90+
const scan = (stream: Readable | null) => {
91+
const decoder = new TextDecoder();
92+
let text = "";
93+
stream?.on("data", (chunk: Buffer) => {
94+
text += decoder.decode(chunk, { stream: true });
95+
const port = findPort(stripEscape(text));
96+
if (port != null) {
97+
clearTimeout(timeout);
98+
resolve(port);
99+
}
100+
});
101+
};
102+
103+
scan(server.stdout);
104+
scan(server.stderr);
105+
server.once("exit", (code) => {
106+
clearTimeout(timeout);
107+
reject(new Error(`The dev server exited early with code ${String(code)}.`));
108+
});
109+
});
110+
}
111+
112+
async function waitForServer(url: string): Promise<void> {
113+
const startTime = Date.now();
114+
let lastStatus: number | undefined;
115+
116+
while (Date.now() - startTime < STARTUP_TIMEOUT) {
117+
try {
118+
const response = await fetch(url, {
119+
headers: { Accept: "application/activity+json" },
120+
signal: AbortSignal.timeout(1000),
121+
});
122+
await response.body?.cancel();
123+
if (response.ok) return;
124+
lastStatus = response.status;
125+
} catch {
126+
// Server not ready yet, continue waiting
127+
}
128+
await new Promise((resolve) => setTimeout(resolve, 500));
129+
}
130+
throw new Error(
131+
`The server did not become ready within ${STARTUP_TIMEOUT}ms.` +
132+
(lastStatus == null ? "" : ` Last response status: ${lastStatus}.`),
133+
);
134+
}
135+
136+
async function checkActor(url: string): Promise<Actor> {
137+
const object = await lookupObject(url, {
138+
documentLoader: getDocumentLoader({ allowPrivateAddress: true }),
139+
});
140+
if (object == null) {
141+
throw new Error(`Could not resolve an actor at ${url}.`);
142+
}
143+
if (!isActor(object)) {
144+
throw new Error(`Expected an actor at ${url}, but got a non-actor object.`);
145+
}
146+
return object;
147+
}
148+
149+
function stopServer(server: ReturnType<typeof spawn>): void {
150+
if (server.pid == null) return;
151+
if (IS_WINDOWS) {
152+
spawnSync("taskkill", ["/pid", String(server.pid), "/T", "/F"], {
153+
stdio: "ignore",
154+
windowsHide: true,
155+
});
156+
return;
157+
}
158+
try {
159+
process.kill(-server.pid, "SIGKILL");
160+
} catch {
161+
// Process group already exited.
162+
}
163+
try {
164+
server.kill("SIGKILL");
165+
} catch {
166+
// Process already exited.
167+
}
168+
}
169+
170+
await main();

‎packages/init/src/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ export interface WebFrameworkInitializer {
9696
federationFile: string;
9797
/** Relative path where the logging configuration file will be created. */
9898
loggingFile: string;
99+
/** Relative path where the smoke-test script file will be created. */
100+
testFile: string;
99101
/** Optional template path for the logging configuration file. */
100102
loggingTemplate?: string;
101103
/**

‎packages/init/src/webframeworks/astro.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import deps from "../json/deps.json" with { type: "json" };
33
import { PACKAGE_VERSION, readTemplate } from "../lib.ts";
44
import type { PackageManager, WebFrameworkDescription } from "../types.ts";
55
import { defaultDenoDependencies } from "./const.ts";
6-
import { getInstruction, pmToRt } from "./utils.ts";
6+
import {
7+
getInstruction,
8+
getTestDependencies,
9+
getTestTask,
10+
pmToRt,
11+
} from "./utils.ts";
712

813
const astroNodeBunDevDependencies = {
914
"@fedify/lint": PACKAGE_VERSION,
@@ -74,9 +79,11 @@ const astroDescription: WebFrameworkDescription = {
7479
"@types/node": deps["npm:@types/node@22"],
7580
}
7681
: {}),
82+
...getTestDependencies(pm),
7783
},
7884
federationFile: "src/federation.ts",
7985
loggingFile: "src/logging.ts",
86+
testFile: "scripts/smoke.test.ts",
8087
format: pm === "deno" ? undefined : { tool: "prettier" },
8188
files: {
8289
"astro.config.ts": await readTemplate(
@@ -131,17 +138,20 @@ const TASKS = {
131138
dev: `${astroDenoCommand} dev`,
132139
build: `${astroDenoCommand} build`,
133140
preview: `${astroDenoCommand} preview`,
141+
test: getTestTask("deno"),
134142
},
135143
"bun": {
136144
dev: "bunx --bun astro dev",
137145
build: "bunx --bun astro build",
138146
preview: "bun ./dist/server/entry.mjs",
147+
test: getTestTask("bun"),
139148
...astroNodeBunDevToolTasks,
140149
},
141150
"node": {
142151
dev: "dotenvx run -- astro dev",
143152
build: "dotenvx run -- astro build",
144153
preview: "dotenvx run -- astro preview",
154+
test: getTestTask("npm"),
145155
...astroNodeBunDevToolTasks,
146156
},
147157
};

0 commit comments

Comments
 (0)