feat(appkit): add testing kit - #530
Conversation
The testing kit needs to construct a real PluginContext without a live OpenTelemetry pipeline. Add an optional constructor dependency for the telemetry provider, defaulting to the shared "plugin-context" provider so the production path is unchanged. This is the single production edit required to wrap the real class in tests rather than reimplementing it. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Wire the testing kit as a published subpath and prove it against the first
of the two hand-rolled context stubs (the design gate):
- Add ./testing to both exports maps (dev + publishConfig) following the
./type-generator shape, add src/testing/index.ts to the tsdown entry, and
declare vitest as an optional peerDependency. Build passes attw + publint;
dist/testing/{index,mock-plugin-context,expect-stream,fixtures}.{js,d.ts}
are emitted and vitest stays external to the main entry.
- Migrate dispatch-tool-call.test.ts: replace (plugin as any).context =
{ executeTool } with mockPluginContext. executeTool is now the REAL method,
so the forwarded toolCallTimeoutMs is asserted through actual signal
composition, the on-behalf-of (asUser) path is verified, and a new test
proves the forwarded timeout actually aborts a slow toolkit tool end-to-end.
This is the primary win from the plan: executeTool's OBO and timeout paths
gain real assertions instead of a stub that proved nothing.
Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
…Context
Replace the second and final hand-rolled stub — (plugin as any).context =
{ addRoute } — with the real PluginContext from mockPluginContext. The kit's
route recorder captures raw handlers, so the alias assertion (both
/invocations and /responses mount the same handler reference) holds against
the real class, where forwardAsyncErrors wrapping would otherwise break
reference identity.
Both context stubs the plan identified are now migrated.
Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
- Add docs/docs/development/testing.md covering mockPluginContext(), expectStream(), and the fixture helpers, with a full end-to-end example. Cross-links to local-development, custom-plugins, and execution-context. - Add template/server/example.test.ts: a self-contained, plugin-agnostic example that scaffolded apps ship with — it defines a tiny custom plugin and exercises both mockPluginContext (route recording) and expectStream (ordered event assertions), running with no workspace or network. Ships the kit to users, satisfying the plan's acceptance criteria that a docs page exists and the template carries at least one example test. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Validation by scaffolding a real app with `databricks apps init` surfaced
that the examples called the `analytics()`/`toPlugin()` factory and then
treated the result as a plugin instance — but a factory returns a
{ plugin, config, name } descriptor for createApp to construct, so
`.attachContext`/handler methods are absent.
Rewrite both the template example test and the docs "Full example" to
instantiate the plugin class directly (`new GreeterPlugin({})`), matching how
the migrated agents suites use the kit. The scaffolded app's `npm test` and
`tsc` both pass against the published `@databricks/appkit/testing` subpath
with no workspace or network.
Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
…pe error
Drop `undefined` from the static FakeToolValue union. `resolve()` treats an
undefined map entry as "unregistered tool" and throws, so allowing undefined
as a declared response made `{ query: undefined }` a confusing runtime error
instead of a compile error. A function returning undefined still works for the
rare "returns nothing" case. Add a test pinning that a null response is
returned as a value, not misread as a missing tool.
Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
…ting kit The plan's step 5 was to MOVE the fixtures into the package, not copy them. The shipped kit (src/testing/fixtures.ts) duplicated all 15 exports of tools/test-helpers.ts, which would drift over time. Collapse the original into a thin re-export of @databricks/appkit/testing so src/testing is the single source of truth while the 18 existing @tools/test-helpers importers keep working unchanged. The re-exported mockServiceContext is now synchronous; every call site either awaits it (no-op on a non-promise) or reads it through Awaited<ReturnType<...>>, so all suites pass unchanged (full appkit suite: 3117 passed, 1 pre-existing skip). Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
…ing docs Code review follow-ups: - expectStream's parseSSEBody split frames on \n\n, so a spec-compliant SSE stream delimited by \r\n\r\n (from a real server) collapsed into one event. AppKit's own writer uses \n\n so existing tests were unaffected, but expectStream is public API that accepts any Response. Normalize CRLF to LF before splitting; add a CRLF regression test. - Docs: instantiate the plugin CLASS in the attach() snippet (the factory returns a descriptor, not an instance), and note that the cache attach() seeds is a per-process singleton shared by tests within a file. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
📦 Bundle size reportCompared against
|
| dist | raw | gzip |
|---|---|---|
| JS (runtime) | 893 KB (+24 KB) | 312 KB (+8.6 KB) |
| Type declarations | 340 KB (+25 KB) | 118 KB (+9.1 KB) |
| Source maps | 1.7 MB (+57 KB) | 586 KB (+20 KB) |
| Other | 11 KB | 3.7 KB |
| Total | 3.0 MB (+106 KB) | 1019 KB (+38 KB) |
Per-entry composition (own code — deps external (as shipped))
| Entry | Initial (gz) | Lazy (gz) | Total (gz) | node_modules (min) | Own code (min) |
|---|---|---|---|---|---|
. |
88 KB (+15 B) | 2.5 KB | 91 KB (+15 B) | external | 288 KB (+46 B) |
./beta |
49 KB | 458 B | 49 KB | external | 143 KB |
./testing |
17 KB | 0 B | 17 KB | external | 50 KB |
./type-generator |
21 KB | 0 B | 21 KB | external | 60 KB |
Chunks:
| Entry | Chunk | Load | Size (gz) |
|---|---|---|---|
. |
index.js |
initial | 84 KB |
. |
utils.js |
initial | 4.0 KB |
. |
remote-tunnel-manager.js |
lazy | 2.5 KB |
./beta |
beta.js |
initial | 33 KB |
./beta |
stream-manager.js |
initial | 5.8 KB |
./beta |
wide-event-emitter.js |
initial | 3.2 KB |
./beta |
databricks.js |
initial | 3.0 KB |
./beta |
configuration.js |
initial | 2.1 KB |
./beta |
service-context.js |
initial | 1.3 KB |
./beta |
client.js |
initial | 431 B |
./beta |
client-options.js |
initial | 219 B |
./beta |
supervisor-api.js |
lazy | 194 B |
./beta |
databricks.js |
lazy | 142 B |
./beta |
index.js |
lazy | 122 B |
./testing |
index.js |
initial | 17 KB |
./type-generator |
index.js |
initial | 21 KB |
@databricks/appkit-ui
npm tarball (packed): 342 KB — gzipped download (dist + bin; excludes release-only docs/NOTICE).
| dist | raw | gzip |
|---|---|---|
| JS (runtime) | 390 KB | 130 KB |
| Type declarations | 228 KB | 83 KB |
| Source maps | 753 KB | 248 KB |
| CSS | 16 KB | 3.3 KB |
| Total | 1.4 MB | 464 KB |
Per-entry composition (consumer bundle — deps bundled, peerDeps external)
| Entry | Initial (gz) | Lazy (gz) | Total (gz) | node_modules (min) | Own code (min) |
|---|---|---|---|---|---|
./js |
5.3 KB | 49 KB | 55 KB | 208 KB | 14 KB |
./js/beta |
20 B | 0 B | 20 B | 0 B | 0 B |
./react |
432 KB | 49 KB | 480 KB | 1.3 MB | 175 KB |
./react/beta |
1.0 KB | 0 B | 1.0 KB | 0 B | 1.9 KB |
Chunks:
| Entry | Chunk | Load | Size (gz) |
|---|---|---|---|
./js |
index.js |
initial | 5.2 KB |
./js |
chunk |
initial | 120 B |
./js |
apache-arrow |
lazy | 49 KB |
./js/beta |
beta.js |
initial | 20 B |
./react |
index.js |
initial | 430 KB |
./react |
tslib |
initial | 2.1 KB |
./react |
apache-arrow |
lazy | 49 KB |
./react/beta |
beta.js |
initial | 1.0 KB |
🤖 AppKit PR bot🔬 Run evalsStart an eval for this PR from the evals-monitor app: Go to Evals Monitor → 📦 Try this PR's app templateScaffolds a new app from this PR's SDK build. Run it in any folder (requires the GitHub CLI — gh run download 31701596628 -R databricks/appkit -n appkit-template-0.60.0-pr.45dec48-feat-testing-kit-530 -D appkit-pr-530 \
&& unzip -o "appkit-pr-530/appkit-template-0.60.0-pr.45dec48-feat-testing-kit-530.zip" -d "appkit-pr-530" \
&& databricks apps init --template "appkit-pr-530"The template pins |
CI's "Lint & Type Check" job runs `pnpm run check` over the whole repo, so a pre-existing lint error unrelated to this branch failed the build: - remote-tunnel-controller.test.ts had two `afterEach` hooks in one describe (lint/suspicious/noDuplicateTestHooks, error severity). Merge them into one — behavior preserved (env reset + console-spy clear both still run after each test). This file is byte-identical to main; the error predated the branch and only surfaced because CI lints the entire tree. Also drop two dead `biome-ignore lint/suspicious/noExplicitAny` suppressions in the testing kit (fixtures.ts, expect-stream.test.ts): `noExplicitAny` is turned off repo-wide in biome.json, so the comments had no effect (suppressions/unused warnings). The invalid-source test now casts through `unknown as never`. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Verified and fixed the findings from an independent code review: - #1 (correctness) expectStream dropped the wire `event:` name when the JSON payload carried its own `type` (spread ran after the assignment). Spread the payload first, then set `type = name ?? parsed.type`, so a frame like `event: error` + `data: {"type":"result"}` reports `error`. Regression test added. - #2 (contract) `@databricks/appkit/testing` eagerly loads vitest via fixtures even for `expectStream`, so vitest is a real requirement. Drop the "optional" peerDependenciesMeta and correct the docs sentence. - #6 (OBO fidelity) the fake `asUser` recorded `asUser: true` unconditionally. Enforce the real `Plugin.asUser` token precondition: a request without `x-forwarded-access-token` throws `missingToken` (missing user id throws too), and the resolved `userId` is recorded on each tool call. Tests now assert both directions (well-formed request vs token-less). - #3 (fidelity) attach() now mirrors AppKit core: registerPlugin plus registerToolProvider for real tool providers, without clobbering injected fakes. getPlugins()/getPluginNames()/hasPlugin() behave as in production. - #12 unknown-tool lookup used `tools[name] === undefined`, so a tool named "constructor"/"toString" hit Object.prototype. Use Object.hasOwn. - #5 drop data-less named SSE frames (real clients ignore them). - #7 re-export the PluginContext type from the testing barrel so MockPluginContext.ctx is nameable through the exports map. - #13 correct the docs: mock.telemetry captures the context's executeTool spans, not plugin-level spans (attachContext rebuilds the plugin's own telemetry). - #4 parseSSEResponse now delegates to the same parseSSEBody as expectStream — one parser, no divergence. All 3 analytics.integration call sites still pass. - #8 reformat template/server/example.test.ts with the template's Prettier so a scaffolded app's `npm run format` passes. - #10 fix the package-doc @example (agentsPlugin._handleStream does not exist). - #11 add kit tests that exercise attach() end-to-end (cache seed, isReady, registration, fake-not-clobbered). Build passes attw + publint; full appkit suite 3125 passed / 1 pre-existing skip. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
… dep With vitest declared as a (non-optional) peerDependency, knip recognizes it as used, so the earlier ignoreDependencies entry is unnecessary. This reverts knip.json to its original state. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
A required peerDependency has no per-subpath scope: it applied to the whole @databricks/appkit package, so every production consumer that never imports the testing kit got an unsatisfied peer (npm 7+ auto-installs vitest into their tree; pnpm warns) — a wider blast radius than the eager-import bug it was meant to fix. Follow appkit's own precedent instead: `vite` backs the ./type-generator subpath as a normal `dependency`, installed for everyone but loaded only by importers of that subpath. Do the same for `vitest` and ./testing. vitest is referenced solely by dist/testing/fixtures.js, never by the main/plugin/core entry, so a consumer importing createApp never loads it. Verified end-to-end: scaffolded an app whose own vitest (4.1.9) differs in major from appkit's dependency (3.2.4), forcing a nested second copy. The testing kit's vi.fn()/vi.spyOn() mocks and expect(...).toHaveBeenCalled() assertions work across the two instances (vi spies carry their own call state), and npm install emits no peer-dep warning. Build passes attw + publint. Also fold in the template example's Prettier formatting (template uses Prettier, not Biome) so a scaffolded app's `npm run format` passes. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The helper builds the REAL PluginContext with faked edges — it does not mock the context — so the name was misleading. Rename to createTestPluginContext (and the MockPluginContext type to TestPluginContext), matching the create*-for-tests convention, and rename the files to test-plugin-context.ts. Pre-merge and unreleased, so no external consumers are affected. Also finish the #13 doc-accuracy fix in the shipped JSDoc (not just the docs page): the telemetry field comment now states it captures the context's spans (executeTool), not plugin-internal spans — attachContext rebuilds the plugin's this.telemetry from the real TelemetryManager. These comments ship in dist/testing/*.d.ts, so IntelliSense previously showed the unqualified claim. Build passes attw + publint; full appkit suite 3125 passed / 1 pre-existing skip. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Behavior-preserving cleanups in the testing kit: - createMockRequest reuses createMockWorkspaceClient() instead of an inline copy of the same mock client (verified identical). - createMockServiceContext / createMockUserContext / mockServiceContext inline the createMockWorkspaceClient() call into the `||` fallback, so the mock client is built only when the caller did not supply one. - The fake asUser view spreads `...base` and overrides executeAgentTool rather than re-declaring getAgentTools. - expectStream's isSubsequence breaks once the expected sequence is fully matched. No semantic change; typecheck clean and all kit + migrated tests pass. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
- #1 (P1) The docs called vitest a peer dependency, but the manifest ships it under `dependencies` (the decision we landed on, matching how appkit ships `vite` for ./type-generator). Correct the docs to match: appkit installs vitest for you, and it loads only when you import ./testing. Manifest and docs now agree. - #2 (P2) expectStream buffered the source eagerly with no bound, so a non-terminating stream hung until the runner's own timeout. Add an optional `{ timeout }` that fails fast with a clear, kit-specific error; document it and cover both directions with tests. - #3 (P2) The fake asUser replicates asUser's token precondition but not the real dev-mode `DEV_OBO_FALLBACK_KEY` OTel marker (a module-private telemetry detail). Narrow the docs and JSDoc to say so and point users at the recorded asUser/userId fields instead of isDevOboFallback(). Build passes attw + publint; full appkit suite 3141 passed / 1 pre-existing skip. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Exercise @databricks/appkit/testing against real core plugins to validate it beyond the two agent proof sites and produce usage references: - analytics.kit.test.ts: cross-plugin executeTool via createTestPluginContext — OBO identity (asUser/userId), token-precondition rejection, and per-call timeout abort. Needs only the kit (no workspace/ServiceContext). - genie.kit.test.ts: drives the real _handleSendMessage SSE stream and asserts event order with expectStream(...).toEmit(...). Both add genuinely new coverage (streamed SSE order + OBO dispatch identity were untested). Full appkit suite 3145 passed / 1 pre-existing skip. Developer-experience notes (kit wins + friction, e.g. createMockResponse doesn't compose with expectStream) captured in internal/ for the milestone review. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
pkosiec
left a comment
There was a problem hiding this comment.
Testing kit - review
I tried this kit two ways: read it closely, then used it to write real tests for the analytics and genie plugins. Short version: I'd adopt it. Wrapping the real PluginContext and faking only the edges (telemetry, tool providers, routes) gives you things a hand-rolled { executeTool } stub can't - real timeout composition, the real asUser token check, real route recording. attach() and OBO both worked cleanly in a fresh test.
One gap stood out in real use, and a few smaller things are worth a look. Details are in the inline comments; here's the map:
Biggest lever
- The kit tests generators well, but you can't assert a plugin's real SSE route -
createMockResponsethrows the written bytes away. Fixing this is the single change that makes plugin testing feel complete. (see comment onfixtures.ts)
Worth deciding before wide use
vitestas a plain runtime dependency ships a test framework into every production install. An optional peer avoids that. (seepackage.json)
Convenience
- An
obooption oncreateMockRequest(+ show it in the docs example). - A
resetTestCache()so the documented cache advice is actually followable.
Polish
- The shipped example test uses an
any-cast instead of the documentedattach(). - Two service-context helpers differ only by a
createprefix. - A
useServiceContextMock()hook could remove the repeatedbeforeEach/afterEachblock.
| } | ||
| return this; | ||
| }), | ||
| write: vi.fn(function (this: Any) { |
There was a problem hiding this comment.
createMockResponse().write() throws the bytes away - it only flips headersSent. So when a plugin streams SSE from a route, there's no way to feed that output into expectStream. You're left scraping res.write.mock.calls and matching strings by hand.
Why it matters: most plugins stream from a route, not a bare generator. Today the kit tests generators well but can't assert what a real route emits. This was the #1 rough spot when I used the kit on the analytics plugin.
Suggestion: have createMockResponse collect the written chunks, then let expectStream read them directly:
await handler(req, res);
await expectStream(res).toEmit("warehouse_status", "result");The cleanest shape is for expectStream to accept the mock response (it's the kit's own type) and parse its captured buffer as SSE. If you'd rather leave expectStream untouched, expose res.sseResponse() that returns a real Response and pass that.
One gotcha: don't hand expectStream the raw body string - a string is an iterable of characters, so it would be consumed one char at a time. It needs the mock response or a Response, not text.
| "semver": "7.7.3", | ||
| "shared": "workspace:*", | ||
| "vite": "npm:rolldown-vite@7.1.14", | ||
| "vitest": "3.2.4", |
There was a problem hiding this comment.
vitest is a plain dependency, so it's installed into every app - including production installs that never import /testing. That pulls a whole test framework (and a second copy of vite) into code that never runs in production.
Why it matters: bigger installs, slower cold starts, and more supply-chain surface for nothing. The exact pin 3.2.4 can also clash with an app's own vitest, leaving two copies - and then the app's clearMocks/restoreMocks won't reset the spies this kit creates.
Heads-up: optionalDependencies would not fix this - npm and pnpm still install those by default (only --no-optional skips them).
Suggestion: make it an optional peer, and keep a dev copy for AppKit's own tests:
"peerDependencies": { "vitest": ">=3" },
"peerDependenciesMeta": { "vitest": { "optional": true } },
"devDependencies": { "vitest": "3.2.4" }An optional peer isn't auto-installed, so it stays out of apps that don't test. Anyone who imports /testing is already writing vitest tests, so they have it - and there's only ever one vitest instance. (A devDependency alone won't work: it isn't installed for consumers, so their own tests couldn't resolve vitest.) Pick a range that covers the vitest majors you test against - the vi.fn/vi.spyOn APIs the kit uses are the same in v3 and v4.
Bigger alternative, if we'd rather /testing need no test framework at all: drop import { vi } from fixtures.ts and hand-roll the small spy surface it uses. More work - mockServiceContext relies on vi.spyOn(ServiceContext, ...) - so the optional peer is the smaller, idiomatic fix.
| * WorkspaceClient (SQL succeeds, warehouse is RUNNING) on both the user and | ||
| * service-principal client slots; override any field via `overrides`. | ||
| */ | ||
| export function createMockRequest(overrides: Any = {}) { |
There was a problem hiding this comment.
createMockRequest() ships with empty headers, so every on-behalf-of test has to hand-add the same two headers (x-forwarded-access-token, x-forwarded-user) to make asUser resolve. The cross-plugin example in the docs doesn't show them, so copying it throws Missing user token.
Why it matters: OBO is the kit's headline feature. The first thing a reader tries fails, and everyone repeats the same header boilerplate.
Suggestion: add an obo option to createMockRequest so OBO is one flag, not hand-rolled headers - it keeps a single entry point instead of a second function to discover:
createMockRequest({ obo: true }); // default test user + token
createMockRequest({ obo: { userId: "alice" } }); // pick the userIt would set the two headers for you and still merge the other overrides. Then use it in the docs cross-plugin example. (A standalone createOboRequest(userId, token?) would also work, but the option keeps one request builder.)
|
|
||
| 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. | ||
|
|
||
| The cache `attach()` seeds is a process-wide singleton: `CacheManager` is initialized once per test process and reused. Vitest isolates test *files* in separate workers, so caches never leak across files, but tests **within one file** share it. If a test populates the cache and a later test in the same file must not see it, reset between tests (e.g. clear the cache in `beforeEach`). |
There was a problem hiding this comment.
The docs note the cache is a shared singleton: isolated between test files, but shared by every test within one file, and vitest does not clear it between those tests. The kit ships no helper to clear it.
Why it matters: a value one test caches can leak into the next test in the same file - a stale read or a false pass. The docs say to "clear the cache in beforeEach", but there is no API to do it. A reset is also handy within a single test - e.g. to check a cache miss, then a hit.
Suggestion: export an async resetTestCache() that wraps CacheManager.getInstanceSync().clear() (and no-ops when the cache isn't initialized yet, since getInstanceSync() throws otherwise). Show it in the beforeEach here.
| const mock = createTestPluginContext({ analytics: { query: "rows" } }); | ||
| const executeToolSpy = vi.spyOn(mock.ctx, "executeTool"); | ||
| // biome-ignore lint/suspicious/noExplicitAny: attach the real context to the plugin | ||
| (plugin as any).context = mock.ctx; |
There was a problem hiding this comment.
This sets (plugin as any).context = mock.ctx instead of the documented await mock.attach(plugin).
Why it matters: this is the example people copy. The cast skips cache seeding, the isReady flip, and registry wiring - so pasted onto a plugin that reads this.cache, it breaks. In a fresh test attach() just works (I confirmed it while dogfooding).
Suggestion: use attach() here. If you genuinely need to skip the wiring, add a small documented setter so the shipped pattern isn't an any-cast.
| * Builds a {@link ServiceContextState} for testing without touching the | ||
| * singleton. Use with {@link mockServiceContext} to install it. | ||
| */ | ||
| export function createMockServiceContext(options: TestContextOptions = {}) { |
There was a problem hiding this comment.
createMockServiceContext builds a value and installs nothing; mockServiceContext installs spies and returns restore(). They sit next to each other and differ only by the create prefix.
Why it matters: in autocomplete they look interchangeable. Pick the builder by mistake and the real singleton stays live, so the test fails in a confusing way.
Suggestion: rename to say what each does (e.g. buildServiceContextState vs installServiceContextSpies), or drop the unused builder from the public exports. If you add useServiceContextMock() (see the other comment on this file), these two become its lower-level building blocks - name them so that reads clearly. Renaming is free now, while the kit is new.
| // `Object.hasOwn`, not `tools[toolName] === undefined`: a tool named | ||
| // "constructor"/"toString"/etc. would otherwise resolve to an inherited | ||
| // Object.prototype method and be invoked instead of reported missing. | ||
| if (!Object.hasOwn(tools, toolName)) { |
There was a problem hiding this comment.
Three edges the kit deliberately handles have no test pinning them:
- the
Object.hasOwnguard here (a tool namedconstructor/toStringmust report "missing", not resolve to a prototype method), - the dev-mode branch of the fake
asUser(test-plugin-context.ts:277), which never runs because the suite is underNODE_ENV=test, - the scalar / array / non-JSON / multi-line
data:paths inparseSSEBody(expect-stream.ts:80).
Why it matters: each is a corner someone could quietly regress - the guard could be weakened to in/=== undefined and CI would stay green.
Suggestion: add one small case for each (a constructor tool name rejects; a NODE_ENV=development token-less request is allowed through; a data: 42 / data: [1] / data: plain text frame parses as expected).
| * | ||
| * @returns The mock context plus the spies and a `restore()` helper. | ||
| */ | ||
| export function mockServiceContext(options: TestContextOptions = {}) { |
There was a problem hiding this comment.
Every suite that mocks the service context repeats the same three steps: call mockServiceContext() in beforeEach, hold the handle, and call restore() in afterEach.
Why it matters: it's boilerplate in every service-context test, and forgetting the restore() leaks spies into the next test - a confusing, order-dependent failure.
Suggestion (small enough to add here): ship a useServiceContextMock(options?) that registers the setup and teardown for you:
useServiceContextMock(); // one line: auto beforeEach setup + afterEach restoreIt just wraps the existing mockServiceContext and calls vitest's beforeEach/afterEach, so it's a thin helper, not a rewrite. (This also leans on vitest being present - one more reason to settle it as the optional peer from the package.json comment.)
Two things to get right so it's actually usable: call it at the top of a describe, not inside a test (beforeEach/afterEach only register during collection); and return a live accessor, not the handle itself - each beforeEach builds fresh spies, so a returned snapshot goes stale. Hand back something like { get current() { ... } } so a test can still assert on the spies.
There was a problem hiding this comment.
I think it should be placed under plugins docs? as "Testing plugins". What do you think?
pkosiec
left a comment
There was a problem hiding this comment.
Round 2 - the new dogfood tests
Thanks for adding these. Both pass, and the analytics one adds real coverage that was missing (cross-plugin executeTool OBO identity).
Two asks:
- Don't keep them as separate
*.kit.test.tsfiles. The testing kit should be the default way to test a plugin, not a parallel track. Fold these intoanalytics.test.ts/genie.test.tsand drop the.kitsplit. For genie, that also means merging with the existing (presence-only) SSE test into one ordered assertion on the real event names. - Trim the comments. Both files carry a lot of explanatory narration that reads as slop; a
/desloppass when merging would cut it to what's needed.
Bonus signal: these files independently re-hit round 1 - a hand-rolled capturing response (C1), a re-invented service-context mock (C8), and hand-rolled OBO headers (C3). Two authors needing the same workarounds is the strongest case yet to land those kit ergonomics.
| import { describe, expect, test } from "vitest"; | ||
| import { createTestPluginContext } from "../../../testing"; | ||
|
|
||
| /** |
There was a problem hiding this comment.
Good tests, but two things about the file itself.
Don't keep them in a separate analytics.kit.test.ts. The kit should be the default way to test a plugin, not a parallel "kit" track. A separate file signals the kit is optional, splits the plugin's suite in two, and leaves it unclear where a new test should go. Fold these three tests into analytics.test.ts and delete this file - the coverage (cross-plugin executeTool OBO identity) is genuinely new, so keep it, just not as its own file.
Trim the narration. The docblock and inline comments explain the exercise at length; it reads as slop. A /deslop pass when you merge would cut it to the essentials.
Why it matters: if this is how we want people to test, the tests should look like normal plugin tests - same file, minimal ceremony - not a heavily annotated "kit demo".
| import { createTestPluginContext, expectStream } from "../../../testing"; | ||
| import { type GeniePlugin, genie } from "../genie"; | ||
|
|
||
| /** |
There was a problem hiding this comment.
Same as the analytics file: merge this into genie.test.ts rather than keep a separate genie.kit.test.ts, and trim the heavy comment narration (/deslop) while you do.
One extra reason to merge: genie.test.ts already tests this SSE path ("should call startConversation and stream SSE events"), but only checks event presence via mockRes.write.mock.calls.join("").toContain(...) - no ordering, brittle substrings. This file adds ordering via expectStream. Two tests for one path is worse than one good one.
Suggestion: replace that existing test's assertion block with expectStream(...).toEmit(...) on genie's real event names, and delete this file.
| * kit's own `createMockResponse` throws written chunks away, so streaming | ||
| * handler tests need this until the kit ships a capturing response. | ||
| */ | ||
| function createCapturingResponse() { |
There was a problem hiding this comment.
For whoever lands the kit changes: this local createCapturingResponse is exactly the createMockResponse gap from the first review (C1), now hand-rolled a second time. Two dogfood files needing the same bridge is the signal to ship a capturing response in the kit and drop these local copies.
|
|
||
| // A minimal ServiceContext stand-in (the kit's mockServiceContext fixture | ||
| // covers this, but genie's streaming path only needs a resolvable context). | ||
| function mockServiceContextLite() { |
There was a problem hiding this comment.
Also re-invents mockServiceContext plus a manual restore - the boilerplate the proposed useServiceContextMock() (C8) removes. When this folds into genie.test.ts, use the kit fixture instead of a local stub.
| streamSendMessage: (...a: unknown[]) => AsyncGenerator<unknown>; | ||
| }; | ||
| } | ||
| ).genieConnector.streamSendMessage = async function* () { |
There was a problem hiding this comment.
When you merge this in, assert genie's real SSE event names. The fake connector here yields status/message/complete, but genie actually emits message_start/status/message_result/query_result (see the existing test). Ordering on invented names won't catch a reorder or drop of the real ones.
| * workspace-client fixtures because that work lives behind those seams.) | ||
| */ | ||
|
|
||
| function mockReq(headers: Record<string, string>): express.Request { |
There was a problem hiding this comment.
Minor: another hand-rolled OBO request with the two headers - more evidence for a createMockRequest({ obo }) option (C3).
Resolve the eight review comments on the testing kit: - createMockResponse now captures written SSE bytes and exposes sseResponse(); expectStream reads a captured mock response directly, so streaming-route tests no longer need a hand-rolled bridge. - Ship vitest as an optional peer dependency (+ devDependency) instead of a plain runtime dependency, keeping the test framework out of production installs and deduping to the app's own copy. Ignore it in knip. - Add an obo option to createMockRequest so on-behalf-of tests set the forwarded identity headers with one flag. - Add resetTestCache() to clear the shared cache singleton between tests. - Use the documented attach() instead of an any-cast in the agents dispatch tests. - Drop the unused createMockServiceContext/createMockUserContext builders from the public surface; keep the service-context builder internal. - Pin the previously untested edges: the Object.hasOwn tool-lookup guard, the dev-mode asUser branch, and parseSSEBody's non-object data values. - Add useServiceContextMock() to register the mock lifecycle in one line, returning a live accessor. Dogfood the new helpers in the analytics, genie, and serving suites, and document them in the testing guide. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The testing kit is entirely plugin-scoped (createTestPluginContext, attach(plugin), plugin route/tool/SSE assertions), and the page's own cross-links already pointed into plugins/. Move it next to custom-plugins and fix the relative links. Keep the heading as 'Testing'; the Plugins section supplies the context. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Address round-2 review: the kit should be the default way to test a plugin, not a parallel '*.kit.test.ts' track. - Fold the three cross-plugin executeTool OBO tests into analytics.test.ts and delete analytics.kit.test.ts. - Upgrade genie.test.ts's SSE test to assert event ORDER via expectStream on genie's real event names (message_start, status, message_result, query_result), replacing brittle write.mock.calls substring checks, and delete genie.kit.test.ts. - Trim the heavy comment narration from the folded-in tests. - Re-export createTestPluginContext and expectStream from the test-helpers shim. - Finish the testing-guide move under plugins/ (sidebar position + links). Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The dogfood fold trimmed expect(mock.toolCalls).toHaveLength(1), so a double-dispatch would no longer fail the happy-path test — and it was inconsistent with the token-less sibling that kept toHaveLength(0). Restore it. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The toEmit swap pinned event order but dropped the payload values the old substring checks covered (conversationId=new-conv-id, status=ASKING_AI), which aren't asserted elsewhere. Restore them structurally via collect() + toMatchObject — keeping the ordering guarantee without brittle substrings. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Testing Kit
tools/test-helpers.tsintopackages/appkit/src/testing/expectStream(...).toEmit(...).docs/development/testing.md), a template example test.