-
-
Notifications
You must be signed in to change notification settings - Fork 129
Add a smoke test to generated fedify init apps
#990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
81b44d9
02f8a79
f36fb54
8bfb90a
b75f6f9
c81fb8e
70efe68
1dbfccd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| links: | ||
| '#990': https://github.com/fedify-dev/fedify/pull/990 | ||
| --- | ||
| - 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], [#990] by Jang Hanarae] |
|
Palcimer marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| import { getDocumentLoader } from "@fedify/fedify"; | ||
| import { type Actor, isActor, lookupObject } from "@fedify/vocab"; | ||
| import { spawn, spawnSync } from "node:child_process"; | ||
| import type { Readable } from "node:stream"; | ||
|
|
||
| const DEV_COMMAND: string[] = /* dev command */; | ||
| const HANDLE = "john"; | ||
| const STARTUP_TIMEOUT = 15_000; | ||
| const IS_WINDOWS = process.platform === "win32"; | ||
|
|
||
| async function main(): Promise<void> { | ||
| const [command, ...args] = DEV_COMMAND; | ||
| const server = spawn(command, args, { | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| shell: IS_WINDOWS, | ||
| windowsHide: true, | ||
| detached: !IS_WINDOWS, | ||
| }); | ||
|
Palcimer marked this conversation as resolved.
|
||
| server.on("error", () => {}); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Propagate development-server spawn errors.
Proposed fix- server.on("error", () => {});
-
const exitOnSignal = () => {
stopServer(server);
process.exit(1);
@@
scan(server.stdout);
scan(server.stderr);
+ server.once("error", (error) => {
+ clearTimeout(timeout);
+ reject(new Error(`Could not start the dev server: ${error.message}`));
+ });
server.once("exit", (code) => {
clearTimeout(timeout);
reject(new Error(`The dev server exited early with code ${String(code)}.`));🤖 Prompt for AI Agents |
||
|
|
||
| const exitOnSignal = () => { | ||
| stopServer(server); | ||
| process.exit(1); | ||
| }; | ||
| process.once("SIGINT", exitOnSignal); | ||
| process.once("SIGTERM", exitOnSignal); | ||
|
|
||
| let output = ""; | ||
| const collectOutput = (stream: Readable | null) => { | ||
| const decoder = new TextDecoder(); | ||
| stream?.on("data", (chunk: Buffer) => { | ||
| output += decoder.decode(chunk, { stream: true }); | ||
| }); | ||
| }; | ||
| collectOutput(server.stdout); | ||
| collectOutput(server.stderr); | ||
|
|
||
| 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 stripEscape(text: string): string { | ||
| return text.replace(new RegExp("\\u001B\\[[0-9;]*[A-Za-z]", "g"), ""); | ||
| } | ||
|
|
||
| function determinePort(server: ReturnType<typeof spawn>): Promise<number> { | ||
| const portPatterns = [ | ||
| /listening on.*:(\d+)/i, | ||
| /server.*:(\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 findPort = (text: string) => { | ||
| for (const pattern of portPatterns) { | ||
| const match = text.match(pattern); | ||
| if (match && match[1]) { | ||
| const port = Number.parseInt(match[1], 10); | ||
| if (port > 0 && port < 65536) return port; | ||
| } | ||
| } | ||
| return null; | ||
| }; | ||
|
|
||
| const scan = (stream: Readable | null) => { | ||
| const decoder = new TextDecoder(); | ||
| let text = ""; | ||
| stream?.on("data", (chunk: Buffer) => { | ||
| text += decoder.decode(chunk, { stream: true }); | ||
| const port = findPort(stripEscape(text)); | ||
| if (port != null) { | ||
| clearTimeout(timeout); | ||
| resolve(port); | ||
| } | ||
| }); | ||
| }; | ||
|
|
||
| scan(server.stdout); | ||
| scan(server.stderr); | ||
| 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
+136
to
+139
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.tplRepository: fedify-dev/fedify Length of output: 8802 Apply the startup timeout to actor resolution.
🤖 Prompt for AI Agents |
||
| 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 { | ||
| if (server.pid == null) return; | ||
| if (IS_WINDOWS) { | ||
| spawnSync("taskkill", ["/pid", String(server.pid), "/T", "/F"], { | ||
| stdio: "ignore", | ||
| windowsHide: true, | ||
| }); | ||
| return; | ||
| } | ||
| try { | ||
| process.kill(-server.pid, "SIGKILL"); | ||
| } catch { | ||
| // Process group already exited. | ||
| } | ||
| try { | ||
| server.kill("SIGKILL"); | ||
| } catch { | ||
| // Process already exited. | ||
| } | ||
| } | ||
|
|
||
| await main(); | ||
There was a problem hiding this comment.
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:
Repository: fedify-dev/fedify
Length of output: 15893
🏁 Script executed:
Repository: fedify-dev/fedify
Length of output: 25321
Add smoke-test command coverage for Deno.
patchFileshas one smoke-test case that checks["npm","run","dev"], but no case usespackageManager: "deno"or checks the Deno command. Add a Deno case, or ensure another patch test coverssmokeTest.tscommand substitution for Deno.🤖 Prompt for AI Agents