diff --git a/apps/dev-playground/server/smoke.test.ts b/apps/dev-playground/server/smoke.test.ts new file mode 100644 index 000000000..b1915f95c --- /dev/null +++ b/apps/dev-playground/server/smoke.test.ts @@ -0,0 +1,121 @@ +import { createTestApp } from "@databricks/appkit/testing"; +import { describe, expect, test, vi } from "vitest"; + +import { lakebaseExamples } from "./lakebase-examples-plugin"; +import { reconnect } from "./reconnect-plugin"; +import { telemetryExamples } from "./telemetry-example-plugin"; + +/** + * Smoke tests for the playground's own server plugins. + * + * `tests/` holds Playwright specs that fake `/api` responses at the browser + * boundary (`page.route` + `fulfill`), so the Express server never runs there. + * These cover the other side: the plugins boot and answer over real HTTP with + * the Databricks data plane faked by the harness — no workspace, no + * credentials, no network. + */ + +/** + * Read one SSE payload, then hang up. + * + * `expectStream` buffers a source to completion, and the reconnect stream is + * five messages three seconds apart — so asserting through it would cost ~12s + * for a smoke test. + */ +async function firstSSEPayload(res: Response): Promise { + if (!res.body) throw new Error("expected a streaming body, got none"); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + try { + while (!buffered.includes("\n\n")) { + const { value, done } = await reader.read(); + if (done) break; + buffered += decoder.decode(value, { stream: true }); + } + const data = buffered.split("\n").find((line) => line.startsWith("data:")); + return data ? JSON.parse(data.slice("data:".length).trim()) : undefined; + } finally { + await reader.cancel(); + } +} + +describe("dev-playground server plugins", () => { + test("all three boot together and register under their manifest names", async () => { + await using app = await createTestApp({ + plugins: [reconnect(), telemetryExamples(), lakebaseExamples()], + }); + + expect(app.plugins.reconnect).toBeDefined(); + expect(app.plugins["telemetry-examples"]).toBeDefined(); + expect(app.plugins["lakebase-examples"]).toBeDefined(); + }); + + test("GET /api/reconnect answers", async () => { + await using app = await createTestApp({ plugins: [reconnect()] }); + + const res = await app.get("/api/reconnect"); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ message: "Reconnected" }); + }); + + test("the reconnect stream opens as SSE and emits its first message", async () => { + await using app = await createTestApp({ plugins: [reconnect()] }); + + const res = await app.get("/api/reconnect/stream?sessionId=smoke"); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + await expect(firstSSEPayload(res)).resolves.toMatchObject({ + type: "message", + count: 1, + total: 5, + content: "Message 1 of 5", + }); + }); + + test("POST /api/telemetry-examples/combined threads the userId through every span", async () => { + // This route really calls `fetch("https://example.com")` (its + // external-api span). Left alone the suite would need the internet, so + // non-loopback requests are stubbed — loopback must pass through, because + // that is how the harness reaches its own server. + const realFetch = globalThis.fetch; + vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + return /127\.0\.0\.1|localhost/.test(url) + ? realFetch(input, init) + : Promise.resolve(new Response("stubbed", { status: 200 })); + }); + + try { + await using app = await createTestApp({ plugins: [telemetryExamples()] }); + + const res = await app.post("/api/telemetry-examples/combined", { + body: { userId: "smoke-user" }, + }); + + expect(res.status).toBe(200); + // A 200 means the whole nested-span body ran against the real + // TelemetryProvider: tracer, meter, and logger. + await expect(res.json()).resolves.toMatchObject({ + success: true, + result: { userId: "smoke-user" }, + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + test("lakebase-examples degrades to no routes when Lakebase is unconfigured", async () => { + // Its setup() and injectRoutes() both bail on missing PGHOST/LAKEBASE_ENDPOINT. + // The app must still boot; the routes must simply be absent. + await using app = await createTestApp({ + plugins: [lakebaseExamples()], + env: { PGHOST: "", LAKEBASE_ENDPOINT: "" }, + }); + + expect(app.plugins["lakebase-examples"]).toBeDefined(); + expect((await app.get("/api/lakebase-examples/raw")).status).toBe(404); + }); +}); diff --git a/apps/dev-playground/server/testing-kit.integration.test.ts b/apps/dev-playground/server/testing-kit.integration.test.ts new file mode 100644 index 000000000..ccf39a5f2 --- /dev/null +++ b/apps/dev-playground/server/testing-kit.integration.test.ts @@ -0,0 +1,239 @@ +import { ApiError, files, genie } from "@databricks/appkit"; +import { + createApiError, + createMockRequest, + createMockResponse, + createMockWorkspaceClient, + createTestApp, + createTestPlugin, + createTestPluginContext, + expectStream, + getMock, + resetTestCache, + useServiceContextMock, + useTestApp, + useTestCache, + withEnv, +} from "@databricks/appkit/testing"; +import { describe, expect, test, vi } from "vitest"; + +import { reconnect } from "./reconnect-plugin"; + +/** + * One worked example per `@databricks/appkit/testing` helper. `smoke.test.ts` + * covers that the plugins boot and answer; this covers the kit itself. + */ + +// Dummy values: the strict validator only checks the vars are present. +const FILES_ENV = { + DATABRICKS_VOLUME_FILES: "/Volumes/main/default/vol", + DATABRICKS_VOLUME_REPORTS: "/Volumes/main/default/vol", +}; +const filesPlugin = () => + files({ volumes: { reports: { policy: files.policy.allowAll() } } }); + +describe("getMock — assert the workspace-client call a route made", () => { + test("GET /api/files/reports/metadata calls files.getMetadata", async () => { + await using app = await createTestApp({ + plugins: [filesPlugin()], + env: FILES_ENV, + responses: { + "files.getMetadata": { + "content-length": "42", + "content-type": "text/plain", + }, + }, + }); + + const res = await app.get("/api/files/reports/metadata?path=report.csv"); + + expect(res.status).toBe(200); + expect(getMock(app.client, "files.getMetadata")).toHaveBeenCalled(); + }); +}); + +describe("createMockWorkspaceClient — build the fake client yourself", () => { + test("declared paths answer; undeclared ones resolve undefined", async () => { + const client = createMockWorkspaceClient({ + responses: { "jobs.getRun": { state: "TERMINATED" } }, + }); + + await expect(client.jobs.getRun({ run_id: 1 })).resolves.toEqual({ + state: "TERMINATED", + }); + expect(getMock(client, "jobs.getRun")).toHaveBeenCalledWith({ run_id: 1 }); + await expect( + client.genie.getMessage({ id: "m-1" }), + ).resolves.toBeUndefined(); + }); +}); + +describe("createApiError — simulate a typed workspace error", () => { + test("produces a genuine ApiError instance", () => { + const err = createApiError({ + statusCode: 404, + message: "No such directory", + errorCode: "NOT_FOUND", + }); + + expect(err).toBeInstanceOf(ApiError); + expect(err.statusCode).toBe(404); + }); + + test("seeded as a rejection, a route surfaces the failure instead of crashing", async () => { + await using app = await createTestApp({ + plugins: [filesPlugin()], + env: FILES_ENV, + responses: { + "files.getMetadata": () => { + throw createApiError({ + statusCode: 404, + message: "No such file", + errorCode: "NOT_FOUND", + }); + }, + }, + }); + + const res = await app.get("/api/files/reports/metadata?path=report.csv"); + + expect(res.ok).toBe(false); + }); +}); + +describe("useTestApp — a fresh app per test, closed for you", () => { + const app = useTestApp({ plugins: [reconnect()] }); + + test("the first test gets its own app", async () => { + expect((await app.current.get("/api/reconnect")).status).toBe(200); + }); + + test("the second gets a fresh one — no close() to forget", async () => { + await expect( + app.current.get("/api/reconnect").then((r) => r.json()), + ).resolves.toEqual({ message: "Reconnected" }); + }); +}); + +describe("withEnv — set env for a block, restored after", () => { + test("the variable is present inside the block and gone after", async () => { + expect(process.env.DEMO_FLAG).toBeUndefined(); + + await withEnv({ DEMO_FLAG: "on" }, async () => { + expect(process.env.DEMO_FLAG).toBe("on"); + }); + + expect(process.env.DEMO_FLAG).toBeUndefined(); + }); +}); + +describe("useTestCache — assert caching against the real CacheManager", () => { + const cache = useTestCache(); + + test("generateKey is stable for identical inputs and scoped per user", () => { + const parts = ["analytics:query", "top_users"]; + expect(cache.current.generateKey(parts, "")).toBe( + cache.current.generateKey(parts, ""), + ); + expect(cache.current.generateKey(parts, "alice")).not.toBe( + cache.current.generateKey(parts, "bob"), + ); + }); + + test("a second identical call is a hit; resetTestCache forces a miss", async () => { + const work = vi.fn(async () => "value"); + + await cache.current.getOrExecute(["report"], work, ""); + await cache.current.getOrExecute(["report"], work, ""); + expect(work).toHaveBeenCalledTimes(1); // second served from cache + + await resetTestCache(); + await cache.current.getOrExecute(["report"], work, ""); + expect(work).toHaveBeenCalledTimes(2); // cleared, so recomputed + }); +}); + +describe("createTestPlugin — instantiate a factory the way production does", () => { + test("applies the config merge and manifest name", () => { + const plugin = createTestPlugin(genie, { spaces: { demo: "space-test" } }); + + expect(plugin.name).toBe("genie"); + }); +}); + +describe("createTestPluginContext — unit-test wiring with no boot, no socket", () => { + test("dispatches a cross-plugin tool call on-behalf-of the user", async () => { + // No playground plugin dispatches tools hermetically (agents needs a live + // model), so drive the context directly with a faked analytics tool. + const mock = createTestPluginContext({ + analytics: { top_users: (args) => [{ user: "alice", args }] }, + }); + + const req = createMockRequest({ obo: { userId: "analyst@example.com" } }); + const result = await mock.ctx.executeTool(req, "analytics", "top_users", { + limit: 5, + }); + + expect(result).toEqual([{ user: "alice", args: { limit: 5 } }]); + expect(mock.toolCalls[0]).toMatchObject({ + plugin: "analytics", + tool: "top_users", + asUser: true, + userId: "analyst@example.com", + }); + }); + + test("a token-less request rejects on the on-behalf-of path", async () => { + const mock = createTestPluginContext({ + analytics: { top_users: () => [] }, + }); + + await expect( + mock.ctx.executeTool(createMockRequest(), "analytics", "top_users", {}), + ).rejects.toThrow(); + }); +}); + +describe("expectStream — assert the ordered events a stream emits", () => { + // reconnect's real stream is long-lived, so smoke.test.ts reads it by hand; + // expectStream buffers to completion, so demo it on a bounded source. + async function* runReport() { + yield { type: "warehouse_status", state: "RUNNING" }; + yield { type: "row", n: 1 }; + yield { type: "result", rows: 1 }; + } + + test("toEmit matches an in-order subsequence", async () => { + await expectStream(runReport()).toEmit("warehouse_status", "result"); + }); + + test("toEmitExactly matches the full shape, in order", async () => { + await expectStream(runReport()).toEmitExactly( + "warehouse_status", + "row", + "result", + ); + }); + + test("reads an SSE handler's writes back through createMockResponse", async () => { + const res = createMockResponse(); + res.setHeader("content-type", "text/event-stream"); + res.write(`event: status\ndata: ${JSON.stringify({ s: "go" })}\n\n`); + res.write(`event: result\ndata: ${JSON.stringify({ n: 1 })}\n\n`); + res.end(); + + await expectStream(res).toEmit("status", "result"); + }); +}); + +describe("useServiceContextMock — spy the data-plane singleton in one line", () => { + // Spies the ServiceContext singleton directly — the seam beneath the client + // createTestApp injects. + const ctx = useServiceContextMock(); + + test("installs live spies over the service context", () => { + expect(ctx.current.getSpy).toBeDefined(); + expect(ctx.current.createUserContextSpy).toBeDefined(); + expect(ctx.current.createUserContextSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index d466c3a51..38f0c3e06 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -59,6 +59,19 @@ export default defineConfig({ environment: "node", }, }, + { + plugins: [tsconfigPaths()], + test: { + name: "dev-playground", + root: "./apps/dev-playground", + environment: "node", + // tests/ holds Playwright specs. Vitest's default `**/*.spec.ts` + // glob would collect them and they fail on import with "Playwright + // Test did not expect test.describe() to be called here". They run + // via `pnpm test:integration`. + exclude: ["**/node_modules/**", "**/dist/**", "tests/**"], + }, + }, ], }, });