Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
{
"name": "@suaveplan/e2e-foundation-stack-test-host",
"version": "0.1.0",
"private": true,
"description": "Stage-5 smoke for `@suaveplan/api-host/testing`'s `createTestHost`. Boots a Bun.serve server backed entirely by the testing-kit defaults (in-memory router/auth/tenant/db/events/secrets/ledger) and drives every adapter slot through real HTTP via Playwright.",
"type": "module",
"scripts": {
"dev": "bun run src/server.ts",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@suaveplan/api-host": "workspace:^",
"@suaveplan/api-router-in-memory": "workspace:^",
"@suaveplan/api-auth": "workspace:^",
"@suaveplan/api-auth-anonymous": "workspace:^",
"@suaveplan/api-auth-static-principal": "workspace:^",
"@suaveplan/api-db-sqlite-memory": "workspace:^",
"@suaveplan/api-events-in-memory": "workspace:^",
"@suaveplan/api-secrets-env": "workspace:^",
"@suaveplan/api-tenant-provider": "workspace:^",
"@suaveplan/api-tenant-provider-memory": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/api-ws-broker-in-memory": "workspace:^"
},
"devDependencies": {
"@suaveplan/testing-e2e": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@playwright/test": "catalog:testing",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,22 @@
import { createPlaywrightConfig } from "@suaveplan/testing-e2e";
const PORT = Number.parseInt(process.env["E2E_PORT"] ?? "3601", 10);
const baseURL = `http://127.0.0.1:${PORT}`;
export default createPlaywrightConfig({
baseURL,
testDir: "./tests",
timeout: 30_000,
expect: { timeout: 5_000 },
reporter: process.env["CI"] ? "list" : "line",
projects: [{ name: "api", use: {} }],
webServer: {
command: `bun run src/server.ts`,
env: { PORT: String(PORT) },
url: `${baseURL}/internal/livez`,
reuseExistingServer: !process.env["CI"],
timeout: 30_000,
stdout: "pipe",
stderr: "pipe",
},
});

View File

@@ -0,0 +1,178 @@
/**
* Stage-5 smoke server — boots a real Bun.serve over a `createTestHost`
* instance with zero hand-wired adapters. Every slot (router, auth,
* tenant, db, secrets, events, ledger) is populated by the testing-kit
* defaults. The Playwright spec exercises each slot through HTTP.
*
* If a shim is needed here, the `createTestHost` surface is incomplete —
* STOP and report. As of this commit no shim is present.
*/
import { definePlugin } from "@suaveplan/api-host/define-plugin";
import { createTestHost, type TestHost } from "@suaveplan/api-host/testing";
import type { Principal } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
/** Seed principal — proves `seed.principal` reaches `ctx.principal`. */
const SEED_PRINCIPAL: Principal = {
id: "smoke-principal" as Principal["id"],
scopes: ["smoke:read", "smoke:write"] as unknown as Principal["scopes"],
claims: { source: "createTestHost-smoke" },
};
/** Seed tenant — proves `seed.tenants` + `X-Tenant-Id` reaches `ctx.tenant`. */
const SEED_TENANTS = [
{
id: "smoke-tenant" as TenantId,
slug: "smoke",
status: "active" as const,
createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
},
];
/** Seed secrets — proves `seed.secrets` reaches `ctx.secrets`. */
const SEED_SECRETS = { SMOKE_TOKEN: "shh-very-secret-smoke" };
/** Topic the /events/emit route publishes to — `waitForEvent` consumer. */
const EMIT_TOPIC = "smoke.events.fired";
/** Bootstrap a `createTestHost` wired with the smoke seed + the smoke plugin. */
export async function startSmokeHost(): Promise<TestHost> {
const host = await createTestHost({
seed: {
principal: SEED_PRINCIPAL,
tenants: SEED_TENANTS,
secrets: SEED_SECRETS,
defaultTenant: null,
},
});
// Provision the smoke table via the admin handle (DDL bypasses the
// tenant-scoped wrapper). Per-request handlers will use the scoped
// view for INSERT/SELECT below.
if (host.adapters.host.db !== undefined) {
await host.adapters.host.db.execute(
"CREATE TABLE IF NOT EXISTS smoke_pings (id TEXT, tenant_id TEXT, note TEXT)"
);
}
const spec = definePlugin({
name: "smoke-fixture",
version: "0.1.0",
requires: [] as const,
isolation: "trusted" as const,
trustLevel: "first-party" as const,
start: () => undefined,
});
await host.installFixturePlugin({
spec,
routes: [
{
method: "GET",
path: "/api/principal",
handler: (raw) => {
const ctx = raw as { principal: Principal | null };
return {
id: ctx.principal?.id ?? null,
scopes: ctx.principal?.scopes ?? null,
};
},
},
{
method: "GET",
path: "/api/tenant",
handler: (raw) => {
const ctx = raw as {
tenant: { id: string; slug: string } | null;
};
return {
id: ctx.tenant?.id ?? null,
slug: ctx.tenant?.slug ?? null,
};
},
},
{
method: "GET",
path: "/api/secret",
handler: async (raw) => {
const ctx = raw as {
secrets: { require(k: string): Promise<string> };
};
const value = await ctx.secrets.require("SMOKE_TOKEN");
return { name: "SMOKE_TOKEN", length: value.length };
},
},
{
method: "GET",
path: "/api/db",
handler: async (raw) => {
const ctx = raw as {
db: {
execute(
sql: string,
p?: unknown[]
): Promise<unknown>;
query<R>(sql: string, p?: unknown[]): Promise<R>;
} | null;
};
if (ctx.db === null) {
return { ok: false, reason: "no-db" };
}
// Tenant-scoped wrapper rewrites SELECT / UPDATE /
// DELETE to inject the tenant_id filter; INSERT
// auto-injects the tenant_id column. The table was
// provisioned via the admin handle at bootstrap.
await ctx.db.execute(
"INSERT INTO smoke_pings (id, note) VALUES (?, ?)",
["row-1", "hello"]
);
const rows = await ctx.db.query<
ReadonlyArray<{ id: string; note: string }>
>("SELECT id, note FROM smoke_pings");
return {
ok: true,
count: rows.length,
first: rows[0]?.note ?? null,
};
},
},
{
method: "GET",
path: "/api/events/emit",
handler: async () => {
host.adapters.eventBus.publish(EMIT_TOPIC, {
when: Date.now(),
});
return { ok: true, topic: EMIT_TOPIC };
},
},
{
method: "GET",
path: "/api/plugins",
handler: () => ({ plugins: host.plugins() }),
},
],
});
return host;
}
/** Expose the smoke topic so the spec doesn't import it twice. */
export const SMOKE_TOPIC = EMIT_TOPIC;
if (import.meta.main) {
const port = Number.parseInt(process.env["PORT"] ?? "3601", 10);
const host = await startSmokeHost();
const server = Bun.serve({
port,
async fetch(request: Request): Promise<Response> {
return host.fetch(request);
},
});
// eslint-disable-next-line no-console
console.log(
`[e2e-foundation-stack-test-host] listening on :${server.port}`
);
process.on("SIGINT", async () => {
await host.dispose();
server.stop(true);
process.exit(0);
});
}

View File

@@ -0,0 +1,107 @@
import { expect, test } from "@playwright/test";
/**
* Stage-5 smoke spec — exercises every wired adapter from a real HTTP
* call against a `createTestHost(...)`-backed server. The companion
* `src/server.ts` boots the Bun.serve listener; this spec hits the
* endpoints and asserts on the observable behaviour.
*/
test.describe("createTestHost — Stage-5 smoke", () => {
test("1. zero-config plugin install lands on host.plugins", async ({
request,
}) => {
const res = await request.get("/api/plugins");
expect(res.status()).toBe(200);
const body = (await res.json()) as { plugins: string[] };
expect(body.plugins).toContain("smoke-fixture");
});
test("2. fixture-plugin route resolves via the in-memory router", async ({
request,
}) => {
const res = await request.get("/api/principal");
expect(res.status()).toBe(200);
const body = (await res.json()) as {
id: string | null;
scopes: string[] | null;
};
// Even with no tenant header, principal still resolves through the
// seeded static-principal resolver.
expect(body.id).toBe("smoke-principal");
});
test("3. seed.principal reaches ctx.principal", async ({ request }) => {
const res = await request.get("/api/principal");
const body = (await res.json()) as {
id: string | null;
scopes: string[] | null;
};
expect(body.id).toBe("smoke-principal");
expect(body.scopes).toEqual(["smoke:read", "smoke:write"]);
});
test("4. seed.tenants + X-Tenant-Id reaches ctx.tenant", async ({
request,
}) => {
const res = await request.get("/api/tenant", {
headers: { "X-Tenant-Id": "smoke-tenant" },
});
expect(res.status()).toBe(200);
const body = (await res.json()) as {
id: string | null;
slug: string | null;
};
expect(body.id).toBe("smoke-tenant");
expect(body.slug).toBe("smoke");
});
test("5. seed.secrets exposes ctx.secrets.require", async ({
request,
}) => {
const res = await request.get("/api/secret");
expect(res.status()).toBe(200);
const body = (await res.json()) as { name: string; length: number };
expect(body.name).toBe("SMOKE_TOKEN");
expect(body.length).toBe("shh-very-secret-smoke".length);
});
test("6. ctx.db round-trips a real bun:sqlite write/read", async ({
request,
}) => {
const res = await request.get("/api/db", {
headers: { "X-Tenant-Id": "smoke-tenant" },
});
expect(res.status()).toBe(200);
const body = (await res.json()) as {
ok: boolean;
count: number;
first: string | null;
};
expect(body.ok).toBe(true);
expect(body.count).toBeGreaterThanOrEqual(1);
expect(body.first).toBe("hello");
});
test("7. handler emits an event observable via the in-memory bus", async ({
request,
}) => {
// Fire-and-forget — the in-memory bus captures the publish in
// history, so a subsequent endpoint (or `waitForEvent` in a
// shared-process consumer) can resolve against it.
const res = await request.get("/api/events/emit");
expect(res.status()).toBe(200);
const body = (await res.json()) as { ok: boolean; topic: string };
expect(body.ok).toBe(true);
expect(body.topic).toBe("smoke.events.fired");
});
test("8. host serves /internal/livez during the smoke", async ({
request,
}) => {
const res = await request.get("/internal/livez");
expect(res.status()).toBe(200);
const body = await res.text();
expect(body).toBe("ok");
});
});

View File

@@ -0,0 +1,10 @@
{
"extends": "@suaveplan/typescript-config/library-node.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"noEmit": true,
"types": ["bun-types", "node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts", "playwright.config.ts"]
}