Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7054c79
init e2e app
20jasper Jan 24, 2026
4e3aeb9
update tsconfig
20jasper Jan 24, 2026
184b3f0
initial smoke test
20jasper Jan 24, 2026
30ccbee
type safe hoof client
20jasper Jan 24, 2026
eb4c1fe
Hit all healthcheck endpoints
20jasper Jan 24, 2026
f4725de
expose port from app
20jasper Jan 24, 2026
ec10e38
remove silly path that nx added
20jasper Jan 24, 2026
26c078e
Add agents file
20jasper Jan 24, 2026
dac0141
use nx instead of npm for command orchestration, fix wacky imports, c…
20jasper Jan 24, 2026
927d1aa
avoid server startup side effect when importing api startup
20jasper Jan 24, 2026
71c2915
Only run typegen when the api changes
20jasper Jan 25, 2026
04665d6
update github action and scripts
20jasper Feb 7, 2026
44c4e85
Make knip happy by removing unused deps
20jasper Feb 7, 2026
6c05356
Wait for docker compose up in CI and load env
20jasper Feb 7, 2026
3f610a9
Change to nodenext
20jasper Feb 7, 2026
603be67
Don't swallow error for generate types
20jasper Feb 7, 2026
389ceb1
Move more reused deps to the catalog
20jasper Feb 7, 2026
add75af
Formatting
20jasper Feb 7, 2026
0512a2a
Upgrade to node 24
20jasper Feb 7, 2026
8147267
update lockfile
20jasper Feb 7, 2026
5da33e9
remove unused package
20jasper Feb 7, 2026
6df7d77
Removed unused generated files and libraries. Upgrade nx version to s…
20jasper Feb 7, 2026
9b82d3f
migrate away from deprecated vite nx test runner
20jasper Feb 7, 2026
b465c52
format
20jasper Feb 7, 2026
e4cc443
Merge branch 'main' into fork-20jasper/e2e-init
fennifith Feb 11, 2026
1e03bf5
update devcontainer node version to 24
fennifith Feb 11, 2026
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
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"workspaceFolder": "/var/app",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "22.15.0"
"version": "24"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
Expand Down
31 changes: 29 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ on:
- checks_requested

jobs:
test:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
Expand All @@ -33,7 +33,34 @@ jobs:
run: |
cp .env.example .env
source .env
pnpm run test
pnpm run test:unit

- name: Check formatting
run: pnpm run prettier

e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Install pnpm
uses: pnpm/action-setup@v4

- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: "pnpm"

- name: Install dependencies
run: pnpm install --ignore-scripts

- name: Set up environment
run: |
cp .env.example .env

- name: Start Docker services
run: docker compose up -d --wait

- name: Run E2E tests
run: pnpm run test:e2e
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@
.nx/workspace-data
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md

vite.config.*.timestamp*
vitest.config.*.timestamp*

# Generated files
apps/e2e/src/generated/
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v22.15.0
v24
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->

# General Guidelines for working with Nx

- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
- You have access to the Nx MCP server and its tools, use them to help the user
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.

<!-- nx configuration end-->
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.7-labs
FROM node:22-alpine3.22 AS base
FROM node:24-alpine3.22 AS base

# Install postgres client dependencies
RUN apk --update add make g++ python3 libpq libpq-dev parallel
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/createApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import rateLimit from "./plugins/rate-limit/index.ts";
import sensible from "./plugins/sensible.ts";
import swagger from "./plugins/swagger.ts";
import { healthRoutes } from "./routes/health.ts";
import postImagesRoutes from "./routes/tasks/post-images.ts";
import urlMetadataRoutes from "./routes/tasks/url-metadata.ts";
import fastify from "fastify";

export const createApp = () => {
const app = fastify({
logger: true,
});

app.register(rateLimit);
app.register(sensible);
app.register(swagger);
app.register(healthRoutes);
app.register(postImagesRoutes);
app.register(urlMetadataRoutes);

return app;
};
21 changes: 2 additions & 19 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,6 @@
import { env } from "@playfulprogramming/common";
import rateLimit from "./plugins/rate-limit/index.ts";
import sensible from "./plugins/sensible.ts";
import swagger from "./plugins/swagger.ts";
import { healthRoutes } from "./routes/health.ts";
import postImagesRoutes from "./routes/tasks/post-images.ts";
import urlMetadataRoutes from "./routes/tasks/url-metadata.ts";
import fastify from "fastify";
import { createApp } from "./createApp.ts";

const app = fastify({
logger: true,
});

app.register(rateLimit);
app.register(sensible);
app.register(swagger);
app.register(healthRoutes);
app.register(postImagesRoutes);
app.register(urlMetadataRoutes);

app.listen({ port: env.PORT, host: "0.0.0.0" }, (err) => {
createApp().listen({ port: env.PORT, host: "0.0.0.0" }, (err) => {
if (err) throw err;
});
22 changes: 22 additions & 0 deletions apps/e2e/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import baseConfig from "../../eslint.config.mjs";

export default [
...baseConfig,
{
files: ["**/*.json"],
rules: {
"@nx/dependency-checks": [
"error",
{
ignoredFiles: [
"{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}",
"{projectRoot}/vite.config.{js,ts,mjs,mts}",
],
},
],
},
languageOptions: {
parser: await import("jsonc-eslint-parser"),
},
},
];
18 changes: 18 additions & 0 deletions apps/e2e/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "e2e",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"typegen": "node --experimental-strip-types scripts/typegen.ts",
"test": "vitest run"
},
"dependencies": {
"@playfulprogramming/api": "workspace:*",
"openapi-fetch": "catalog:"
},
"devDependencies": {
"openapi-typescript": "catalog:",
"vitest": "catalog:"
}
}
34 changes: 34 additions & 0 deletions apps/e2e/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/e2e/src",
"projectType": "application",
"tags": [],
"targets": {
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "eslint ./src",
"cwd": "apps/e2e"
}
},
"typegen": {
"executor": "nx:run-commands",
"options": {
"command": "node --experimental-strip-types scripts/typegen.ts",
"cwd": "apps/e2e"
},
"outputs": ["{projectRoot}/src/generated/api-schema.d.ts"],
"cache": true,
"inputs": ["{workspaceRoot}/apps/api/**/*"]
},
"test": {
"executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../coverage/apps/e2e"
},
"dependsOn": ["typegen"]
}
}
}
54 changes: 54 additions & 0 deletions apps/e2e/scripts/typegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import { execSync } from "child_process";
import { spawnApp } from "../src/lib/spawn-app.ts";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const outputDir = path.join(__dirname, "../src/generated");
const outputFile = path.join(outputDir, "api-schema.d.ts");

export default async function generateTypes() {
await fs.mkdir(outputDir, { recursive: true });

await using app = await spawnApp();
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Node.js 22 explicit resource management await using support version

💡 Result:

Node.js 22.x does not support the Explicit Resource Management syntax (using / await using) because Node 22 ships with V8 12.4. [1]

Support arrives with Node.js 24.0.0 (released May 6, 2025), which upgrades to V8 13.6 and lists “Explicit resource management” among the newly supported JavaScript features—this is the first Node line where await using is available natively. [2][3]

Sources: [1] [2] [3]


🌐 Web query:

Node.js Symbol.asyncDispose support which version

💡 Result:

Symbol.asyncDispose landed in Node.js v20.4.0 (initial Symbol.dispose / Symbol.asyncDispose support was added there). [1] Core APIs then started exposing [Symbol.asyncDispose]() methods (for example https.Server[Symbol.asyncDispose]() is marked “Added in: v20.4.0”). [2]

So: Node.js v20.4.0+.

Sources: [1] [2]


🌐 Web query:

Node.js --experimental-strip-types flag purpose and capabilities

💡 Result:

--experimental-strip-types enables Node.js to execute .ts files by “erasing” (stripping) TypeScript type syntax at runtime, turning the file into valid JavaScript without doing full TypeScript compilation. It was added in Node v22.6.0. [1][2]

What it does

  • Strips inline type annotations (and related erasable-only TS syntax) by replacing them with whitespace. [1]
  • Performs no type-checking (you still need tsc/a typechecker for that). [1]
  • Is designed to be lightweight and avoid needing source maps, because it doesn’t do codegen-heavy transforms. [1]

What it does not do / key limits

  • No TS-to-JS “transform” features that require runtime code generation (commonly called “non-erasable” syntax), e.g. enum and namespace/module with runtime code; those require --experimental-transform-types. [1][3]
  • Ignores tsconfig.json-dependent behaviors like path mapping (paths) or downleveling newer JS syntax. [1]
  • Early docs/release notes also call out operational constraints like explicit file extensions in imports/requires, requiring type-only imports to be marked with type, and being disabled for TypeScript inside node_modules by default (at least at introduction). [2]

Related flags / current behavior

  • --experimental-transform-types extends support to TypeScript syntax that needs transformation/codegen (e.g., enums/namespaces). [1]
  • Some Node releases expose --no-experimental-strip-types to disable running TypeScript via stripping. [4]

Sources: Node.js TypeScript docs and release notes; TypeScript team notes on “erasable” syntax expectations. [1][2][3][4]


🏁 Script executed:

#!/bin/bash
cd apps/e2e/scripts

# Find and read the spawnApp function implementation
rg -A 20 "function spawnApp|const spawnApp|export.*spawnApp" --type ts

Repository: playfulprogramming/hoof

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Check if the file imports or defines spawnApp
cat -n typegen.ts | head -20

Repository: playfulprogramming/hoof

Length of output: 109


🏁 Script executed:

#!/bin/bash
# Find the typegen.ts file
fd -t f "typegen.ts"

Repository: playfulprogramming/hoof

Length of output: 94


🏁 Script executed:

#!/bin/bash
# Search for spawnApp definition anywhere in the codebase
rg -l "spawnApp" --type ts --type js

Repository: playfulprogramming/hoof

Length of output: 151


🏁 Script executed:

#!/bin/bash
cat -n apps/e2e/scripts/typegen.ts | head -30

Repository: playfulprogramming/hoof

Length of output: 1244


🏁 Script executed:

#!/bin/bash
cat -n apps/e2e/src/lib/spawn-app.ts

Repository: playfulprogramming/hoof

Length of output: 1489


await using syntax is not supported by the Node.js runtime (22.x ships with V8 12.4), causing a pipeline SyntaxError.

The await using declaration is a TC39 Explicit Resource Management proposal that only landed in Node.js 24.0.0 (V8 13.6). Since the engines field requires Node >=22.6.0, this code will fail with SyntaxError: Unexpected identifier 'app'. The --experimental-strip-types flag only removes TypeScript type annotations—it does not enable unsupported JavaScript syntax proposals.

Refactor to use a try/finally block with explicit cleanup via Symbol.asyncDispose:

🐛 Proposed fix
-	await using app = await spawnApp();
-	const swaggerUrl = `${app.baseUrl}/openapi.json`;
+	const app = await spawnApp();
+	try {
+		const swaggerUrl = `${app.baseUrl}/openapi.json`;
 
-	const response = await fetch(swaggerUrl);
-	if (!response.ok) {
-		throw new Error(
-			`Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`,
-		);
-	}
-	const spec = await response.json();
+		const response = await fetch(swaggerUrl);
+		if (!response.ok) {
+			throw new Error(
+				`Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`,
+			);
+		}
+		const spec = await response.json();
 
-	console.log("Generating types");
-	const tempSpecFile = path.join(outputDir, ".api-spec.json");
-	try {
-		await fs.writeFile(tempSpecFile, JSON.stringify(spec, null, 0));
-		execSync(
-			`pnpm exec openapi-typescript "${tempSpecFile}" -o "${outputFile}"`,
-			{
-				stdio: "inherit",
-			},
-		);
+		console.log("Generating types");
+		const tempSpecFile = path.join(outputDir, ".api-spec.json");
+		try {
+			await fs.writeFile(tempSpecFile, JSON.stringify(spec, null, 0));
+			execSync(
+				`pnpm exec openapi-typescript "${tempSpecFile}" -o "${outputFile}"`,
+				{
+					stdio: "inherit",
+				},
+			);
+		} finally {
+			await fs.unlink(tempSpecFile);
+		}
 	} finally {
-		await fs.unlink(tempSpecFile);
+		await app[Symbol.asyncDispose]();
 	}
🧰 Tools
🪛 GitHub Actions: Test

[error] 16-16: SyntaxError: Unexpected identifier 'app'.

🤖 Prompt for AI Agents
In `@apps/e2e/scripts/typegen.ts` at line 16, Replace the unsupported `await using
app = await spawnApp();` pattern with explicit async resource management: call
`await spawnApp()` to get `app`, wrap the test logic in a `try` block and
perform cleanup in a `finally` block by invoking the resource's async disposer
(e.g. `await app[Symbol.asyncDispose]()`) or a fallback like `await
app.dispose()` if present; update references to `app` accordingly and ensure
`spawnApp` result is awaited before entering the try so the resource is always
cleaned up even on error.

const swaggerUrl = `${app.baseUrl}/openapi.json`;

const response = await fetch(swaggerUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`,
);
}
const spec = await response.json();

console.log("Generating types");
const tempSpecFile = path.join(outputDir, ".api-spec.json");
try {
await fs.writeFile(tempSpecFile, JSON.stringify(spec, null, 0));
execSync(
`pnpm exec openapi-typescript "${tempSpecFile}" -o "${outputFile}"`,
{
stdio: "inherit",
},
);
} finally {
await fs.unlink(tempSpecFile);
}

console.log(`Types generated successfully at ${outputFile}`);
console.log(
`You may need to restart your TypeScript language server for changes to reflect`,
);
}

if (process.argv[1] === __filename) {
generateTypes()
.then(() => process.exit(0))
.catch((err) => {
console.error("generateTypes failed:", err);
process.exit(1);
});
}
47 changes: 47 additions & 0 deletions apps/e2e/src/lib/spawn-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import createClient, { type Client } from "openapi-fetch";
import type { paths } from "../generated/api-schema.d.ts";
import { createApp } from "@playfulprogramming/api/src/createApp.ts";

type TestApp = {
baseUrl: string;
port: number;
} & AsyncDisposable;

type TestAppWithClient = TestApp & {
client: Client<paths>;
} & AsyncDisposable;

export async function spawnApp(): Promise<TestApp> {
const app = createApp();

await app.listen({ port: 0, host: "127.0.0.1" });

const address = app.server.address();
const port = typeof address === "string" ? address : address?.port;
if (!port) throw new Error("Failed to get server port");

const baseUrl = `http://127.0.0.1:${port}`;
return {
baseUrl,
port: Number(port),
[Symbol.asyncDispose]: async () => {
await app.close();
},
};
}

export async function spawnAppWithClient(): Promise<TestAppWithClient> {
const app = await spawnApp();

const client = createClient<paths>({
baseUrl: app.baseUrl,
});

return {
...app,
client,
[Symbol.asyncDispose]: async () => {
await app[Symbol.asyncDispose]();
},
};
}
17 changes: 17 additions & 0 deletions apps/e2e/src/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import { spawnAppWithClient } from "./lib/spawn-app.ts";

describe("E2E: Health Check", () => {
it.each(["/", "/health/postgres", "/health/redis"] as const)(
"should respond 200 for %s",
async (path) => {
await using app = await spawnAppWithClient();

const res = await app.client.GET(path, {
parseAs: "text",
});
expect(res.response.status).toBe(200);
expect(res.data).toBe("OK");
},
);
});
19 changes: 19 additions & 0 deletions apps/e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"moduleDetection": "force",
"noUncheckedIndexedAccess": true,
"allowJs": true,
"resolveJsonModule": true
},
"include": ["src/**/*", "scripts/**/*"]
}
25 changes: 25 additions & 0 deletions apps/e2e/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
import { nxCopyAssetsPlugin } from "@nx/vite/plugins/nx-copy-assets.plugin";

export default defineConfig(() => ({
root: __dirname,
cacheDir: "../../node_modules/.vite/apps/e2e",
plugins: [nxViteTsPaths(), nxCopyAssetsPlugin(["*.md"])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
name: "e2e",
watch: false,
globals: true,
environment: "node",
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
reporters: ["default"],
coverage: {
reportsDirectory: "../../coverage/apps/e2e",
provider: "v8" as const,
},
},
}));
Loading