@databricks/appkit 0.75.1 → 0.76.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/dist/appkit/package.js +1 -1
- package/dist/cache/index.d.ts +10 -0
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +13 -0
- package/dist/cache/index.js.map +1 -1
- package/dist/cli/commands/codemod/on-plugins-ready.js +2 -1
- package/dist/cli/commands/codemod/on-plugins-ready.js.map +1 -1
- package/dist/cli/commands/lint.js +2 -1
- package/dist/cli/commands/lint.js.map +1 -1
- package/dist/cli/commands/plugin/add-resource/add-resource.js +1 -1
- package/dist/cli/commands/plugin/add-resource/add-resource.js.map +1 -1
- package/dist/cli/commands/plugin/create/create.js +1 -1
- package/dist/cli/commands/plugin/create/create.js.map +1 -1
- package/dist/cli/commands/plugin/create/prompt-resource.js +1 -1
- package/dist/cli/commands/plugin/create/prompt-resource.js.map +1 -1
- package/dist/cli/commands/plugin/sync/sync.js +2 -1
- package/dist/cli/commands/plugin/sync/sync.js.map +1 -1
- package/dist/cli/commands/registry/env-writer.js +4 -1
- package/dist/cli/commands/registry/env-writer.js.map +1 -1
- package/dist/connectors/sql-warehouse/client.js +13 -1
- package/dist/connectors/sql-warehouse/client.js.map +1 -1
- package/dist/core/appkit.d.ts +1 -1
- package/dist/core/appkit.d.ts.map +1 -1
- package/dist/core/appkit.js +26 -5
- package/dist/core/appkit.js.map +1 -1
- package/dist/core/lifecycle-manager.js +47 -27
- package/dist/core/lifecycle-manager.js.map +1 -1
- package/dist/evals/judge.d.ts.map +1 -1
- package/dist/evals/judge.js +8 -2
- package/dist/evals/judge.js.map +1 -1
- package/dist/plugins/agents/mlflow.js +6 -1
- package/dist/plugins/agents/mlflow.js.map +1 -1
- package/dist/telemetry/telemetry-manager.js +17 -0
- package/dist/telemetry/telemetry-manager.js.map +1 -1
- package/dist/testing/create-test-app.d.ts +111 -0
- package/dist/testing/create-test-app.d.ts.map +1 -0
- package/dist/testing/create-test-app.js +187 -0
- package/dist/testing/create-test-app.js.map +1 -0
- package/dist/testing/create-test-plugin.d.ts +17 -0
- package/dist/testing/create-test-plugin.d.ts.map +1 -0
- package/dist/testing/create-test-plugin.js +22 -0
- package/dist/testing/create-test-plugin.js.map +1 -0
- package/dist/testing/fixtures.d.ts +51 -35
- package/dist/testing/fixtures.d.ts.map +1 -1
- package/dist/testing/fixtures.js +129 -54
- package/dist/testing/fixtures.js.map +1 -1
- package/dist/testing/index.d.ts +8 -3
- package/dist/testing/index.js +7 -2
- package/dist/testing/mock-workspace-client.d.ts +47 -0
- package/dist/testing/mock-workspace-client.d.ts.map +1 -0
- package/dist/testing/mock-workspace-client.js +192 -0
- package/dist/testing/mock-workspace-client.js.map +1 -0
- package/dist/testing/reset-singletons.js +39 -0
- package/dist/testing/reset-singletons.js.map +1 -0
- package/dist/testing/test-app.d.ts +54 -0
- package/dist/testing/test-app.d.ts.map +1 -0
- package/dist/testing/test-app.js +55 -0
- package/dist/testing/test-app.js.map +1 -0
- package/dist/testing/test-cache.d.ts +60 -0
- package/dist/testing/test-cache.d.ts.map +1 -0
- package/dist/testing/test-cache.js +65 -0
- package/dist/testing/test-cache.js.map +1 -0
- package/dist/testing/test-plugin-context.d.ts +43 -2
- package/dist/testing/test-plugin-context.d.ts.map +1 -1
- package/dist/testing/test-plugin-context.js +47 -2
- package/dist/testing/test-plugin-context.js.map +1 -1
- package/docs/api/appkit/Function.createApp.md +9 -9
- package/docs/plugins/agents.md +3 -1
- package/docs/plugins/testing.md +440 -14
- package/llms.txt +1 -1
- package/package.json +11 -3
- package/sbom.cdx.json +1 -1
package/docs/plugins/testing.md
CHANGED
|
@@ -1,22 +1,301 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
-
AppKit ships a testing kit at `@databricks/appkit/testing` so you can test a plugin
|
|
3
|
+
AppKit ships a testing kit at `@databricks/appkit/testing` so you can test a plugin, including its cross-plugin tool calls and streaming responses, without a live Databricks workspace, credentials, or network access. Plugin tests stay fast and run in CI, where no workspace is available.
|
|
4
4
|
|
|
5
5
|
## Goal[](#goal "Direct link to Goal")
|
|
6
6
|
|
|
7
|
-
Exercise a plugin's real code paths
|
|
7
|
+
Exercise a plugin's real code paths against a real `PluginContext` with only its outer edges faked. That covers route registration, cross-plugin tool dispatch, user-scoped (on-behalf-of) execution, and per-call timeouts. Nothing about the context is reimplemented, so a test can't drift from production behavior.
|
|
8
8
|
|
|
9
|
-
The kit has
|
|
9
|
+
The kit has three entry points plus a set of fixture helpers:
|
|
10
10
|
|
|
11
|
-
* **`
|
|
11
|
+
* **`createTestApp({ plugins })`** — boot a real app and call it over real HTTP. Start here.
|
|
12
|
+
* **`createTestPluginContext()`** — build a real `PluginContext` with faked edges and attach it to a plugin, with no boot and no socket.
|
|
12
13
|
* **`expectStream(...).toEmit(...)`** — assert the ordered event types a stream emits.
|
|
13
|
-
* **Fixtures** — `createMockRequest`, `createMockResponse`, `mockServiceContext`, and SQL response builders.
|
|
14
|
+
* **Fixtures** — `createMockRequest`, `createMockResponse`, `createMockWorkspaceClient`, `mockServiceContext`, and SQL response builders.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
`vitest` is an **optional peer dependency**: the kit's mocks use its `vi` and resolve against your installed copy. Apps that never import `@databricks/appkit/testing` don't install it, so production stays free of the test framework. Any Vitest v3 or v4 works.
|
|
17
|
+
|
|
18
|
+
## Testing your plugin[](#testing-your-plugin "Direct link to Testing your plugin")
|
|
19
|
+
|
|
20
|
+
`createTestApp({ plugins })` boots a **real** AppKit app, with the real Express wiring, routes, and resource validation, then hands you methods to call it like a client would:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { createTestApp, expectStream } from "@databricks/appkit/testing";
|
|
24
|
+
|
|
25
|
+
test("my plugin answers a request", async () => {
|
|
26
|
+
const app = await createTestApp({ plugins: [myPlugin()] });
|
|
27
|
+
try {
|
|
28
|
+
const res = await app.post("/api/my-plugin/thing", { body: { q: 1 }, obo: true });
|
|
29
|
+
expect(res.status).toBe(200);
|
|
30
|
+
await expectStream(res).toEmit("status", "result");
|
|
31
|
+
} finally {
|
|
32
|
+
await app.close();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
No workspace, no credentials, no network. The harness pins a non-development `NODE_ENV`, binds an ephemeral port, installs a fake workspace client, and keeps the cache in memory so nothing reaches out.
|
|
39
|
+
|
|
40
|
+
Paths are the full mounted route. A plugin's prefix is `/api/` plus its manifest name in kebab-case, so a plugin named `mySearch` serves at `/api/my-search/…`.
|
|
41
|
+
|
|
42
|
+
### Which harness?[](#which-harness "Direct link to Which harness?")
|
|
43
|
+
|
|
44
|
+
| | `createTestApp` | `createTestPluginContext` |
|
|
45
|
+
| --------------------------------- | --------------------------- | ------------------------------------------ |
|
|
46
|
+
| Boots the app | Yes | No |
|
|
47
|
+
| Binds a socket | Yes (ephemeral port) | No |
|
|
48
|
+
| Express middleware, error handler | Real | Not involved |
|
|
49
|
+
| Resource / env validation | Real, and strict | Not involved |
|
|
50
|
+
| Workspace client | Faked and injected | Fake it yourself with `mockServiceContext` |
|
|
51
|
+
| Needs `close()` | **Yes** | No |
|
|
52
|
+
| Speed | Fast, but pays for a socket | Fastest |
|
|
53
|
+
|
|
54
|
+
Use `createTestApp` for a plugin's HTTP behavior end to end. Use `createTestPluginContext` to unit-test wiring: route registration, tool dispatch, timeout composition. Name harness suites `*.integration.test.ts`, matching the existing convention.
|
|
55
|
+
|
|
56
|
+
### Faking what your plugin reads[](#faking-what-your-plugin-reads "Direct link to Faking what your plugin reads")
|
|
57
|
+
|
|
58
|
+
Declare responses by dotted path — `"<service>.<method>"` on AppKit's workspace-client facade:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const app = await createTestApp({
|
|
62
|
+
plugins: [myPlugin()],
|
|
63
|
+
responses: {
|
|
64
|
+
"jobs.getRun": { state: "TERMINATED", result_state: "SUCCESS" },
|
|
65
|
+
"statementExecution.executeStatement": { status: { state: "SUCCEEDED" } },
|
|
66
|
+
"apiClient.request": { results: [] },
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A function value receives the call arguments, so you can script per-argument behavior or reject to test an error path. `responses` configures the built-in mock, so passing it alongside your own `client` is rejected rather than silently ignored — configure the responses on that client instead. Any path you **don't** declare resolves `undefined` rather than crashing — see [Mocking Databricks services](#mocking-databricks-services).
|
|
73
|
+
|
|
74
|
+
For the response *shapes*, follow the service types on the Databricks SDK. The kit doesn't validate them, so a wrong shape fails in your plugin, not in the fake.
|
|
75
|
+
|
|
76
|
+
With one app open, `app.client` is the very object your handler resolves at runtime — reached inside a plugin via `getExecutionContext().client` — so you can assert calls on it:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { getMock } from "@databricks/appkit/testing";
|
|
80
|
+
|
|
81
|
+
expect(getMock(app.client, "jobs.getRun")).toHaveBeenCalledWith({ run_id: 42 });
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Facade accessors are typed against the SDK, so `expect(app.client.jobs.getRun).toHaveBeenCalled()` won't typecheck — `getMock` reaches the underlying spy.
|
|
86
|
+
|
|
87
|
+
### Requests[](#requests "Direct link to Requests")
|
|
88
|
+
|
|
89
|
+
`app.get/post/put/patch/delete(path, options?)` return a native `Response`, so `expectStream` composes directly with no bridge.
|
|
90
|
+
|
|
91
|
+
* `body` — a non-string value is JSON-encoded with `content-type: application/json`. A string is sent as-is.
|
|
92
|
+
* `headers` — merged last, so they win over anything the harness set.
|
|
93
|
+
* `obo` — `true` for the default test user, or `{ userId, token, email }`. Same shorthand as `createMockRequest({ obo })`, so a handler using `asUser(req)` resolves that identity.
|
|
94
|
+
* `signal` — forwarded to `fetch`.
|
|
95
|
+
|
|
96
|
+
### Teardown[](#teardown "Direct link to Teardown")
|
|
97
|
+
|
|
98
|
+
The harness binds a socket, so **every boot needs a `close()`**. It releases the socket, runs your plugin's `shutdown()` hooks, drops AppKit's singletons, and restores `process.env` to its pre-boot state. It's idempotent.
|
|
99
|
+
|
|
100
|
+
Prefer `await using`, which closes the app at scope exit even if the test throws:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
await using app = await createTestApp({ plugins: [myPlugin()] });
|
|
104
|
+
// released at scope exit
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`try/finally` works too, and is what you need if the app has to outlive a block:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const app = await createTestApp({ plugins: [myPlugin()] });
|
|
112
|
+
try {
|
|
113
|
+
// ...
|
|
114
|
+
} finally {
|
|
115
|
+
await app.close();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Miss the close and the app stays live — socket bound, singletons and `process.env` not restored — so the next `createTestApp` is refused (one app at a time).
|
|
121
|
+
|
|
122
|
+
For a suite where **every** test needs its own app, `useTestApp()` wires both hooks for you — a fresh app before each test, closed after — so there is no `close()` to forget:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { useTestApp } from "@databricks/appkit/testing";
|
|
126
|
+
|
|
127
|
+
describe("my plugin over HTTP", () => {
|
|
128
|
+
const app = useTestApp({ plugins: [myPlugin()] });
|
|
129
|
+
|
|
130
|
+
test("answers a request", async () => {
|
|
131
|
+
const res = await app.current.post("/api/my-plugin/run", { body: { id: 1 } });
|
|
132
|
+
expect(res.status).toBe(200);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Call it at the top of a `describe`, not inside a test — Vitest registers `beforeEach`/`afterEach` during collection. Read `.current` from within a test; outside one it throws rather than handing back a closed app. `await using` stays the shorter choice for a single test, but it cannot carry an app from a `beforeEach` into the test body.
|
|
139
|
+
|
|
140
|
+
### Satisfying declared resources[](#satisfying-declared-resources "Direct link to Satisfying declared resources")
|
|
141
|
+
|
|
142
|
+
The harness runs the real validator with a strict posture, so a plugin whose manifest requires a resource fails the boot unless its env var is set. Supply it with `env`:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
// Throws: MY_WAREHOUSE_ID is required by the manifest.
|
|
146
|
+
await createTestApp({ plugins: [myPlugin()] });
|
|
147
|
+
|
|
148
|
+
// Boots.
|
|
149
|
+
await createTestApp({ plugins: [myPlugin()], env: { MY_WAREHOUSE_ID: "w-1" } });
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
That makes "my plugin declares its resources correctly" a genuine assertion. `env` is restored on `close()`.
|
|
154
|
+
|
|
155
|
+
What this does not check
|
|
156
|
+
|
|
157
|
+
The harness validates that required resources' **environment variables are present**. It does **not** validate config *values* against your manifest's `config.schema` — no runtime validator exists for that yet. A test that boots successfully tells you your resource declarations and env are wired up; it says nothing about whether your config values are well-formed.
|
|
158
|
+
|
|
159
|
+
### Other options[](#other-options "Direct link to Other options")
|
|
160
|
+
|
|
161
|
+
* `server: false` — no socket. Plugin setup, validation, and teardown still run; the request methods throw if called. Useful when you only care that a plugin boots.
|
|
162
|
+
* `client` — supply your own workspace client instead of the built-in fake. You then own its `currentUser.me()`: AppKit reads `currentUser.id` during boot and can't start without it.
|
|
163
|
+
* `nodeEnv` — defaults to `"test"`. `"development"` is **refused**: dev mode routes the harness's ephemeral port through `get-port`, which throws on port `0`, and it also boots a real Vite server and relaxes validation.
|
|
164
|
+
* `cache` — defaults to in-memory. Override it only when reaching the network is the point of the test.
|
|
165
|
+
|
|
166
|
+
## Testing your plugin[](#testing-your-plugin-1 "Direct link to Testing your plugin")
|
|
167
|
+
|
|
168
|
+
`createTestApp({ plugins })` boots a **real** AppKit app, with the real Express wiring, routes, and resource validation, then hands you methods to call it like a client would:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { createTestApp, expectStream } from "@databricks/appkit/testing";
|
|
172
|
+
|
|
173
|
+
test("my plugin answers a request", async () => {
|
|
174
|
+
const app = await createTestApp({ plugins: [myPlugin()] });
|
|
175
|
+
try {
|
|
176
|
+
const res = await app.post("/api/my-plugin/thing", { body: { q: 1 }, obo: true });
|
|
177
|
+
expect(res.status).toBe(200);
|
|
178
|
+
await expectStream(res).toEmit("status", "result");
|
|
179
|
+
} finally {
|
|
180
|
+
await app.close();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
No workspace, no credentials, no network. The harness pins a non-development `NODE_ENV`, binds an ephemeral port, installs a fake workspace client, and keeps the cache in memory so nothing reaches out.
|
|
187
|
+
|
|
188
|
+
Paths are the full mounted route. A plugin's prefix is `/api/` plus its manifest name in kebab-case, so a plugin named `mySearch` serves at `/api/my-search/…`.
|
|
189
|
+
|
|
190
|
+
### Which harness?[](#which-harness-1 "Direct link to Which harness?")
|
|
191
|
+
|
|
192
|
+
| | `createTestApp` | `createTestPluginContext` |
|
|
193
|
+
| --------------------------------- | --------------------------- | ------------------------------------------ |
|
|
194
|
+
| Boots the app | Yes | No |
|
|
195
|
+
| Binds a socket | Yes (ephemeral port) | No |
|
|
196
|
+
| Express middleware, error handler | Real | Not involved |
|
|
197
|
+
| Resource / env validation | Real, and strict | Not involved |
|
|
198
|
+
| Workspace client | Faked and injected | Fake it yourself with `mockServiceContext` |
|
|
199
|
+
| Needs `close()` | **Yes** | No |
|
|
200
|
+
| Speed | Fast, but pays for a socket | Fastest |
|
|
201
|
+
|
|
202
|
+
Use `createTestApp` for a plugin's HTTP behavior end to end. Use `createTestPluginContext` to unit-test wiring: route registration, tool dispatch, timeout composition. Name harness suites `*.integration.test.ts`, matching the existing convention.
|
|
203
|
+
|
|
204
|
+
### Faking what your plugin reads[](#faking-what-your-plugin-reads-1 "Direct link to Faking what your plugin reads")
|
|
205
|
+
|
|
206
|
+
Declare responses by dotted path — `"<service>.<method>"` on AppKit's workspace-client facade:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
const app = await createTestApp({
|
|
210
|
+
plugins: [myPlugin()],
|
|
211
|
+
responses: {
|
|
212
|
+
"jobs.getRun": { state: "TERMINATED", result_state: "SUCCESS" },
|
|
213
|
+
"statementExecution.executeStatement": { status: { state: "SUCCEEDED" } },
|
|
214
|
+
"apiClient.request": { results: [] },
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
A function value receives the call arguments, so you can script per-argument behavior or reject to test an error path. `responses` configures the built-in mock, so passing it alongside your own `client` is rejected rather than silently ignored — configure the responses on that client instead. Any path you **don't** declare resolves `undefined` rather than crashing — see [Mocking Databricks services](#mocking-databricks-services) for the trade-off it makes.
|
|
221
|
+
|
|
222
|
+
For the response *shapes*, follow the service types on the Databricks SDK. The kit doesn't validate them, so a wrong shape fails in your plugin, not in the fake.
|
|
223
|
+
|
|
224
|
+
With one app open, `app.client` is the very object your handler resolves at runtime — reached inside a plugin via `getExecutionContext().client` — so you can assert calls on it:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { getMock } from "@databricks/appkit/testing";
|
|
228
|
+
|
|
229
|
+
expect(getMock(app.client, "jobs.getRun")).toHaveBeenCalledWith({ run_id: 42 });
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`getMock` exists because facade accessors are typed against the SDK, so `expect(app.client.jobs.getRun).toHaveBeenCalled()` won't typecheck.
|
|
234
|
+
|
|
235
|
+
### Requests[](#requests-1 "Direct link to Requests")
|
|
236
|
+
|
|
237
|
+
`app.get/post/put/patch/delete(path, options?)` return a native `Response`, so `expectStream` composes directly with no bridge.
|
|
238
|
+
|
|
239
|
+
* `body` — a non-string value is JSON-encoded with `content-type: application/json`. A string is sent as-is.
|
|
240
|
+
* `headers` — merged last, so they win over anything the harness set.
|
|
241
|
+
* `obo` — `true` for the default test user, or `{ userId, token, email }`. Same shorthand as `createMockRequest({ obo })`, so a handler using `asUser(req)` resolves that identity.
|
|
242
|
+
* `signal` — forwarded to `fetch`.
|
|
243
|
+
|
|
244
|
+
### Teardown[](#teardown-1 "Direct link to Teardown")
|
|
245
|
+
|
|
246
|
+
The harness binds a socket, so **every boot needs a `close()`**. It releases the socket, runs your plugin's `shutdown()` hooks, drops AppKit's singletons, and restores `process.env` to its pre-boot state. It's idempotent.
|
|
247
|
+
|
|
248
|
+
Prefer `await using`, which closes the app at scope exit even if the test throws:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
await using app = await createTestApp({ plugins: [myPlugin()] });
|
|
252
|
+
// released at scope exit
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`try/finally` works too, and is what you need if the app has to outlive a block:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
const app = await createTestApp({ plugins: [myPlugin()] });
|
|
260
|
+
try {
|
|
261
|
+
// ...
|
|
262
|
+
} finally {
|
|
263
|
+
await app.close();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Miss the close and the app stays live — socket bound, singletons and `process.env` not restored — so the next `createTestApp` is refused (one app at a time).
|
|
269
|
+
|
|
270
|
+
### Satisfying declared resources[](#satisfying-declared-resources-1 "Direct link to Satisfying declared resources")
|
|
271
|
+
|
|
272
|
+
The harness runs the real validator with a strict posture, so a plugin whose manifest requires a resource fails the boot unless its env var is set. Supply it with `env`:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
// Throws: MY_WAREHOUSE_ID is required by the manifest.
|
|
276
|
+
await createTestApp({ plugins: [myPlugin()] });
|
|
277
|
+
|
|
278
|
+
// Boots.
|
|
279
|
+
await createTestApp({ plugins: [myPlugin()], env: { MY_WAREHOUSE_ID: "w-1" } });
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
That makes "my plugin declares its resources correctly" a genuine assertion. `env` is restored on `close()`.
|
|
284
|
+
|
|
285
|
+
What this does not check
|
|
286
|
+
|
|
287
|
+
The harness validates that required resources' **environment variables are present**. It does **not** validate config *values* against your manifest's `config.schema` — no runtime validator exists for that yet. A test that boots successfully tells you your resource declarations and env are wired up; it says nothing about whether your config values are well-formed.
|
|
288
|
+
|
|
289
|
+
### Other options[](#other-options-1 "Direct link to Other options")
|
|
290
|
+
|
|
291
|
+
* `server: false` — no socket. Plugin setup, validation, and teardown still run; the request methods throw if called. Useful when you only care that a plugin boots.
|
|
292
|
+
* `client` — supply your own workspace client instead of the built-in fake. You then own its `currentUser.me()`: AppKit reads `currentUser.id` during boot and can't start without it.
|
|
293
|
+
* `nodeEnv` — defaults to `"test"`. `"development"` is **refused**: dev mode routes the harness's ephemeral port through `get-port`, which throws on port `0`, and it also boots a real Vite server and relaxes validation.
|
|
294
|
+
* `cache` — defaults to in-memory. Overriding it is what would let the cache reach the network, so leave it alone unless that's the point of the test.
|
|
16
295
|
|
|
17
296
|
## `createTestPluginContext()`[](#createtestplugincontext "Direct link to createtestplugincontext")
|
|
18
297
|
|
|
19
|
-
`PluginContext` is the mediator AppKit passes to every plugin
|
|
298
|
+
`PluginContext` is the mediator AppKit passes to every plugin: it buffers routes, tracks tool providers, and runs cross-plugin tool calls with user scoping and a timeout. `createTestPluginContext()` returns the **real** context with three edges faked:
|
|
20
299
|
|
|
21
300
|
| Edge | How it's faked |
|
|
22
301
|
| -------------- | ----------------------------------------------------------------------------------------- |
|
|
@@ -24,7 +303,7 @@ The kit uses [Vitest](https://vitest.dev)'s `vi` for its mocks, so `vitest` is a
|
|
|
24
303
|
| Tool providers | Fakes registered through the real `registerToolProvider`, keyed by plugin then tool name. |
|
|
25
304
|
| Routes | The real `addRoute`/`addMiddleware` are wrapped to record what a plugin registers. |
|
|
26
305
|
|
|
27
|
-
|
|
306
|
+
The context is real, so `executeTool` runs the actual user-scope (`asUser(req)`) and timeout-composition paths — not stubs of them.
|
|
28
307
|
|
|
29
308
|
### Registering fake tool responses[](#registering-fake-tool-responses "Direct link to Registering fake tool responses")
|
|
30
309
|
|
|
@@ -56,7 +335,36 @@ await mock.attach(plugin);
|
|
|
56
335
|
|
|
57
336
|
Instantiate the plugin **class** directly (`new MyAgentPlugin(...)`). The `analytics()` / `agents()` factories you pass to `createApp` return a descriptor for the app to construct — for a unit test you want the instance.
|
|
58
337
|
|
|
59
|
-
|
|
338
|
+
### Seeding with workspace responses and environment[](#seeding-with-workspace-responses-and-environment "Direct link to Seeding with workspace responses and environment")
|
|
339
|
+
|
|
340
|
+
`createTestPluginContext` accepts a second `options` parameter to control the faked workspace client and environment:
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
const mock = createTestPluginContext({}, {
|
|
344
|
+
responses: {
|
|
345
|
+
"jobs.getRun": { state: "TERMINATED" },
|
|
346
|
+
"servingEndpoints.query": (args, signal) => runFakeQuery(args),
|
|
347
|
+
},
|
|
348
|
+
env: { MY_VAR: "test-value" },
|
|
349
|
+
strict: true,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// The factory call is synchronous; attach is the async part.
|
|
353
|
+
await mock.attach(plugin);
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`options` is:
|
|
358
|
+
|
|
359
|
+
* `responses` — seed the mock workspace client with responses keyed by dotted path (`"jobs.getRun"`, `"genie.getMessage"`). A value can be static or a function of call arguments and the abort signal.
|
|
360
|
+
* `env` — set environment variables scoped to the test; they are restored on plugin detach.
|
|
361
|
+
* `strict` — throw if a handler calls an undeclared workspace-client path (instead of silently resolving `undefined`). The built-in defaults still count as declared.
|
|
362
|
+
|
|
363
|
+
The context installs a test-scoped service context via `beforeEach` and restores it on `afterEach`, so it survives across tests in the same suite. Call the returned `.restore()` explicitly if you need to clear it mid-test.
|
|
364
|
+
|
|
365
|
+
The workspace client and on-behalf-of stub are process-wide too: `ServiceContext` holds one client, and the `createUserContext` fake is a single spy. So **`createTestApp` allows one open app at a time** and throws if you boot a second before closing the first. Vitest isolates test *files* in separate workers, so this constrains only apps within one file — and a `describe` holding an app open in `beforeAll` can't contain a test that boots its own.
|
|
366
|
+
|
|
367
|
+
The cache is a process-wide singleton too — initialized once per test process and shared by tests **within one file** (it never leaks across files). If one test populates it and a later one must not see that, clear between tests with `resetTestCache()`:
|
|
60
368
|
|
|
61
369
|
```ts
|
|
62
370
|
import { resetTestCache } from "@databricks/appkit/testing";
|
|
@@ -69,6 +377,33 @@ beforeEach(async () => {
|
|
|
69
377
|
|
|
70
378
|
It also helps *within* a single test — clear the cache to force a miss, then assert the following call is a hit.
|
|
71
379
|
|
|
380
|
+
### Asserting cache behaviour[](#asserting-cache-behaviour "Direct link to Asserting cache behaviour")
|
|
381
|
+
|
|
382
|
+
When a plugin caches its work (like `analytics` caching query results), test the caching *itself* — a second identical call is a hit, different users get different keys — with `useTestCache()`. It boots the real in-memory cache, clears it before each test, and hands back the real `CacheManager`, so you assert against production's own `getOrExecute` and `generateKey` rather than mocking the internal `cache` module:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { useTestCache } from "@databricks/appkit/testing";
|
|
386
|
+
|
|
387
|
+
describe("my plugin caches", () => {
|
|
388
|
+
const testCache = useTestCache();
|
|
389
|
+
|
|
390
|
+
test("a second identical request is served from cache", async () => {
|
|
391
|
+
const plugin = new MyPlugin(config);
|
|
392
|
+
// ...drive the same request twice against a mocked downstream call...
|
|
393
|
+
expect(downstreamMock).toHaveBeenCalledTimes(1);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("scopes the cache key per user", () => {
|
|
397
|
+
const a = testCache.current.generateKey(["query", sql], "user-1");
|
|
398
|
+
const b = testCache.current.generateKey(["query", sql], "user-2");
|
|
399
|
+
expect(a).not.toBe(b);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Call it at the top of a `describe` (or module top-level), not inside a test — Vitest registers its `beforeEach`/`afterEach` at collection time. It boots the cache before each test, so a plugin you construct binds `this.cache` to the real cache and runs its actual caching path. Use `resetTestCache()` (above) instead when you only need to clear the cache, not a handle to it.
|
|
406
|
+
|
|
72
407
|
### Inspecting what happened[](#inspecting-what-happened "Direct link to Inspecting what happened")
|
|
73
408
|
|
|
74
409
|
The returned object exposes live views you read after the action under test runs:
|
|
@@ -96,7 +431,7 @@ expect(mock.telemetry.getTracer().startActiveSpan).toHaveBeenCalled();
|
|
|
96
431
|
|
|
97
432
|
`mock.telemetry` is injected into the `PluginContext`, so it captures the spans the *context* opens (notably `executeTool`). It is **not** the plugin's own telemetry: `attachContext` rebuilds `this.telemetry` from the real `TelemetryManager`, so spans a plugin opens internally do not land on `mock.telemetry`.
|
|
98
433
|
|
|
99
|
-
|
|
434
|
+
Assert cross-plugin on-behalf-of through `RecordedToolCall.asUser`. The fake `asUser` enforces the real `Plugin.asUser`'s token precondition: a request carrying a forwarded token records `asUser: true` with the resolved `userId`, and one missing `x-forwarded-access-token` **rejects**. Assert both directions — a well-formed request records the expected `userId`, a token-less one throws. A silent `{ executeTool }` stub verifies neither.
|
|
100
435
|
|
|
101
436
|
The fake replicates `asUser`'s **token precondition**, not its internal dev-mode telemetry marker: in `NODE_ENV=development` the real `Plugin.asUser` skips impersonation and sets an OTel `isDevOboFallback()` flag, which the fake does not reproduce. Assert OBO through the recorded `asUser`/`userId` fields rather than `isDevOboFallback()`.
|
|
102
437
|
|
|
@@ -133,7 +468,7 @@ await expectStream(res).toEmit("status", "result");
|
|
|
133
468
|
|
|
134
469
|
```
|
|
135
470
|
|
|
136
|
-
`expectStream(res)` and `expectStream(res.sseResponse())` are equivalent
|
|
471
|
+
`expectStream(res)` and `expectStream(res.sseResponse())` are equivalent; the latter hands you the raw `Response` if you want it. Do **not** pass the SSE body as a string: a string is an iterable of characters, so `expectStream` rejects it with a pointer to `sseResponse()` rather than emitting one "event" per character.
|
|
137
472
|
|
|
138
473
|
`toEmit` checks that the expected types appear **in order** but tolerates other events before, between, or after them — which is what you want for streams that interleave bookkeeping events like heartbeats or metadata. Use `toEmitExactly` when the stream's shape is fully determined.
|
|
139
474
|
|
|
@@ -146,12 +481,20 @@ await expectStream(handler.stream(req), { timeout: 1000 }).toEmit("result");
|
|
|
146
481
|
|
|
147
482
|
## Fixtures[](#fixtures "Direct link to Fixtures")
|
|
148
483
|
|
|
484
|
+
AppKit has two contexts, and they're faked by different tools. `PluginContext` is the mediator between plugins, handling routes, tool dispatch, and user scoping; `createTestPluginContext()` gives you the real thing with faked edges. `ServiceContext` is the **data plane**: it resolves the workspace client, the service principal, and the warehouse ID that plugins reach through `getWorkspaceClient()`.
|
|
485
|
+
|
|
486
|
+
The kit covers both. `createTestApp` fakes the data plane by injecting a mock workspace client at the real seam; below that, `mockServiceContext` spies the singleton directly, and `createMockWorkspaceClient` builds the client either of them installs.
|
|
487
|
+
|
|
149
488
|
The kit re-exports the request/response/context fixtures AppKit uses internally:
|
|
150
489
|
|
|
151
490
|
* `createMockRequest(overrides?)` / `createMockResponse()` — Express request/response doubles, including the streaming flags (`headersSent`, `writableEnded`). Pass `obo: true` (or `obo: { userId, token, email }`) to set the forwarded identity headers `asUser` requires, instead of hand-adding them. `createMockResponse()` also captures everything a handler writes; pass it to `expectStream` (or call `sseResponse()`) to assert a streaming route's SSE. (Plugins resolve the workspace client through `getWorkspaceClient()`, not the request — use `mockServiceContext` to control it.)
|
|
491
|
+
|
|
492
|
+
* `createMockRouter()` — build a mock Express-style router for testing route-registration wiring.
|
|
493
|
+
|
|
152
494
|
* `mockServiceContext(options?)` — spy the `ServiceContext` singleton so code that resolves the service principal or a user context gets test doubles. Call in `beforeEach`, and call the returned `restore()` in `afterEach`.
|
|
495
|
+
|
|
153
496
|
* `useServiceContextMock(options?)` — the same, in one line: it registers the `beforeEach` install and `afterEach` restore for you. Call it at the top of a `describe` block (not inside a test), and read the live `.current` handle from within a test:
|
|
154
|
-
|
|
497
|
+
|
|
155
498
|
```ts
|
|
156
499
|
describe("my plugin", () => {
|
|
157
500
|
const ctx = useServiceContextMock();
|
|
@@ -162,13 +505,96 @@ The kit re-exports the request/response/context fixtures AppKit uses internally:
|
|
|
162
505
|
});
|
|
163
506
|
|
|
164
507
|
```
|
|
508
|
+
|
|
165
509
|
* `createSuccessfulSQLResponse(rows, columns)` / `createFailedSQLResponse(message)` — build SQL Warehouse statement responses.
|
|
510
|
+
|
|
166
511
|
* `setupDatabricksEnv(overrides?)` — set `DATABRICKS_HOST` / `DATABRICKS_WAREHOUSE_ID` to test values.
|
|
167
|
-
|
|
512
|
+
|
|
513
|
+
* `withEnv(vars, fn)` — set environment variables for the duration of a sync or async function, restoring each key's prior state (or deleting it if it was previously unset). Unlike a bare `process.env.X = ...` followed by `delete`, nested calls restore LIFO and don't accidentally leave prior values in place.
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
// Before: process.env.X = "test"; try { /* code */ } finally { delete process.env.X }
|
|
517
|
+
// After:
|
|
518
|
+
await withEnv({ X: "test" }, async () => { /* code */ });
|
|
519
|
+
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
* `createApiError({ statusCode, message, errorCode })` — create a genuine `ApiError` instance for testing error paths. Returns an instance where `error instanceof ApiError` holds, so your error handling resolves the right type.
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
const error = createApiError({ statusCode: 404, message: "Not found", errorCode: "NOT_FOUND" });
|
|
526
|
+
expect(error instanceof ApiError).toBe(true);
|
|
527
|
+
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
* `resetTestCache()` — clear the shared cache singleton between (or within) tests; no-ops if the cache isn't initialized yet. The kit uses both words deliberately: a **mock** records calls so you can assert on them (`createMockWorkspaceClient`, `mockServiceContext`), while a **fake** stands in and simply works (`FakeProvider`, `FakeToolResponse`).
|
|
531
|
+
|
|
532
|
+
* `createTestPlugin(factory, config?)` — instantiate a plugin from its factory with the same config merge AppKit applies. See [Full example](#full-example).
|
|
533
|
+
|
|
534
|
+
* `getListeningPort(server)` — wait for a server to finish binding and return the port it landed on. `createTestApp` does this for you; reach for it when you start a server yourself with `port: 0`.
|
|
535
|
+
|
|
536
|
+
## Mocking Databricks services[](#mocking-databricks-services "Direct link to Mocking Databricks services")
|
|
537
|
+
|
|
538
|
+
Every core plugin's real work goes through `getWorkspaceClient()`. `createMockWorkspaceClient()` fakes that whole surface, so a plugin touching `jobs`, `genie`, `servingEndpoints`, or `files` is testable without hand-building a nested client:
|
|
539
|
+
|
|
540
|
+
```ts
|
|
541
|
+
import { createMockWorkspaceClient, getMock } from "@databricks/appkit/testing";
|
|
542
|
+
|
|
543
|
+
const client = createMockWorkspaceClient({
|
|
544
|
+
responses: { "jobs.getRun": { state: "TERMINATED" } },
|
|
545
|
+
config: { host: "https://my-test-host.example.com" },
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
await client.jobs.getRun({ run_id: 1 }); // → { state: "TERMINATED" }
|
|
549
|
+
await client.genie.getMessage({ id: "m-1" }); // → undefined, does not throw
|
|
550
|
+
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
`createTestApp` installs one of these for you, so reach for it directly only when you're driving a plugin through `createTestPluginContext` or `mockServiceContext`.
|
|
554
|
+
|
|
555
|
+
How it works, and what to expect:
|
|
556
|
+
|
|
557
|
+
* The **facade is typed**, so `client.jbos` is a compile error. AppKit owns the interface, so it's a closed set, not an open-ended chase of the SDK.
|
|
558
|
+
* Each **service** is a proxy that mints a memoized mock per method. `client.jobs.getRun === client.jobs.getRun`, so call assertions are stable, and `toLegacyWorkspaceClient()` shares the same functions — one `responses` entry covers both views.
|
|
559
|
+
* `config.host` is a real **string** (not a mock), because AppKit builds URLs from it. `apiClient.userAgent()` is synchronous for the same reason, and `apiClient.request` resolves `{}` so destructuring its result doesn't throw.
|
|
560
|
+
* Sensible defaults are built in: SQL statements succeed, warehouses report `RUNNING`, and `currentUser.me()` returns a service user. Pass `defaults: false` to script everything yourself.
|
|
561
|
+
|
|
562
|
+
Undeclared methods return undefined
|
|
563
|
+
|
|
564
|
+
An undeclared method resolves `undefined` instead of throwing. That's the point — your plugin survives touching services the test doesn't care about — but it means a call whose response you *forgot* to declare silently returns `undefined` rather than failing loudly, so a test can pass for the wrong reason.
|
|
565
|
+
|
|
566
|
+
Pass `strict: true` to turn that silence into a failure: a call to a path with no declared response throws instead of resolving `undefined`, naming the path. The canned defaults still count as declared, so a harness boot works unchanged.
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
const app = await createTestApp({ plugins: [myPlugin()], strict: true });
|
|
570
|
+
// a handler calling an undeclared path now fails the request
|
|
571
|
+
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
TypeScript catches more than the obvious: each accessor is typed against the SDK's own service class, so both a misspelled **service** (`client.jbos`) and a misspelled **method** (`client.jobs.getRunz`) are compile errors. The gap is a *real* method with no declared response — and any call that bypasses the types with a cast.
|
|
575
|
+
|
|
576
|
+
One more divergence: a service's methods are minted on access, so they are **callable but not enumerable**. `typeof client.jobs.getRun` is `"function"`, but `'getRun' in client.jobs` is `false` and `Object.keys(client.jobs)` is `[]`. Plugin code that feature-detects with `in` or reflects over a service will therefore take a different branch than in production. That's deliberate — reporting the keys would make `util.inspect` probe each one, minting a mock per probe.
|
|
577
|
+
|
|
578
|
+
Separately, `createLakebasePool({ workspaceClient })` will build a pool whose password callback resolves to a mock: the pool exists but cannot connect. A Lakebase test needs a real database or a purpose-built fake pool, not this.
|
|
168
579
|
|
|
169
580
|
## Full example[](#full-example "Direct link to Full example")
|
|
170
581
|
|
|
171
|
-
|
|
582
|
+
For a plugin you wrote, instantiate the class directly with `new`. The `analytics()` / `agents()` factory functions you pass to `createApp` return a *descriptor* for the app to construct, not an instance.
|
|
583
|
+
|
|
584
|
+
When you want an instance from one of those factories, use `createTestPlugin` rather than reaching through the descriptor:
|
|
585
|
+
|
|
586
|
+
```ts
|
|
587
|
+
import { createTestPlugin } from "@databricks/appkit/testing";
|
|
588
|
+
|
|
589
|
+
const plugin = createTestPlugin(genie, { spaceId: "s-1" });
|
|
590
|
+
|
|
591
|
+
// Not this — it skips DEFAULT_CONFIG and forgets `name`, so the instance is
|
|
592
|
+
// configured differently from the one production builds:
|
|
593
|
+
// const plugin = new (genie({}).plugin)({ spaceId: "s-1" });
|
|
594
|
+
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
`createTestPlugin` applies the same merge AppKit does at registration: `DEFAULT_CONFIG`, then your config, then the manifest `name`. It's for this unit-test path only — `createTestApp` takes descriptors and builds the instances itself.
|
|
172
598
|
|
|
173
599
|
```ts
|
|
174
600
|
import { Plugin, type PluginManifest } from "@databricks/appkit";
|
package/llms.txt
CHANGED
|
@@ -59,7 +59,7 @@ npx @databricks/appkit docs <query>
|
|
|
59
59
|
- [Plugin management](./docs/plugins/plugin-management.md): AppKit includes a CLI for managing plugins. All commands are available under npx @databricks/appkit plugin.
|
|
60
60
|
- [Server plugin](./docs/plugins/server.md): Provides HTTP server capabilities with development and production modes.
|
|
61
61
|
- [Plugin Stability Tiers](./docs/plugins/stability.md): AppKit plugins have a two-tier stability system that communicates API maturity and breaking-change expectations.
|
|
62
|
-
- [Testing](./docs/plugins/testing.md): AppKit ships a testing kit at @databricks/appkit/testing so you can test a plugin
|
|
62
|
+
- [Testing](./docs/plugins/testing.md): AppKit ships a testing kit at @databricks/appkit/testing so you can test a plugin, including its cross-plugin tool calls and streaming responses, without a live Databricks workspace, credentials, or network access. Plugin tests stay fast and run in CI, where no workspace is available.
|
|
63
63
|
|
|
64
64
|
## appkit API reference [collapsed]
|
|
65
65
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@databricks/appkit",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.76.1",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
7
7
|
"bin": {
|
|
@@ -58,7 +58,6 @@
|
|
|
58
58
|
"@ast-grep/napi": "0.37.0",
|
|
59
59
|
"@databricks/lakebase": "0.6.0",
|
|
60
60
|
"@databricks/sdk-experimental": "0.17.0",
|
|
61
|
-
"@mlflow/core": "0.4.0",
|
|
62
61
|
"@opentelemetry/api": "1.9.0",
|
|
63
62
|
"@opentelemetry/api-logs": "0.219.0",
|
|
64
63
|
"@opentelemetry/auto-instrumentations-node": "0.77.0",
|
|
@@ -76,7 +75,6 @@
|
|
|
76
75
|
"@opentelemetry/semantic-conventions": "1.38.0",
|
|
77
76
|
"@types/semver": "7.7.1",
|
|
78
77
|
"apache-arrow": "21.1.0",
|
|
79
|
-
"autoevals": "0.3.0",
|
|
80
78
|
"dotenv": "16.6.1",
|
|
81
79
|
"drizzle-orm": "0.45.2",
|
|
82
80
|
"express": "4.22.2",
|
|
@@ -97,14 +95,23 @@
|
|
|
97
95
|
"yaml": "2.8.2"
|
|
98
96
|
},
|
|
99
97
|
"peerDependencies": {
|
|
98
|
+
"@mlflow/core": "0.4.0",
|
|
99
|
+
"autoevals": "0.3.0",
|
|
100
100
|
"vitest": ">=3"
|
|
101
101
|
},
|
|
102
102
|
"peerDependenciesMeta": {
|
|
103
|
+
"@mlflow/core": {
|
|
104
|
+
"optional": true
|
|
105
|
+
},
|
|
106
|
+
"autoevals": {
|
|
107
|
+
"optional": true
|
|
108
|
+
},
|
|
103
109
|
"vitest": {
|
|
104
110
|
"optional": true
|
|
105
111
|
}
|
|
106
112
|
},
|
|
107
113
|
"devDependencies": {
|
|
114
|
+
"@mlflow/core": "0.4.0",
|
|
108
115
|
"@opentelemetry/context-async-hooks": "2.8.0",
|
|
109
116
|
"@types/express": "4.17.25",
|
|
110
117
|
"@types/js-yaml": "4.0.9",
|
|
@@ -112,6 +119,7 @@
|
|
|
112
119
|
"@types/pg": "8.16.0",
|
|
113
120
|
"@types/ws": "8.18.1",
|
|
114
121
|
"@vitejs/plugin-react": "5.1.1",
|
|
122
|
+
"autoevals": "0.3.0",
|
|
115
123
|
"vitest": "3.2.4"
|
|
116
124
|
},
|
|
117
125
|
"overrides": {
|