From b224c9907415e4dd9cdf779de2b91955cbe652f9 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Feb 2026 11:09:52 +0000 Subject: [PATCH 1/9] Fix errors in examples --- examples/cloudflare-workers/package.json | 1 + examples/custom-collections/main.ts | 3 +- examples/elysia/app.ts | 2 +- examples/fastify/index.ts | 2 +- examples/h3/index.ts | 4 +- examples/hono-sample/main.ts | 2 +- examples/next15-app-router/tsconfig.json | 8 ++- .../sveltekit-sample/src/lib/federation.ts | 2 +- examples/sveltekit-sample/src/lib/fetch.ts | 3 +- examples/sveltekit-sample/src/lib/store.ts | 2 +- .../users/[identifier]/posts/+page.server.ts | 2 +- pnpm-lock.yaml | 52 +++++++------------ 12 files changed, 40 insertions(+), 43 deletions(-) diff --git a/examples/cloudflare-workers/package.json b/examples/cloudflare-workers/package.json index 186b9f52b..26544c337 100644 --- a/examples/cloudflare-workers/package.json +++ b/examples/cloudflare-workers/package.json @@ -14,6 +14,7 @@ "wrangler": "^4.18.0" }, "dependencies": { + "@fedify/cfworkers": "workspace:^", "@fedify/fedify": "workspace:^", "@fedify/vocab": "workspace:^" } diff --git a/examples/custom-collections/main.ts b/examples/custom-collections/main.ts index fdedf9377..55cda8f81 100644 --- a/examples/custom-collections/main.ts +++ b/examples/custom-collections/main.ts @@ -1,4 +1,5 @@ -import { createFederation, MemoryKvStore, Note } from "@fedify/fedify"; +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { Note } from "@fedify/vocab"; // Mock data - in a real application, this would query your database const POSTS = [ diff --git a/examples/elysia/app.ts b/examples/elysia/app.ts index 7b36589cf..e3fe22e2e 100644 --- a/examples/elysia/app.ts +++ b/examples/elysia/app.ts @@ -11,7 +11,7 @@ federation.setNodeInfoDispatcher("/nodeinfo/2.1", (ctx) => { return { software: { name: "fedify-elysia", // Lowercase, digits, and hyphens only. - version: { major: 1, minor: 0, patch: 0 }, + version: "1.0.0", homepage: new URL(ctx.canonicalOrigin), }, protocols: ["activitypub"], diff --git a/examples/fastify/index.ts b/examples/fastify/index.ts index fdea0772b..7940b78b5 100644 --- a/examples/fastify/index.ts +++ b/examples/fastify/index.ts @@ -1,7 +1,7 @@ import { createFederation, MemoryKvStore } from "@fedify/fedify"; import { Person } from "@fedify/vocab"; import Fastify from "fastify"; -import fedifyPlugin from "../../packages/fastify/src/index.ts"; +import fedifyPlugin from "@fedify/fastify"; const fastify = Fastify({ logger: true }); diff --git a/examples/h3/index.ts b/examples/h3/index.ts index fd775ae2f..07b72a287 100644 --- a/examples/h3/index.ts +++ b/examples/h3/index.ts @@ -1,3 +1,4 @@ +import { integrateFederation, onError } from "@fedify/h3"; import { createApp, createRouter, @@ -5,8 +6,7 @@ import { setResponseHeader, toWebHandler, } from "h3"; -import { integrateFederation, onError } from "../src"; -import { federation } from "./federation"; +import { federation } from "./federation.ts"; export const app = createApp({ onError, diff --git a/examples/hono-sample/main.ts b/examples/hono-sample/main.ts index 629f19fd9..46a59b9cd 100644 --- a/examples/hono-sample/main.ts +++ b/examples/hono-sample/main.ts @@ -1,4 +1,4 @@ -import { createFederation, MemoryKvStore } from "@fedify/fedify/federation"; +import { createFederation, MemoryKvStore } from "@fedify/fedify"; import { federation } from "@fedify/hono"; import { Person } from "@fedify/vocab"; import { Hono } from "hono"; diff --git a/examples/next15-app-router/tsconfig.json b/examples/next15-app-router/tsconfig.json index 7203eb046..46cb83092 100644 --- a/examples/next15-app-router/tsconfig.json +++ b/examples/next15-app-router/tsconfig.json @@ -22,6 +22,12 @@ "~/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "app/.well-known/**/*.ts" + ], "exclude": ["node_modules"] } diff --git a/examples/sveltekit-sample/src/lib/federation.ts b/examples/sveltekit-sample/src/lib/federation.ts index 81e3c6899..d1dc07952 100644 --- a/examples/sveltekit-sample/src/lib/federation.ts +++ b/examples/sveltekit-sample/src/lib/federation.ts @@ -2,7 +2,6 @@ import { createFederation, generateCryptoKeyPair, MemoryKvStore, - PUBLIC_COLLECTION, } from "@fedify/fedify"; import { Accept, @@ -11,6 +10,7 @@ import { Image, Note, Person, + PUBLIC_COLLECTION, type Recipient, Undo, } from "@fedify/vocab"; diff --git a/examples/sveltekit-sample/src/lib/fetch.ts b/examples/sveltekit-sample/src/lib/fetch.ts index 645ea7509..c9818ea9e 100644 --- a/examples/sveltekit-sample/src/lib/fetch.ts +++ b/examples/sveltekit-sample/src/lib/fetch.ts @@ -1,4 +1,5 @@ -import { Note, type RequestContext } from "@fedify/fedify"; +import { type RequestContext } from "@fedify/fedify"; +import { Note } from "@fedify/vocab"; import type { Post, User } from "./types"; import { postStore } from "./store"; diff --git a/examples/sveltekit-sample/src/lib/store.ts b/examples/sveltekit-sample/src/lib/store.ts index 74dc319df..3e2f5b9e6 100644 --- a/examples/sveltekit-sample/src/lib/store.ts +++ b/examples/sveltekit-sample/src/lib/store.ts @@ -1,4 +1,4 @@ -import type { Note, Person } from "@fedify/fedify"; +import type { Note, Person } from "@fedify/vocab"; declare global { var keyPairsStore: Map>; diff --git a/examples/sveltekit-sample/src/routes/users/[identifier]/posts/+page.server.ts b/examples/sveltekit-sample/src/routes/users/[identifier]/posts/+page.server.ts index eef9958eb..e6abb54a3 100644 --- a/examples/sveltekit-sample/src/routes/users/[identifier]/posts/+page.server.ts +++ b/examples/sveltekit-sample/src/routes/users/[identifier]/posts/+page.server.ts @@ -1,7 +1,7 @@ import { default as federation, default as fedi } from "$lib/federation"; import { getPosts, getUser } from "$lib/fetch"; import { postStore } from "$lib/store"; -import { Create, Note } from "@fedify/fedify"; +import { Create, Note } from "@fedify/vocab"; import { error, redirect } from "@sveltejs/kit"; import type { Action, Actions, PageServerLoad } from "./$types"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aad4f3995..a92e27611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -346,6 +346,9 @@ importers: examples/cloudflare-workers: dependencies: + '@fedify/cfworkers': + specifier: workspace:^ + version: link:../../packages/cfworkers '@fedify/fedify': specifier: workspace:^ version: link:../../packages/fedify @@ -13890,8 +13893,8 @@ snapshots: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react: 7.37.5(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.32.0(jiti@2.5.1)) @@ -13910,8 +13913,8 @@ snapshots: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react: 7.37.5(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.32.0(jiti@2.5.1)) @@ -13934,22 +13937,22 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 - eslint: 9.32.0(jiti@2.5.1) + eslint: 8.57.1 get-tsconfig: 4.10.1 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -13960,22 +13963,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) - transitivePeerDependencies: - - supports-color - - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1 - eslint: 8.57.1 - get-tsconfig: 4.10.1 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color @@ -13990,25 +13978,25 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color @@ -14041,7 +14029,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -14052,7 +14040,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -14070,7 +14058,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -14081,7 +14069,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From dadf62bbc4456f9d3c0d3b1139738bddc3e2483e Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Feb 2026 21:42:27 +0000 Subject: [PATCH 2/9] Add test script for examples --- examples/test-examples/mod.ts | 828 ++++++++++++++++++++++++++++++++++ 1 file changed, 828 insertions(+) create mode 100644 examples/test-examples/mod.ts diff --git a/examples/test-examples/mod.ts b/examples/test-examples/mod.ts new file mode 100644 index 000000000..b0fdf206a --- /dev/null +++ b/examples/test-examples/mod.ts @@ -0,0 +1,828 @@ +/** + * Test runner for Fedify example projects. + * + * For server-based examples, starts the server, creates a public tunnel with + * `fedify tunnel` (via `deno task cli tunnel`), and verifies federation is + * working via `fedify lookup`. For script-based examples, runs them directly + * and checks the exit code. + * + * Usage (from repository root): + * deno run --allow-all examples/test-examples/mod.ts [options] [examples...] + * + * Options: + * --timeout MS Server readiness timeout in ms (default: 10000) + * --debug Enable debug-level logging via @logtape/logtape + * + * If example names are provided as positional arguments, only those examples + * are tested. Otherwise all examples are tested. + * + * Example: + * deno run --allow-all examples/test-examples/mod.ts express koa + * deno run --allow-all examples/test-examples/mod.ts --debug hono-sample + */ + +import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; +import { join } from "@std/path"; + +// ─── Paths ──────────────────────────────────────────────────────────────────── + +const EXAMPLES_DIR = new URL("../", import.meta.url).pathname; +const REPO_ROOT = new URL("../../", import.meta.url).pathname; + +// ─── Logging ────────────────────────────────────────────────────────────────── +// +// We configure logtape before everything else so that log calls in helpers +// work even if they execute at module-initialization time. +// +// All test-runner logs live under the ["fedify", "examples"] category. +// Library-internal fedify logs are intentionally excluded so they don't flood +// the output. Pass --debug to lower the level from "info" to "debug". + +const debugMode = Deno.args.includes("--debug") || Deno.args.includes("-d"); + +await configure({ + sinks: { console: getConsoleSink() }, + filters: {}, + loggers: [ + { + category: ["fedify", "examples"], + lowestLevel: debugMode ? "debug" : "info", + sinks: ["console"], + filters: [], + }, + { + // Suppress logtape's own meta-logs unless something is wrong. + category: ["logtape", "meta"], + lowestLevel: "warning", + sinks: ["console"], + filters: [], + }, + ], +}); + +const logger = getLogger(["fedify", "examples", "test-runner"]); + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** An example that starts a long-running HTTP server. */ +interface ServerExample { + name: string; + /** Directory name inside examples/ */ + dir: string; + /** Optional build command to run before starting the server */ + buildCmd?: string[]; + /** Working directory for buildCmd (defaults to the example directory) */ + buildCwd?: string; + /** Command to start the server */ + startCmd: string[]; + /** Working directory for startCmd (defaults to the example directory) */ + startCwd?: string; + /** Port the server listens on */ + port: number; + /** ActivityPub actor username to look up via WebFinger */ + actor: string; + /** URL to poll until the server responds (any HTTP status counts as ready) */ + readyUrl: string; + /** Override the global ready-timeout (ms) for this example */ + readyTimeout?: number; + /** Extra environment variables injected into the server process */ + env?: Record; +} + +/** An example that is a standalone script (not a server). */ +interface ScriptExample { + name: string; + dir: string; + cmd: string[]; + description: string; +} + +/** + * A script example that accepts an ActivityPub handle as its last argument. + * Each handle in `handles` is tried in order; the test passes as soon as any + * one of them exits with code 0. + */ +interface MultiHandleExample { + name: string; + dir: string; + /** Command prefix — the handle is appended as the final argument. */ + cmd: string[]; + /** Handles to try, in order. Pass if any succeeds. */ + handles: string[]; + description: string; +} + +/** An example that is intentionally not tested automatically. */ +interface SkippedExample { + name: string; + reason: string; +} + +type TestResult = + | { name: string; status: "pass"; output: string } + | { name: string; status: "fail"; error: string; output: string } + | { name: string; status: "skip"; reason: string }; + +// ─── Example Registry ───────────────────────────────────────────────────────── + +const SERVER_EXAMPLES: ServerExample[] = [ + { + // Deno-native Hono server; actor path is /{identifier} but only "sample" + // is registered. + name: "hono-sample", + dir: "hono-sample", + startCmd: ["deno", "run", "--allow-all", "main.ts"], + port: 8000, + actor: "sample", + readyUrl: "http://localhost:8000/", + }, + { + // h3 server exported as a Fetch handler; run with `deno serve`. + // Executed from the repo root so Deno workspace imports resolve correctly. + name: "h3", + dir: "h3", + startCmd: [ + "deno", + "serve", + "--allow-all", + "--port", + "8000", + "examples/h3/index.ts", + ], + startCwd: REPO_ROOT, + port: 8000, + actor: "demo", + readyUrl: "http://localhost:8000/", + }, + { + // Express server; app.ts reads process.env.PORT (default 8000). + name: "express", + dir: "express", + startCmd: ["pnpm", "start"], + port: 8000, + actor: "demo", + readyUrl: "http://localhost:8000/", + }, + { + // Fastify server; actor path is /users/{identifier}. + name: "fastify", + dir: "fastify", + startCmd: ["pnpm", "start"], + port: 3000, + actor: "demo", + readyUrl: "http://localhost:3000/", + }, + { + // Koa server; actor path is /users/{identifier}. + name: "koa", + dir: "koa", + startCmd: ["pnpm", "start"], + port: 3000, + actor: "demo", + readyUrl: "http://localhost:3000/", + }, + { + // Elysia/Bun server; actor path is /{identifier} but only "sample" works. + name: "elysia", + dir: "elysia", + startCmd: ["bun", "run", "app.ts"], + port: 3000, + actor: "sample", + readyUrl: "http://localhost:3000/", + }, + { + // Next.js 14 app router; actor path is /users/{identifier}. + // Requires a build step before starting. + name: "next14-app-router", + dir: "next14-app-router", + buildCmd: ["pnpm", "build"], + startCmd: ["pnpm", "start"], + port: 3000, + actor: "demo", + readyUrl: "http://localhost:3000/", + readyTimeout: 30_000, + }, + { + // Next.js 15 app router; actor path is /users/{identifier}. + // Requires a build step before starting. + name: "next15-app-router", + dir: "next15-app-router", + buildCmd: ["pnpm", "build"], + startCmd: ["pnpm", "start"], + port: 3000, + actor: "demo", + readyUrl: "http://localhost:3000/", + readyTimeout: 30_000, + }, + { + // Next.js integration example using @fedify/next middleware. + // Requires a build step before starting. + name: "next-integration", + dir: "next-integration", + buildCmd: ["pnpm", "build"], + startCmd: ["pnpm", "start"], + port: 3000, + actor: "demo", + readyUrl: "http://localhost:3000/", + readyTimeout: 30_000, + }, + { + // SvelteKit sample using @fedify/sveltekit; actor path is /users/{identifier}. + // Built with vite; served with vite preview on port 4173. + name: "sveltekit-sample", + dir: "sveltekit-sample", + buildCmd: ["pnpm", "build"], + startCmd: ["pnpm", "preview"], + port: 4173, + actor: "demo", + readyUrl: "http://localhost:4173/", + }, +]; + +const SCRIPT_EXAMPLES: ScriptExample[] = [ + { + // Self-contained federation demo; creates a federation in-process and + // performs a single fetch. No server is started. + name: "custom-collections", + dir: "custom-collections", + cmd: ["deno", "run", "--allow-all", "main.ts"], + description: "Custom collection demonstration (in-process federation)", + }, +]; + +const MULTI_HANDLE_EXAMPLES: MultiHandleExample[] = [ + { + // Looks up a real fediverse actor; passes if any handle resolves. + name: "actor-lookup-cli", + dir: "actor-lookup-cli", + cmd: ["deno", "run", "--allow-all", "main.ts"], + handles: ["@hongminhee@hackers.pub", "@hongminhee@hollo.social"], + description: "Actor lookup CLI (real fediverse handle)", + }, +]; + +const SKIPPED_EXAMPLES: SkippedExample[] = [ + { + name: "cloudflare-workers", + reason: "Requires Cloudflare Workers environment and wrangler CLI", + }, + { + name: "fresh", + reason: + "No actor dispatcher configured; federation lookup cannot be verified", + }, +]; + +// ─── ANSI Colors ────────────────────────────────────────────────────────────── + +const c = { + bold: (s: string) => `\x1b[1m${s}\x1b[0m`, + dim: (s: string) => `\x1b[2m${s}\x1b[0m`, + green: (s: string) => `\x1b[32m${s}\x1b[0m`, + red: (s: string) => `\x1b[31m${s}\x1b[0m`, + yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, + cyan: (s: string) => `\x1b[36m${s}\x1b[0m`, +}; + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +function decodeChunks(chunks: Uint8Array[]): string { + let total = 0; + for (const chunk of chunks) total += chunk.length; + const buf = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + buf.set(chunk, offset); + offset += chunk.length; + } + return new TextDecoder().decode(buf); +} + +/** + * Drains a ReadableStream into `chunks` in the background, logging each chunk + * at DEBUG level so that raw server/tunnel output is visible with --debug. + */ +function drainLogging( + stream: ReadableStream, + chunks: Uint8Array[], + streamLogger: ReturnType, +): void { + (async () => { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + const text = decoder.decode(value).trim(); + if (text) streamLogger.debug("{output}", { output: text }); + } + })(); +} + +/** + * Polls `url` every 500 ms until the server responds with any HTTP status. + * Returns true if ready before `timeoutMs`, false otherwise. + */ +async function waitForServer(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + await res.body?.cancel(); + return true; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + return false; +} + +// ─── Process Management ─────────────────────────────────────────────────────── + +/** + * Finds all processes **listening** on `port` (TCP LISTEN state only) via + * `lsof -ti : -sTCP:LISTEN` and sends SIGKILL to each of them. + * + * Using `-sTCP:LISTEN` is critical: without it `lsof` also returns processes + * that merely have an established _client_ connection to the port (e.g. the + * test-runner itself after calling `waitForServer`), which would cause the + * runner to kill itself. + */ +async function killPortUsers(port: number): Promise { + let pids: string[]; + try { + const result = await new Deno.Command("lsof", { + args: ["-ti", `:${port}`, "-sTCP:LISTEN"], + stdout: "piped", + stderr: "null", + }).output(); + pids = new TextDecoder() + .decode(result.stdout) + .trim() + .split("\n") + .filter(Boolean); + } catch { + return; // lsof unavailable or no match — nothing to do. + } + for (const pid of pids) { + try { + await new Deno.Command("kill", { args: ["-9", pid] }).output(); + logger.debug("Force-killed PID {pid} on port {port}", { pid, port }); + } catch { + // Already gone. + } + } +} + +/** + * Sends SIGKILL to `proc` immediately and awaits its exit status. + */ +async function forceKillProc(proc: Deno.ChildProcess): Promise { + try { + proc.kill("SIGKILL"); + } catch { + // Process already exited. + } + await proc.status.catch(() => {}); +} + +// ─── Tunnel ─────────────────────────────────────────────────────────────────── + +/** + * Starts `fedify tunnel -s localhost.run ` and waits up to `timeoutMs` + * for the tunnel URL to appear in its output. The tunnel process is kept + * alive and returned to the caller; it must be killed when no longer needed. + * + * Returns `null` if the URL was not found before the timeout. + */ +async function startTunnel( + port: number, + timeoutMs: number, +): Promise<{ proc: Deno.ChildProcess; url: string } | null> { + const tunnelLogger = getLogger(["fedify", "examples", "tunnel"]); + tunnelLogger.info("Opening localhost.run tunnel on port {port}", { port }); + + const proc = new Deno.Command("deno", { + args: ["task", "cli", "tunnel", "-s", "pinggy.io", String(port)], + cwd: REPO_ROOT, + stdout: "piped", + stderr: "piped", + }).spawn(); + + // Accumulate text from both streams while logging each chunk at DEBUG. + const textChunks: string[] = []; + const decoder = new TextDecoder(); + + const readStream = (stream: ReadableStream) => { + (async () => { + const reader = stream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + textChunks.push(text); + const trimmed = text.trim(); + if (trimmed) tunnelLogger.debug("{output}", { output: trimmed }); + } + })(); + }; + + readStream(proc.stdout); + readStream(proc.stderr); + + // Poll until we find an https URL in the accumulated output. + // The `message` template tag from @optique/run may wrap the URL in double + // quotes in non-TTY output, so we stop matching at whitespace or quotes. + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const match = textChunks.join("").match(/https:\/\/[^\s"']+/); + if (match) { + tunnelLogger.info("Tunnel established at {url}", { url: match[0] }); + return { proc, url: match[0] }; + } + await new Promise((r) => setTimeout(r, 200)); + } + + tunnelLogger.error( + "Tunnel did not produce a URL within {timeout} ms", + { timeout: timeoutMs }, + ); + await forceKillProc(proc); + return null; +} + +// ─── Test Runners ───────────────────────────────────────────────────────────── + +async function testServerExample( + example: ServerExample, + defaultTimeoutMs: number, +): Promise { + const { + name, + dir, + buildCmd, + startCmd, + startCwd, + port, + actor, + readyUrl, + env, + } = example; + const exampleDir = join(EXAMPLES_DIR, dir); + const cwd = startCwd ?? exampleDir; + const timeoutMs = example.readyTimeout ?? defaultTimeoutMs; + const serverLogger = getLogger(["fedify", "examples", name]); + + // ── Build step (if configured) ──────────────────────────────────────────── + if (buildCmd != null) { + const buildCwd = example.buildCwd ?? exampleDir; + console.log(c.cyan(`\n[${name}]`) + " Building…"); + console.log(c.dim(` cmd : ${buildCmd.join(" ")}`)); + console.log(c.dim(` cwd : ${buildCwd}`)); + serverLogger.info("Building {name}", { name, cmd: buildCmd.join(" ") }); + + let buildResult: Deno.CommandOutput; + try { + buildResult = await new Deno.Command(buildCmd[0], { + args: buildCmd.slice(1), + cwd: buildCwd, + stdout: "piped", + stderr: "piped", + }).output(); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + const error = `Build command not found: ${buildCmd[0]}`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: "" }; + } + throw e; + } + + const buildOutput = new TextDecoder().decode(buildResult.stdout) + + new TextDecoder().decode(buildResult.stderr); + serverLogger.debug("Build output:\n{output}", { + output: buildOutput.trim(), + }); + + if (!buildResult.success) { + const error = `Build failed with exit code ${buildResult.code}`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: buildOutput }; + } + serverLogger.info("{name} build succeeded", { name }); + console.log(c.dim(` build succeeded`)); + } + + // Kill any process already using the port before we try to bind it. + serverLogger.debug("Clearing port {port} before start", { port }); + await killPortUsers(port); + + console.log(c.cyan(`\n[${name}]`) + " Starting server…"); + console.log(c.dim(` cmd : ${startCmd.join(" ")}`)); + console.log(c.dim(` cwd : ${cwd}`)); + if (env) console.log(c.dim(` env : ${JSON.stringify(env)}`)); + + serverLogger.info("Starting {name}", { name, cmd: startCmd.join(" "), cwd }); + + let serverProc: Deno.ChildProcess; + try { + serverProc = new Deno.Command(startCmd[0], { + args: startCmd.slice(1), + cwd: cwd, + stdout: "piped", + stderr: "piped", + env: env ? { ...Deno.env.toObject(), ...env } : undefined, + }).spawn(); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + const error = `Command not found: ${startCmd[0]}`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: "" }; + } + throw e; + } + + const stdoutChunks: Uint8Array[] = []; + const stderrChunks: Uint8Array[] = []; + drainLogging(serverProc.stdout, stdoutChunks, serverLogger); + drainLogging(serverProc.stderr, stderrChunks, serverLogger); + + const collectServerOutput = () => + decodeChunks(stdoutChunks) + decodeChunks(stderrChunks); + + let tunnelProc: Deno.ChildProcess | null = null; + + try { + console.log( + c.dim( + ` waiting for server at ${readyUrl} (timeout: ${timeoutMs} ms)…`, + ), + ); + serverLogger.debug("Polling {url}", { url: readyUrl }); + + const ready = await waitForServer(readyUrl, timeoutMs); + if (!ready) { + const error = `Server did not become ready within ${timeoutMs} ms`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: collectServerOutput() }; + } + + serverLogger.info("{name} is ready; opening tunnel on port {port}", { + name, + port, + }); + console.log(c.dim(` server ready — opening tunnel on port ${port}…`)); + + const tunnel = await startTunnel(port, 30_000); + if (tunnel == null) { + const error = "fedify tunnel did not produce a URL within 5 minutes"; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: collectServerOutput() }; + } + + tunnelProc = tunnel.proc; + const tunnelHostname = new URL(tunnel.url).hostname; + const handle = `@${actor}@${tunnelHostname}`; + + console.log(c.dim(` tunnel URL : ${tunnel.url}`)); + console.log(c.dim(` running : fedify lookup ${handle} -d`)); + serverLogger.info("Running fedify lookup {handle}", { handle }); + + const lookup = await new Deno.Command("deno", { + args: ["task", "cli", "lookup", handle, "-d"], + cwd: REPO_ROOT, + stdout: "piped", + stderr: "piped", + }).output(); + + const lookupOutput = new TextDecoder().decode(lookup.stdout) + + new TextDecoder().decode(lookup.stderr); + serverLogger.debug("Lookup output:\n{output}", { + output: lookupOutput.trim(), + }); + + if (lookup.code === 0) { + serverLogger.info("{name} passed", { name }); + return { name, status: "pass", output: lookupOutput }; + } + const error = `fedify lookup exited with code ${lookup.code}`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: lookupOutput }; + } finally { + // Force-kill tunnel first (it holds a connection to the server). + if (tunnelProc != null) { + serverLogger.debug("Force-killing tunnel process"); + await forceKillProc(tunnelProc); + } + serverLogger.debug("Force-killing server process"); + await forceKillProc(serverProc); + + // Kill any lingering processes still bound to the port. + serverLogger.debug("Killing remaining processes on port {port}", { port }); + await killPortUsers(port); + } +} + +async function testScriptExample(example: ScriptExample): Promise { + const { name, dir, cmd, description } = example; + const cwd = join(EXAMPLES_DIR, dir); + const scriptLogger = getLogger(["fedify", "examples", name]); + + console.log(c.cyan(`\n[${name}]`) + ` Running: ${c.dim(description)}`); + console.log(c.dim(` cmd : ${cmd.join(" ")}`)); + scriptLogger.info("Running script {name}: {cmd}", { + name, + cmd: cmd.join(" "), + }); + + const result = await new Deno.Command(cmd[0], { + args: cmd.slice(1), + cwd, + stdout: "piped", + stderr: "piped", + }).output(); + + const output = new TextDecoder().decode(result.stdout) + + new TextDecoder().decode(result.stderr); + scriptLogger.debug("Script output:\n{output}", { output: output.trim() }); + + if (result.code === 0) { + scriptLogger.info("{name} passed", { name }); + return { name, status: "pass", output }; + } + const error = `Script exited with code ${result.code}`; + scriptLogger.error("{error}", { error }); + return { name, status: "fail", error, output }; +} + +async function testMultiHandleExample( + example: MultiHandleExample, +): Promise { + const { name, dir, cmd, handles, description } = example; + const cwd = join(EXAMPLES_DIR, dir); + const scriptLogger = getLogger(["fedify", "examples", name]); + + console.log(c.cyan(`\n[${name}]`) + ` Running: ${c.dim(description)}`); + scriptLogger.info("Testing {name} with {count} handle(s)", { + name, + count: handles.length, + }); + + let lastOutput = ""; + for (const handle of handles) { + const fullCmd = [...cmd, handle]; + console.log(c.dim(` cmd : ${fullCmd.join(" ")}`)); + scriptLogger.info("Trying handle {handle}", { handle }); + + const result = await new Deno.Command(fullCmd[0], { + args: fullCmd.slice(1), + cwd, + stdout: "piped", + stderr: "piped", + }).output(); + + const output = + new TextDecoder().decode(result.stdout) + + new TextDecoder().decode(result.stderr); + scriptLogger.debug("Output:\n{output}", { output: output.trim() }); + lastOutput = output; + + if (result.code === 0) { + scriptLogger.info("{name} passed with handle {handle}", { name, handle }); + return { name, status: "pass", output }; + } + scriptLogger.warn("Handle {handle} failed with code {code}", { + handle, + code: result.code, + }); + } + + const error = `All ${handles.length} handle(s) failed`; + scriptLogger.error("{error}", { error }); + return { name, status: "fail", error, output: lastOutput }; +} + +// ─── Result Printer ─────────────────────────────────────────────────────────── + +function printInlineResult(result: TestResult): void { + if (result.status === "pass") { + console.log(c.green(` ✓ ${result.name}`)); + } else if (result.status === "fail") { + console.log(c.red(` ✗ ${result.name}: ${result.error}`)); + } else { + console.log(c.yellow(` ⊘ ${result.name}: ${result.reason}`)); + } +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + // ── Parse CLI arguments ────────────────────────────────────────────────── + let defaultTimeoutMs = 10_000; + const filterNames = new Set(); + + for (let i = 0; i < Deno.args.length; i++) { + const arg = Deno.args[i]; + if (arg === "--timeout" && i + 1 < Deno.args.length) { + defaultTimeoutMs = Number(Deno.args[++i]); + } else if (arg.startsWith("--timeout=")) { + defaultTimeoutMs = Number(arg.slice("--timeout=".length)); + } else if (arg === "--debug" || arg === "-d") { + // already handled above at module level + } else if (!arg.startsWith("--")) { + filterNames.add(arg); + } + } + + const shouldRun = (name: string) => + filterNames.size === 0 || filterNames.has(name); + + // ── Banner ─────────────────────────────────────────────────────────────── + console.log(c.bold("Fedify Example Test Runner")); + console.log(`Tunnel service: localhost.run (via \`fedify tunnel\`)`); + console.log(`Ready timeout : ${defaultTimeoutMs} ms`); + if (debugMode) console.log(`Debug logging : enabled`); + if (filterNames.size > 0) { + console.log(`Running only : ${[...filterNames].join(", ")}`); + } + console.log(); + + logger.info("Test runner started", { debugMode, defaultTimeoutMs }); + + // ── Run examples ───────────────────────────────────────────────────────── + const results: TestResult[] = []; + const usedPorts = new Set(); + + for (const example of SERVER_EXAMPLES) { + if (!shouldRun(example.name)) continue; + usedPorts.add(example.port); + const result = await testServerExample(example, defaultTimeoutMs); + results.push(result); + printInlineResult(result); + } + + for (const example of SCRIPT_EXAMPLES) { + if (!shouldRun(example.name)) continue; + const result = await testScriptExample(example); + results.push(result); + printInlineResult(result); + } + + for (const example of MULTI_HANDLE_EXAMPLES) { + if (!shouldRun(example.name)) continue; + const result = await testMultiHandleExample(example); + results.push(result); + printInlineResult(result); + } + + for (const example of SKIPPED_EXAMPLES) { + if (!shouldRun(example.name)) continue; + const result: TestResult = { + name: example.name, + status: "skip", + reason: example.reason, + }; + results.push(result); + printInlineResult(result); + } + + // ── Final port cleanup ─────────────────────────────────────────────────── + // After all tests are done, ensure no processes remain on the used ports. + if (usedPorts.size > 0) { + logger.debug("Final cleanup: killing remaining processes on used ports"); + for (const port of usedPorts) { + await killPortUsers(port); + } + } + + // ── Summary ────────────────────────────────────────────────────────────── + const passed = results.filter((r) => r.status === "pass"); + const failed = results.filter((r) => r.status === "fail"); + const skipped = results.filter((r) => r.status === "skip"); + + console.log(c.bold("\n─────────────────── Summary ───────────────────")); + console.log(c.green(` ✓ Passed : ${passed.length}`)); + console.log(c.red(` ✗ Failed : ${failed.length}`)); + console.log(c.yellow(` ⊘ Skipped: ${skipped.length}`)); + + logger.info("Test run complete", { + passed: passed.length, + failed: failed.length, + skipped: skipped.length, + }); + + if (failed.length > 0) { + console.log(c.bold(c.red("\nFailed examples:"))); + for (const r of failed) { + if (r.status !== "fail") continue; + console.log(c.red(` • ${r.name}: ${r.error}`)); + // Show the last 20 lines of combined output as a quick hint. + const preview = r.output.trim().split("\n").slice(-20).join("\n"); + if (preview) console.log(c.dim(preview)); + } + Deno.exit(1); + } +} + +await main(); From 3f4c8d14fd8eb278950f51fd237dcce9f825dfac Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Feb 2026 21:53:12 +0000 Subject: [PATCH 3/9] Fix code with testing examples --- examples/next14-app-router/next.config.mjs | 6 ++++++ examples/next14-app-router/next.config.ts | 7 ------- examples/sveltekit-sample/vite.config.ts | 3 ++- examples/test-examples/mod.ts | 3 +-- packages/express/src/index.ts | 2 +- packages/nestjs/src/fedify.middleware.ts | 4 ++-- 6 files changed, 12 insertions(+), 13 deletions(-) create mode 100644 examples/next14-app-router/next.config.mjs delete mode 100644 examples/next14-app-router/next.config.ts diff --git a/examples/next14-app-router/next.config.mjs b/examples/next14-app-router/next.config.mjs new file mode 100644 index 000000000..b108e1a2e --- /dev/null +++ b/examples/next14-app-router/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/examples/next14-app-router/next.config.ts b/examples/next14-app-router/next.config.ts deleted file mode 100644 index e9ffa3083..000000000 --- a/examples/next14-app-router/next.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - /* config options here */ -}; - -export default nextConfig; diff --git a/examples/sveltekit-sample/vite.config.ts b/examples/sveltekit-sample/vite.config.ts index 86683174d..bf5fb74b5 100644 --- a/examples/sveltekit-sample/vite.config.ts +++ b/examples/sveltekit-sample/vite.config.ts @@ -1,7 +1,8 @@ -import tailwindcss from "@tailwindcss/vite"; import { sveltekit } from "@sveltejs/kit/vite"; +import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [tailwindcss(), sveltekit()], + server: { allowedHosts: true }, }); diff --git a/examples/test-examples/mod.ts b/examples/test-examples/mod.ts index b0fdf206a..c577ea9fe 100644 --- a/examples/test-examples/mod.ts +++ b/examples/test-examples/mod.ts @@ -682,8 +682,7 @@ async function testMultiHandleExample( stderr: "piped", }).output(); - const output = - new TextDecoder().decode(result.stdout) + + const output = new TextDecoder().decode(result.stdout) + new TextDecoder().decode(result.stderr); scriptLogger.debug("Output:\n{output}", { output: output.trim() }); lastOutput = output; diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index 7c9596f79..695eabf50 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -71,7 +71,7 @@ export function integrateFederation( function fromERequest(req: ERequest): Request { const url = `${req.protocol}://${ - req.header("Host") ?? req.hostname + req.hostname ?? req.header("Host") }${req.url}`; const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { diff --git a/packages/nestjs/src/fedify.middleware.ts b/packages/nestjs/src/fedify.middleware.ts index a0e0e53d4..8eca12404 100644 --- a/packages/nestjs/src/fedify.middleware.ts +++ b/packages/nestjs/src/fedify.middleware.ts @@ -1,10 +1,10 @@ +import type { Federation } from "@fedify/fedify"; import { Injectable, type NestMiddleware, type Type } from "@nestjs/common"; import type { NextFunction, Request as ERequest, Response as EResponse, } from "express"; -import type { Federation } from "@fedify/fedify"; import { Buffer } from "node:buffer"; export type ContextDataFactory = ( @@ -69,7 +69,7 @@ export function integrateFederation( function fromERequest(req: ERequest): Request { const url = `${req.protocol}://${ - req.header("Host") ?? req.hostname + req.hostname ?? req.header("Host") }${req.url}`; const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { From 539699509d0c301f0465a02520bc3631a6e2f70c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Feb 2026 21:55:17 +0000 Subject: [PATCH 4/9] Add command and docs about test-examples --- CONTRIBUTING.md | 11 +++++++++++ mise.toml | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b3126e8a..4618c2794 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -545,6 +545,17 @@ The test results are stored in `/tmp/fedify-init//`(UNIX). > to configure the init command to use local workspace packages instead of > published versions. +#### Testing the examples + +If you want to test the example projects, you can run the following command +from the root: + +~~~~ bash +mise run test:examples +~~~~ + +This command runs the tests for all example projects. + ### Building the docs If you want to change the Fedify docs, you would like to preview the changes diff --git a/mise.toml b/mise.toml index 2091c4700..9995d6011 100644 --- a/mise.toml +++ b/mise.toml @@ -169,6 +169,10 @@ for PACKAGE in ${usage_packages}; do done ''' +[tasks."test:examples"] +description = "Run tests for all example projects" +run = "deno run -A examples/test-examples/mod.ts" + # Snapshot updates # Note: vocab uses @std/testing/snapshot which only works on Deno # vocab-tools has separate snapshots for each runtime From 23cb60ef95237963b71059c5474de5e86ff6b7f2 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Feb 2026 22:20:37 +0000 Subject: [PATCH 5/9] Fix request url --- examples/test-examples/mod.ts | 2 +- packages/express/src/index.ts | 4 +--- packages/nestjs/src/fedify.middleware.ts | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/examples/test-examples/mod.ts b/examples/test-examples/mod.ts index c577ea9fe..58268e4fc 100644 --- a/examples/test-examples/mod.ts +++ b/examples/test-examples/mod.ts @@ -22,7 +22,7 @@ */ import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; -import { join } from "@std/path"; +import { fromFileUrl, join } from "@std/path"; // ─── Paths ──────────────────────────────────────────────────────────────────── diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index 695eabf50..1bc759d67 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -70,9 +70,7 @@ export function integrateFederation( } function fromERequest(req: ERequest): Request { - const url = `${req.protocol}://${ - req.hostname ?? req.header("Host") - }${req.url}`; + const url = `${req.protocol}://${req.host ?? req.header("Host")}${req.url}`; const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (Array.isArray(value)) { diff --git a/packages/nestjs/src/fedify.middleware.ts b/packages/nestjs/src/fedify.middleware.ts index 8eca12404..606f232b3 100644 --- a/packages/nestjs/src/fedify.middleware.ts +++ b/packages/nestjs/src/fedify.middleware.ts @@ -68,9 +68,7 @@ export function integrateFederation( } function fromERequest(req: ERequest): Request { - const url = `${req.protocol}://${ - req.hostname ?? req.header("Host") - }${req.url}`; + const url = `${req.protocol}://${req.host ?? req.header("Host")}${req.url}`; const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (Array.isArray(value)) { From 35d0ccdad3d622dd52ab0ee8bec29d7c656209f8 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Feb 2026 22:21:01 +0000 Subject: [PATCH 6/9] Apply `fromFileUrl` --- examples/test-examples/mod.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/test-examples/mod.ts b/examples/test-examples/mod.ts index 58268e4fc..a4336dce2 100644 --- a/examples/test-examples/mod.ts +++ b/examples/test-examples/mod.ts @@ -26,8 +26,8 @@ import { fromFileUrl, join } from "@std/path"; // ─── Paths ──────────────────────────────────────────────────────────────────── -const EXAMPLES_DIR = new URL("../", import.meta.url).pathname; -const REPO_ROOT = new URL("../../", import.meta.url).pathname; +const EXAMPLES_DIR = fromFileUrl(new URL("../", import.meta.url)); +const REPO_ROOT = fromFileUrl(new URL("../../", import.meta.url)); // ─── Logging ────────────────────────────────────────────────────────────────── // From ff12d8c5b66236618b980863202e8d29afbe59f3 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Feb 2026 22:21:38 +0000 Subject: [PATCH 7/9] Fix comment --- examples/test-examples/mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/test-examples/mod.ts b/examples/test-examples/mod.ts index a4336dce2..9719d1ff9 100644 --- a/examples/test-examples/mod.ts +++ b/examples/test-examples/mod.ts @@ -390,7 +390,7 @@ async function forceKillProc(proc: Deno.ChildProcess): Promise { // ─── Tunnel ─────────────────────────────────────────────────────────────────── /** - * Starts `fedify tunnel -s localhost.run ` and waits up to `timeoutMs` + * Starts `fedify tunnel -s pinggy.io ` and waits up to `timeoutMs` * for the tunnel URL to appear in its output. The tunnel process is kept * alive and returned to the caller; it must be killed when no longer needed. * From 8f09a4f8d9a30c78fbdcbc9c5ee4371b8e003d3b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Feb 2026 23:00:06 +0000 Subject: [PATCH 8/9] Apply dax --- examples/test-examples/mod.ts | 215 ++++++++++++++++------------------ 1 file changed, 98 insertions(+), 117 deletions(-) diff --git a/examples/test-examples/mod.ts b/examples/test-examples/mod.ts index 9719d1ff9..72ef70c7b 100644 --- a/examples/test-examples/mod.ts +++ b/examples/test-examples/mod.ts @@ -21,6 +21,7 @@ * deno run --allow-all examples/test-examples/mod.ts --debug hono-sample */ +import $, { type CommandChild } from "@david/dax"; import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; import { fromFileUrl, join } from "@std/path"; @@ -286,36 +287,29 @@ const c = { // ─── Utilities ──────────────────────────────────────────────────────────────── -function decodeChunks(chunks: Uint8Array[]): string { - let total = 0; - for (const chunk of chunks) total += chunk.length; - const buf = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - buf.set(chunk, offset); - offset += chunk.length; - } - return new TextDecoder().decode(buf); -} - /** * Drains a ReadableStream into `chunks` in the background, logging each chunk * at DEBUG level so that raw server/tunnel output is visible with --debug. */ function drainLogging( stream: ReadableStream, - chunks: Uint8Array[], + chunks: string[], streamLogger: ReturnType, ): void { (async () => { const reader = stream.getReader(); const decoder = new TextDecoder(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - const text = decoder.decode(value).trim(); - if (text) streamLogger.debug("{output}", { output: text }); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + chunks.push(text); + const trimmed = text.trim(); + if (trimmed) streamLogger.debug("{output}", { output: trimmed }); + } + } catch { + // Stream may error when the process is killed. } })(); } @@ -352,22 +346,17 @@ async function waitForServer(url: string, timeoutMs: number): Promise { async function killPortUsers(port: number): Promise { let pids: string[]; try { - const result = await new Deno.Command("lsof", { - args: ["-ti", `:${port}`, "-sTCP:LISTEN"], - stdout: "piped", - stderr: "null", - }).output(); - pids = new TextDecoder() - .decode(result.stdout) - .trim() - .split("\n") - .filter(Boolean); + const text = await $`lsof -ti ${`:${port}`} -sTCP:LISTEN` + .stderr("null") + .noThrow() + .text(); + pids = text.trim().split("\n").filter(Boolean); } catch { return; // lsof unavailable or no match — nothing to do. } for (const pid of pids) { try { - await new Deno.Command("kill", { args: ["-9", pid] }).output(); + await $`kill -9 ${pid}`.quiet("both").noThrow(); logger.debug("Force-killed PID {pid} on port {port}", { pid, port }); } catch { // Already gone. @@ -376,15 +365,20 @@ async function killPortUsers(port: number): Promise { } /** - * Sends SIGKILL to `proc` immediately and awaits its exit status. + * Sends SIGKILL to `child` immediately. A rejection handler is attached to + * the CommandChild promise (which extends Promise) so that the + * eventual rejection from the killed process does not surface as an unhandled + * promise rejection. We intentionally do **not** await the promise because + * dax keeps it pending until all piped streams are fully consumed, which may + * never happen once the process is forcibly killed. */ -async function forceKillProc(proc: Deno.ChildProcess): Promise { +function forceKillChild(child: CommandChild): void { + child.catch(() => {}); try { - proc.kill("SIGKILL"); + child.kill("SIGKILL"); } catch { // Process already exited. } - await proc.status.catch(() => {}); } // ─── Tunnel ─────────────────────────────────────────────────────────────────── @@ -399,16 +393,16 @@ async function forceKillProc(proc: Deno.ChildProcess): Promise { async function startTunnel( port: number, timeoutMs: number, -): Promise<{ proc: Deno.ChildProcess; url: string } | null> { +): Promise<{ child: CommandChild; url: string } | null> { const tunnelLogger = getLogger(["fedify", "examples", "tunnel"]); tunnelLogger.info("Opening localhost.run tunnel on port {port}", { port }); - const proc = new Deno.Command("deno", { - args: ["task", "cli", "tunnel", "-s", "pinggy.io", String(port)], - cwd: REPO_ROOT, - stdout: "piped", - stderr: "piped", - }).spawn(); + const child = $`deno task cli tunnel -s pinggy.io ${String(port)}` + .cwd(REPO_ROOT) + .stdout("piped") + .stderr("piped") + .noThrow() + .spawn(); // Accumulate text from both streams while logging each chunk at DEBUG. const textChunks: string[] = []; @@ -417,19 +411,23 @@ async function startTunnel( const readStream = (stream: ReadableStream) => { (async () => { const reader = stream.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const text = decoder.decode(value, { stream: true }); - textChunks.push(text); - const trimmed = text.trim(); - if (trimmed) tunnelLogger.debug("{output}", { output: trimmed }); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + textChunks.push(text); + const trimmed = text.trim(); + if (trimmed) tunnelLogger.debug("{output}", { output: trimmed }); + } + } catch { + // Stream may error when the process is killed. } })(); }; - readStream(proc.stdout); - readStream(proc.stderr); + readStream(child.stdout()); + readStream(child.stderr()); // Poll until we find an https URL in the accumulated output. // The `message` template tag from @optique/run may wrap the URL in double @@ -439,7 +437,7 @@ async function startTunnel( const match = textChunks.join("").match(/https:\/\/[^\s"']+/); if (match) { tunnelLogger.info("Tunnel established at {url}", { url: match[0] }); - return { proc, url: match[0] }; + return { child, url: match[0] }; } await new Promise((r) => setTimeout(r, 200)); } @@ -448,7 +446,7 @@ async function startTunnel( "Tunnel did not produce a URL within {timeout} ms", { timeout: timeoutMs }, ); - await forceKillProc(proc); + forceKillChild(child); return null; } @@ -482,30 +480,25 @@ async function testServerExample( console.log(c.dim(` cwd : ${buildCwd}`)); serverLogger.info("Building {name}", { name, cmd: buildCmd.join(" ") }); - let buildResult: Deno.CommandOutput; + let buildResult; try { - buildResult = await new Deno.Command(buildCmd[0], { - args: buildCmd.slice(1), - cwd: buildCwd, - stdout: "piped", - stderr: "piped", - }).output(); + buildResult = await $`${buildCmd}` + .cwd(buildCwd) + .stdout("piped") + .stderr("piped") + .noThrow(); } catch (e) { - if (e instanceof Deno.errors.NotFound) { - const error = `Build command not found: ${buildCmd[0]}`; - serverLogger.error("{error}", { error }); - return { name, status: "fail", error, output: "" }; - } - throw e; + const error = `Build command not found: ${buildCmd[0]}`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: String(e) }; } - const buildOutput = new TextDecoder().decode(buildResult.stdout) + - new TextDecoder().decode(buildResult.stderr); + const buildOutput = buildResult.stdout + buildResult.stderr; serverLogger.debug("Build output:\n{output}", { output: buildOutput.trim(), }); - if (!buildResult.success) { + if (buildResult.code !== 0) { const error = `Build failed with exit code ${buildResult.code}`; serverLogger.error("{error}", { error }); return { name, status: "fail", error, output: buildOutput }; @@ -525,33 +518,27 @@ async function testServerExample( serverLogger.info("Starting {name}", { name, cmd: startCmd.join(" "), cwd }); - let serverProc: Deno.ChildProcess; + let serverChild: CommandChild; try { - serverProc = new Deno.Command(startCmd[0], { - args: startCmd.slice(1), - cwd: cwd, - stdout: "piped", - stderr: "piped", - env: env ? { ...Deno.env.toObject(), ...env } : undefined, - }).spawn(); + let cmd = $`${startCmd}`.cwd(cwd).stdout("piped").stderr("piped") + .noThrow(); + if (env) cmd = cmd.env(env); + serverChild = cmd.spawn(); } catch (e) { - if (e instanceof Deno.errors.NotFound) { - const error = `Command not found: ${startCmd[0]}`; - serverLogger.error("{error}", { error }); - return { name, status: "fail", error, output: "" }; - } - throw e; + const error = `Command not found: ${startCmd[0]}`; + serverLogger.error("{error}", { error }); + return { name, status: "fail", error, output: String(e) }; } - const stdoutChunks: Uint8Array[] = []; - const stderrChunks: Uint8Array[] = []; - drainLogging(serverProc.stdout, stdoutChunks, serverLogger); - drainLogging(serverProc.stderr, stderrChunks, serverLogger); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + drainLogging(serverChild.stdout(), stdoutChunks, serverLogger); + drainLogging(serverChild.stderr(), stderrChunks, serverLogger); const collectServerOutput = () => - decodeChunks(stdoutChunks) + decodeChunks(stderrChunks); + stdoutChunks.join("") + stderrChunks.join(""); - let tunnelProc: Deno.ChildProcess | null = null; + let tunnelChild: CommandChild | null = null; try { console.log( @@ -576,12 +563,12 @@ async function testServerExample( const tunnel = await startTunnel(port, 30_000); if (tunnel == null) { - const error = "fedify tunnel did not produce a URL within 5 minutes"; + const error = "fedify tunnel did not produce a URL within 30s"; serverLogger.error("{error}", { error }); return { name, status: "fail", error, output: collectServerOutput() }; } - tunnelProc = tunnel.proc; + tunnelChild = tunnel.child; const tunnelHostname = new URL(tunnel.url).hostname; const handle = `@${actor}@${tunnelHostname}`; @@ -589,15 +576,13 @@ async function testServerExample( console.log(c.dim(` running : fedify lookup ${handle} -d`)); serverLogger.info("Running fedify lookup {handle}", { handle }); - const lookup = await new Deno.Command("deno", { - args: ["task", "cli", "lookup", handle, "-d"], - cwd: REPO_ROOT, - stdout: "piped", - stderr: "piped", - }).output(); + const lookup = await $`deno task cli lookup ${handle} -d` + .cwd(REPO_ROOT) + .stdout("piped") + .stderr("piped") + .noThrow(); - const lookupOutput = new TextDecoder().decode(lookup.stdout) + - new TextDecoder().decode(lookup.stderr); + const lookupOutput = lookup.stdout + lookup.stderr; serverLogger.debug("Lookup output:\n{output}", { output: lookupOutput.trim(), }); @@ -611,12 +596,12 @@ async function testServerExample( return { name, status: "fail", error, output: lookupOutput }; } finally { // Force-kill tunnel first (it holds a connection to the server). - if (tunnelProc != null) { + if (tunnelChild != null) { serverLogger.debug("Force-killing tunnel process"); - await forceKillProc(tunnelProc); + forceKillChild(tunnelChild); } serverLogger.debug("Force-killing server process"); - await forceKillProc(serverProc); + forceKillChild(serverChild); // Kill any lingering processes still bound to the port. serverLogger.debug("Killing remaining processes on port {port}", { port }); @@ -636,15 +621,13 @@ async function testScriptExample(example: ScriptExample): Promise { cmd: cmd.join(" "), }); - const result = await new Deno.Command(cmd[0], { - args: cmd.slice(1), - cwd, - stdout: "piped", - stderr: "piped", - }).output(); + const result = await $`${cmd}` + .cwd(cwd) + .stdout("piped") + .stderr("piped") + .noThrow(); - const output = new TextDecoder().decode(result.stdout) + - new TextDecoder().decode(result.stderr); + const output = result.stdout + result.stderr; scriptLogger.debug("Script output:\n{output}", { output: output.trim() }); if (result.code === 0) { @@ -675,15 +658,13 @@ async function testMultiHandleExample( console.log(c.dim(` cmd : ${fullCmd.join(" ")}`)); scriptLogger.info("Trying handle {handle}", { handle }); - const result = await new Deno.Command(fullCmd[0], { - args: fullCmd.slice(1), - cwd, - stdout: "piped", - stderr: "piped", - }).output(); + const result = await $`${fullCmd}` + .cwd(cwd) + .stdout("piped") + .stderr("piped") + .noThrow(); - const output = new TextDecoder().decode(result.stdout) + - new TextDecoder().decode(result.stderr); + const output = result.stdout + result.stderr; scriptLogger.debug("Output:\n{output}", { output: output.trim() }); lastOutput = output; From 5874a854f5293cce8ed4206b6f801a4644c86086 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 19 Feb 2026 23:30:09 +0000 Subject: [PATCH 9/9] Warn if untested examples exists --- examples/test-examples/mod.ts | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/examples/test-examples/mod.ts b/examples/test-examples/mod.ts index 72ef70c7b..34d1fb7a2 100644 --- a/examples/test-examples/mod.ts +++ b/examples/test-examples/mod.ts @@ -776,6 +776,26 @@ async function main(): Promise { } } + // ── Unregistered examples ──────────────────────────────────────────────── + // Collect every example name that appears in any registry. + const registeredNames = new Set([ + ...SERVER_EXAMPLES.map((e) => e.name), + ...SCRIPT_EXAMPLES.map((e) => e.name), + ...MULTI_HANDLE_EXAMPLES.map((e) => e.name), + ...SKIPPED_EXAMPLES.map((e) => e.name), + ]); + + // Scan the examples/ directory for sub-directories that are not registered. + const unregistered: string[] = []; + for await (const entry of Deno.readDir(EXAMPLES_DIR)) { + if (!entry.isDirectory) continue; + // The test-examples directory is the test runner itself — skip it. + if (entry.name === "test-examples") continue; + if (!registeredNames.has(entry.name)) { + unregistered.push(entry.name); + } + } + // ── Summary ────────────────────────────────────────────────────────────── const passed = results.filter((r) => r.status === "pass"); const failed = results.filter((r) => r.status === "fail"); @@ -786,6 +806,22 @@ async function main(): Promise { console.log(c.red(` ✗ Failed : ${failed.length}`)); console.log(c.yellow(` ⊘ Skipped: ${skipped.length}`)); + if (unregistered.length > 0) { + unregistered.sort(); + console.log( + c.yellow( + `\n ⚠ ${unregistered.length} example(s) not registered in test runner:`, + ), + ); + for (const name of unregistered) { + console.log(c.yellow(` • ${name}`)); + } + logger.warn( + "{count} unregistered example(s): {names}", + { count: unregistered.length, names: unregistered.join(", ") }, + ); + } + logger.info("Test run complete", { passed: passed.length, failed: failed.length, @@ -803,6 +839,8 @@ async function main(): Promise { } Deno.exit(1); } + + Deno.exit(0); } await main();