Initial Commit
This commit is contained in:
35
e2e/foundation-stack-test-host/package.json
Normal file
35
e2e/foundation-stack-test-host/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
22
e2e/foundation-stack-test-host/playwright.config.ts
Normal file
22
e2e/foundation-stack-test-host/playwright.config.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
178
e2e/foundation-stack-test-host/src/server.ts
Normal file
178
e2e/foundation-stack-test-host/src/server.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
107
e2e/foundation-stack-test-host/tests/test-host.spec.ts
Normal file
107
e2e/foundation-stack-test-host/tests/test-host.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
10
e2e/foundation-stack-test-host/tsconfig.json
Normal file
10
e2e/foundation-stack-test-host/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user