From a0c37dcfaca0e8e07e1528afc5e0e5202f7e12fa Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 01:18:17 +0000 Subject: [PATCH 1/2] feat(test): export loadRouteModule(id) from agent-bundle/test (#493) --- .changeset/493-load-route-module.md | 5 + packages/agent-bundle/README.md | 8 +- packages/agent-bundle/src/test/index.ts | 7 +- packages/agent-bundle/src/test/render.ts | 99 +++++++++++++-- .../tests/route-register-typegen.test.ts | 20 +++ .../route-unit/load-route-module.test.ts | 114 ++++++++++++++++++ website/docs/en/guide/development/testing.mdx | 25 ++++ website/docs/zh/guide/development/testing.mdx | 21 ++++ 8 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 .changeset/493-load-route-module.md create mode 100644 packages/agent-bundle/tests/route-unit/load-route-module.test.ts diff --git a/.changeset/493-load-route-module.md b/.changeset/493-load-route-module.md new file mode 100644 index 000000000..f933c12c8 --- /dev/null +++ b/.changeset/493-load-route-module.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Export `loadRouteModule(id)` from `agent-bundle/test`: the evaluated module behind one compiled route id, through the same registered loader `renderRoute` uses, so `inputSchema`, `resultSchema`, `config`, and `default` are the route's own exports by reference and a schema-identity suite can iterate `testManifest().routes` instead of maintaining static route imports. A literal id is checked against the registered route ids and the schemas' parsed values are typed from the registration; outside an `agentBundleRstest()` pool or against a foreign manifest it fails closed with `manifest-unavailable`. Fixes #493. (#499) diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 28dac3b21..655a0efbf 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -508,7 +508,13 @@ its own. `testManifest()` exposes the compiled route inventory, so a suite can iterate every route in process rather than paying for a build per route. Every failure — an unknown route, a refused route kind, a rejected input, a render error — names the route id, the target -kind, and the module provenance. +kind, and the module provenance. `loadRouteModule(id)` returns the evaluated +module behind one compiled id through the same registered loader `renderRoute` +uses — the module object itself, so `inputSchema`, `resultSchema`, `config`, +and `default` are the route's own exports by reference — which replaces a +hand-maintained list of static route imports in a schema-identity suite; it +fails closed with `manifest-unavailable` outside an `agentBundleRstest()` pool +or against a manifest the registered loaders did not come from. The same generated `.agent-bundle/routes.d.ts` registers the route contracts on `@agent-bundle/runtime`'s `Register` interface. With that file in the project's diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index 55b37511e..bacda7396 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -8,7 +8,7 @@ * * | level | helper | what it proves | * | --- | --- | --- | - * | `route-unit` | `renderRoute`, `renderRouteEvents`, `createTargetCapabilityFixture`, `projectTargetCapabilities` | the route component and its document through the real Agent renderer; explicit target-capability projection through the real MCP projector, without transport or host proof | + * | `route-unit` | `renderRoute`, `renderRouteEvents`, `loadRouteModule`, `createTargetCapabilityFixture`, `projectTargetCapabilities` | the route component and its document through the real Agent renderer (and the evaluated route module itself, by compiled id); explicit target-capability projection through the real MCP projector, without transport or host proof | * | `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface`, `runContractMatrix` | the real generated MCP server's protocol contract, over the SDK's in-memory transport; MCP App routes are not registered and report `not-applicable` | * | `dev-epoch` | `runDevEpochContractMatrix` | an epoch-pinned generated stdio process opened through the Workbench session service; MCP App routes are covered (surface + `ui://` sweep) and auto-covered without a fixture | * | `cli-dispatch` | `invokeCli`, `cliJson`, `cliNdjson` | a compiled plain or rendered CLI command dispatched through the routed CLI's own shell, including rendered output modes, in this process | @@ -61,9 +61,12 @@ export { AGENT_TEST_REGISTRY_VERSION, registerTestRoutes, testManifest } from '. export type { AgentProviderModuleLoader, AgentTestRouteRegistry } from './registry.ts'; export { AgentTestError } from './errors.ts'; export type { AgentTestErrorCode } from './errors.ts'; -export { renderRoute, renderRouteEvents } from './render.ts'; +export { loadRouteModule, renderRoute, renderRouteEvents } from './render.ts'; export type { HarnessOptionsArguments, + LoadRouteModuleOptions, + LoadedRouteModule, + RouteModuleSchema, RenderRouteContext, RenderRouteContextInit, RenderRouteOptions, diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 1432b704b..8c5de7e71 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -44,6 +44,7 @@ import { } from './registry.ts'; import type { AgentRouteModule, + AgentRouteSchema, RenderableRouteKind, RenderedRouteProvenance, TestableRouteDescriptor, @@ -434,9 +435,37 @@ const resolveTarget = async ( return { component: componentOf(target, provenance), kind, layouts: [], module: target, provenance }; } const manifest = options.manifest ?? testManifest(); - const descriptor = manifest.routes[target]; + const loaded = await loadManifestRouteModule(manifest, target); + const layouts = await loadLayoutChain(manifest, loaded.descriptor, loaded.provenance); + return { + component: componentOf(loaded.module, loaded.provenance), + kind: loaded.kind, + layouts, + manifest, + module: loaded.module, + provenance: loaded.provenance, + }; +}; + +interface LoadedManifestRoute { + readonly descriptor: TestableRouteDescriptor; + readonly kind: RenderableRouteKind; + readonly module: AgentRouteModule; + readonly provenance: RenderedRouteProvenance; +} + +/** + * Resolves one compiled route id of `manifest` to its evaluated module through + * the loader the generated setup registered — the one path every manifest + * render takes, and the one `loadRouteModule` exposes on its own. + */ +const loadManifestRouteModule = async ( + manifest: AgentBundleTestManifest, + routeId: string, +): Promise => { + const descriptor = manifest.routes[routeId]; if (descriptor === undefined) { - throw new AgentTestError('route-not-found', `No compiled route is named ${JSON.stringify(target)}.`, { + throw new AgentTestError('route-not-found', `No compiled route is named ${JSON.stringify(routeId)}.`, { details: [ `project root: ${manifest.projectRoot}`, `compiled: ${knownRouteIds(manifest)}`, @@ -490,15 +519,63 @@ const resolveTarget = async ( ); } const module = await loader(); - const layouts = await loadLayoutChain(manifest, descriptor, { ...provenance, kind }); - return { - component: componentOf(module, { ...provenance, kind }), - kind, - layouts, - manifest, - module, - provenance: { ...provenance, kind }, - }; + return { descriptor, kind, module, provenance: { ...provenance, kind } }; +}; + +/** A route schema whose parsed value is typed from the route's registration once the id is registered. */ +export interface RouteModuleSchema extends AgentRouteSchema { + readonly parse: (value: unknown) => Value; +} + +/** + * The evaluated module `loadRouteModule` returns: the same object the generated + * server, the routed CLI, and `renderRoute` execute, so `inputSchema` and + * `resultSchema` are the route's own schema instances by reference (not copies + * or JSON), `config` is the module's static config export, and `default` its + * component — absent for a plain `src/scripts/*.ts` module, whose contract is + * `main`. Any other named export is reachable through the index signature. + */ +export interface LoadedRouteModule { + readonly [exportName: string]: unknown; + readonly config?: unknown; + readonly default?: (props: never) => unknown; + readonly inputSchema?: RouteModuleSchema>; + readonly resultSchema?: RouteModuleSchema>; +} + +export interface LoadRouteModuleOptions { + /** Loads against an explicit manifest instead of the one the generated configuration registered. */ + readonly manifest?: AgentBundleTestManifest; +} + +/** + * Loads the evaluated module of one compiled route by its id, through the + * lazy loader the generated Rstest setup registered for it — the loader + * `renderRoute` uses. This is the supported replacement for a hand-maintained + * list of static `import * as m from '../../src/mcp//tools/'` + * statements in a schema-identity suite: iterate `testManifest().routes` and + * load each id instead (#493). + * + * The id is checked against the registered route ids exactly as `renderRoute` + * checks its target, so a removed placement is a type error. Outside a pool + * built with `agentBundleRstest()` it throws `manifest-unavailable`, and a + * manifest describing another project rejects with the same mismatch report + * `renderRoute` gives: route loaders are bound to the compilation that + * produced them. Every renderable kind loads — tools, resources, prompts, + * event routes, CLI commands, and scripts; App routes are browser builds and + * are not loadable here (`unsupported-route-kind`). + */ +export const loadRouteModule = async ( + routeId: (Target & RouteTargetConstraint) | RouteTargetConstraint, + ...[options = {}]: HarnessOptionsArguments +): Promise> => { + const manifest = options.manifest ?? testManifest(); + // The loader returns the module namespace object itself. Its shape is not + // checked here: a plain script legitimately has no default export, and the + // render and dispatch levels already report a module that breaks their own + // contract with the route's provenance. + const loaded = await loadManifestRouteModule(manifest, routeId as string); + return loaded.module as LoadedRouteModule; }; /** diff --git a/packages/agent-bundle/tests/route-register-typegen.test.ts b/packages/agent-bundle/tests/route-register-typegen.test.ts index 8b1597480..7a7aa287e 100644 --- a/packages/agent-bundle/tests/route-register-typegen.test.ts +++ b/packages/agent-bundle/tests/route-register-typegen.test.ts @@ -144,6 +144,7 @@ it('types every route-aware public surface from the generated route registration ' getMcpPrompt,', ' invokeCli,', ' invokeMcpTool,', + ' loadRouteModule,', ' renderRoute,', ' renderRouteEvents,', ' runContractMatrix,', @@ -223,7 +224,17 @@ it('types every route-aware public surface from the generated route registration " expectNoMcpCall({ server: 'curator' });", " expectNoMcpCall({ server: 'github', tool: 'search_issues' });", " expectMcpCall({ server: dynamic, tool: dynamic });", + ' // loadRouteModule checks its id the same way and types the schemas\' parsed values from the registration.', + " const found_module = await loadRouteModule('tool:curator/find');", + " const parsedQuery: string | undefined = found_module.inputSchema?.parse({ query: 'dune' }).query;", + ' const parsedHits: number | undefined = found_module.resultSchema?.parse({ hits: 1 }).hits;', + ' const looseModule = await loadRouteModule(dynamic);', + ' const looseParsed: unknown = looseModule.resultSchema?.parse({});', + ' // A conventional script is loadable by literal even though scripts are not registered.', + " const script = await loadRouteModule('script:anything');", + ' const scriptParsed: unknown = script.resultSchema?.parse({});', ' void hits; void status; void none; void anything; void packed; void executed; void isReport;', + ' void parsedQuery; void parsedHits; void looseParsed; void scriptParsed;', '};', '', ].join('\n')), @@ -292,6 +303,11 @@ it('types every route-aware public surface from the generated route registration "export const missing = renderRoute('tool:curator/missing');", '', ].join('\n')), + writeProjectFile(root, 'wrong-load-id.ts', [ + "import { loadRouteModule } from 'agent-bundle/test';", + "export const missing = loadRouteModule('tool:curator/missing');", + '', + ].join('\n')), writeProjectFile(root, 'wrong-input.ts', [ "import { renderRoute } from 'agent-bundle/test';", "export const mistyped = renderRoute('tool:curator/find', { input: { query: 7 } });", @@ -336,6 +352,10 @@ it('types every route-aware public surface from the generated route registration expect(wrongId).toHaveLength(1); // The rejection names the registered ids, not `never`. expect(wrongId[0]).toContain(`Argument of type '"tool:curator/missing"' is not assignable to parameter of type '${registeredIds.map((id) => `"${id}"`).join(' | ')}'`); + const wrongLoadId = typecheck(root, 'wrong-load-id.ts', true); + expect(wrongLoadId).toHaveLength(1); + // Scripts are not registered, so a `script:` literal is admitted beside the registered ids. + expect(wrongLoadId[0]).toContain(`Argument of type '"tool:curator/missing"' is not assignable to parameter of type '${registeredIds.map((id) => `"${id}"`).join(' | ')} | \`script:\${string}\`'`); const wrongInput = typecheck(root, 'wrong-input.ts', true); expect(wrongInput).toHaveLength(1); expect(wrongInput[0]).toContain("Type 'number' is not assignable to type 'string'"); diff --git a/packages/agent-bundle/tests/route-unit/load-route-module.test.ts b/packages/agent-bundle/tests/route-unit/load-route-module.test.ts new file mode 100644 index 000000000..d9c73acc2 --- /dev/null +++ b/packages/agent-bundle/tests/route-unit/load-route-module.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from '@rstest/core'; +import { z } from 'zod'; + +import * as Report from '../../fixtures/route-harness/src/cli/report.tsx'; +import * as Catalog from '../../fixtures/route-harness/src/mcp/harness/tools/catalog.tsx'; +import * as Summary from '../../fixtures/route-harness/src/scripts/summary.tsx'; +import { AgentTestError } from '../../src/test/errors.ts'; +import { loadRouteModule, renderRoute } from '../../src/test/render.ts'; +import { testManifest } from '../../src/test/registry.ts'; + +const rejection = async (load: Promise): Promise => { + try { + await load; + } catch (thrown: unknown) { + return thrown as AgentTestError; + } + throw new Error('The load resolved, so no harness diagnostic was produced.'); +}; + +/** + * `loadRouteModule` (#493) is the supported replacement for a hand-maintained + * list of static route imports in a schema-identity suite: the evaluated + * module comes back through the same registered loader `renderRoute` uses, so + * its schemas are the route's own instances, not copies. + */ +describe('loadRouteModule', () => { + it('returns the evaluated tool module whose schemas are the very instances the route exports', async () => { + const module = await loadRouteModule('tool:harness/catalog'); + + // Identity, not shape: the same objects the statically imported module holds. + expect(module.inputSchema).toBe(Catalog.inputSchema); + expect(module.resultSchema).toBe(Catalog.resultSchema); + expect(module.default).toBe(Catalog.default); + expect(module.inputSchema).toBeInstanceOf(z.ZodObject); + expect(module.resultSchema).toBeInstanceOf(z.ZodObject); + expect(typeof module.default).toBe('function'); + // The static config the compiler extracted into the manifest is the module's. + expect(module.config).toEqual(testManifest().routes['tool:harness/catalog']!.config); + }); + + it('loads every renderable route id the manifest reports, including CLI commands and scripts', async () => { + const manifest = testManifest(); + // Loading evaluates the module exactly as an import does. The fixture's + // scripts include deliberately broken, blank, self-executing, and + // never-settling modules for the script-dispatch level, so scripts are + // loaded by name below and every other renderable kind is swept here. + const loadable = Object.values(manifest.routes) + .filter((route) => route.kind !== 'app' && route.kind !== 'script'); + expect(loadable.map((route) => route.kind)).toEqual(expect.arrayContaining(['cli', 'event-route', 'prompt', 'resource', 'tool'])); + + for (const route of loadable) { + const module = await loadRouteModule(route.id); + expect(typeof module.default, route.id).toBe('function'); + // The compiler records `{}` for a module that exports no static config. + expect(module.config ?? {}, route.id).toEqual(route.config); + } + + const report = await loadRouteModule('cli:report'); + expect(report.inputSchema).toBe(Report.inputSchema); + expect(report.resultSchema).toBe(Report.resultSchema); + expect(report.config).toBe(Report.config); + + // A rendered script default-exports its component; a plain `.ts` script's + // contract is `main`, so `default` is not required of it. + const summary = await loadRouteModule('script:summary'); + expect(summary.default).toBe(Summary.default); + expect(summary.inputSchema).toBeUndefined(); + + const checksum = await loadRouteModule('script:checksum'); + expect(checksum.default).toBeUndefined(); + expect(typeof checksum['main']).toBe('function'); + }); + + it('returns one module instance per route, the one renderRoute renders', async () => { + const [first, second] = await Promise.all([ + loadRouteModule('tool:harness/echo'), + loadRouteModule('tool:harness/echo'), + ]); + expect(first).toBe(second); + + // The rendered document's value parses through the very schema the loaded + // module exports, so the harness and the consumer agree on one contract. + const rendered = await renderRoute('tool:harness/echo', { input: { message: 'identity' } }); + expect(first.resultSchema!.parse(rendered.document.value)).toEqual(rendered.result); + }); + + it('rejects an id the manifest does not compile with the compiled ids', async () => { + const error = await rejection(loadRouteModule('tool:harness/missing')); + + expect(error).toBeInstanceOf(AgentTestError); + expect(error.code).toBe('route-not-found'); + expect(error.message).toContain('tool:harness/catalog'); + }); + + it('refuses an App route, which is a browser build', async () => { + const appId = Object.values(testManifest().routes).find((route) => route.kind === 'app')?.id; + expect(appId).toBeDefined(); + + const error = await rejection(loadRouteModule(appId!)); + expect(error.code).toBe('unsupported-route-kind'); + }); + + it('fails closed for a manifest whose loaders are not the registered ones', async () => { + const compiled = testManifest(); + const foreign = { ...compiled, digest: `${compiled.digest}-foreign`, projectRoot: `${compiled.projectRoot}-sibling` }; + + const error = await rejection(loadRouteModule('tool:harness/catalog', { manifest: foreign })); + + expect(error).toBeInstanceOf(AgentTestError); + expect(error.code).toBe('manifest-unavailable'); + expect(error.message).toContain('not the ones registered in this test process'); + expect(error.message).toContain(`registered: ${compiled.digest}`); + }); +}); diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index 6eaa8c97c..45e795419 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -109,6 +109,31 @@ process rather than paying for a build per route. Every failure — an unknown r route kind, a rejected input, a render error — names the route id, the target kind, and the module provenance. +`loadRouteModule(id)` returns the evaluated module behind one of those ids, through the same lazy +loader `renderRoute` uses. It is the module object itself, so `inputSchema` and `resultSchema` are +the route's own schema instances by reference, `config` is its static config export, and `default` +its component (a plain `.ts` script exports `main` instead). A schema-identity suite therefore +needs no hand-maintained list of static route imports: iterate `testManifest().routes` and load +each id. + +```ts +import { loadRouteModule, testManifest } from 'agent-bundle/test'; + +for (const route of Object.values(testManifest().routes)) { + if (route.kind === 'app') continue; // browser builds load at the browser level + const module = await loadRouteModule(route.id); + expect(module.inputSchema).toBeInstanceOf(z.ZodObject); + expect(module.config ?? {}).toEqual(route.config); +} +``` + +A literal id is checked against the registered ids exactly as `renderRoute` checks its target, and +the schemas' parsed values are typed from the registration, so a removed placement is a type error. +Loading evaluates the module as an import would. Outside a pool built with `agentBundleRstest()` +the call fails closed with `manifest-unavailable`; a manifest describing another project rejects +with the same mismatch report `renderRoute` gives, because route loaders are bound to the +compilation that produced them. + Matchers over the Agent Document contracts: `toHaveStatus`, `toContainMarkdown`, `toContainText`, `toHaveValue`, `toHaveError`, and `toHaveNodeKinds`. diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index f18096d0a..760e1bb2a 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -93,6 +93,27 @@ const chapters: number | undefined = result?.chapters; // 无需强制类型转 一次构建的代价。任何失败——未知路由、被拒绝的路由种类、被拒的输入、渲染错误——都会指明 route id、target 种类与模块 provenance。 +`loadRouteModule(id)` 通过 `renderRoute` 所用的同一个惰性加载器,返回某个 id 背后已求值的模块。它就是模块 +对象本身,因此 `inputSchema` 与 `resultSchema` 按引用就是路由自己的 schema 实例,`config` 是它的静态配置 +导出,`default` 是它的组件(普通 `.ts` 脚本导出的是 `main`)。于是 schema 一致性套件不再需要手工维护一份 +静态路由导入列表:遍历 `testManifest().routes` 并逐个加载即可。 + +```ts +import { loadRouteModule, testManifest } from 'agent-bundle/test'; + +for (const route of Object.values(testManifest().routes)) { + if (route.kind === 'app') continue; // 浏览器构建在浏览器级别加载 + const module = await loadRouteModule(route.id); + expect(module.inputSchema).toBeInstanceOf(z.ZodObject); + expect(module.config ?? {}).toEqual(route.config); +} +``` + +字面量 id 会像 `renderRoute` 检查其目标那样对照已注册的 id 进行检查,schema 的解析结果也从注册中获得类型, +因此被移除的放置会成为类型错误。加载会像 import 一样对模块求值。在不是由 `agentBundleRstest()` 构建的 +测试池之外,调用会以 `manifest-unavailable` 关闭式失败;描述另一个项目的 manifest 会给出与 `renderRoute` +相同的不匹配报告并拒绝,因为路由加载器绑定到产生它们的那次编译。 + 针对 Agent Document 契约的匹配器:`toHaveStatus`、`toContainMarkdown`、`toContainText`、 `toHaveValue`、`toHaveError` 与 `toHaveNodeKinds`。 From 15fe1accddb88b0bcff49c007ebcf2ab82530819 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 02:35:27 +0000 Subject: [PATCH 2/2] fix(test): admit script: literals in loadRouteModule; filter the docs example to schema-backed routes (#493) --- packages/agent-bundle/src/test/index.ts | 1 + packages/agent-bundle/src/test/render.ts | 17 +++++++++++++++-- website/docs/en/guide/development/testing.mdx | 12 +++++++++--- website/docs/zh/guide/development/testing.mdx | 11 ++++++++--- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index bacda7396..4637fb8ce 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -64,6 +64,7 @@ export type { AgentTestErrorCode } from './errors.ts'; export { loadRouteModule, renderRoute, renderRouteEvents } from './render.ts'; export type { HarnessOptionsArguments, + LoadRouteModuleConstraint, LoadRouteModuleOptions, LoadedRouteModule, RouteModuleSchema, diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 8c5de7e71..58ef0450b 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -548,6 +548,18 @@ export interface LoadRouteModuleOptions { readonly manifest?: AgentBundleTestManifest; } +/** + * The constraint one `loadRouteModule` id must satisfy. Conventional scripts + * are loadable but are not part of the generated route registration + * (`.agent-bundle/routes.d.ts` registers tool, resource, prompt, CLI, and + * event routes), so a `script:` literal is admitted unchecked while every + * other literal is checked against the registered ids exactly as `renderRoute` + * checks its target; a value typed `string` stays legal for dynamic lookups, + * and without a registration every string is legal. The union is spelled + * inline so a rejection lists the registered ids rather than an alias name. + */ +export type LoadRouteModuleConstraint = string extends Target ? string : RegisteredRouteId | `script:${string}`; + /** * Loads the evaluated module of one compiled route by its id, through the * lazy loader the generated Rstest setup registered for it — the loader @@ -557,7 +569,8 @@ export interface LoadRouteModuleOptions { * load each id instead (#493). * * The id is checked against the registered route ids exactly as `renderRoute` - * checks its target, so a removed placement is a type error. Outside a pool + * checks its target, so a removed placement is a type error (`script:` ids are + * not registered and pass unchecked; see {@link LoadRouteModuleConstraint}). Outside a pool * built with `agentBundleRstest()` it throws `manifest-unavailable`, and a * manifest describing another project rejects with the same mismatch report * `renderRoute` gives: route loaders are bound to the compilation that @@ -566,7 +579,7 @@ export interface LoadRouteModuleOptions { * are not loadable here (`unsupported-route-kind`). */ export const loadRouteModule = async ( - routeId: (Target & RouteTargetConstraint) | RouteTargetConstraint, + routeId: (Target & LoadRouteModuleConstraint) | LoadRouteModuleConstraint, ...[options = {}]: HarnessOptionsArguments ): Promise> => { const manifest = options.manifest ?? testManifest(); diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index 45e795419..f98913ce9 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -119,17 +119,23 @@ each id. ```ts import { loadRouteModule, testManifest } from 'agent-bundle/test'; -for (const route of Object.values(testManifest().routes)) { - if (route.kind === 'app') continue; // browser builds load at the browser level +// Schemas are the MCP route contract; event routes and scripts export none. +const mcpRoutes = Object.values(testManifest().routes) + .filter((route) => route.kind === 'tool' || route.kind === 'prompt' || route.kind === 'resource'); + +for (const route of mcpRoutes) { const module = await loadRouteModule(route.id); expect(module.inputSchema).toBeInstanceOf(z.ZodObject); + expect(module.resultSchema).toBeInstanceOf(z.ZodObject); expect(module.config ?? {}).toEqual(route.config); } ``` A literal id is checked against the registered ids exactly as `renderRoute` checks its target, and the schemas' parsed values are typed from the registration, so a removed placement is a type error. -Loading evaluates the module as an import would. Outside a pool built with `agentBundleRstest()` +Conventional scripts are loadable but are not part of the registration, so a `script:` literal is +accepted unchecked. Loading evaluates the module as an import would — a self-executing plain script +runs. Outside a pool built with `agentBundleRstest()` the call fails closed with `manifest-unavailable`; a manifest describing another project rejects with the same mismatch report `renderRoute` gives, because route loaders are bound to the compilation that produced them. diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index 760e1bb2a..bc657b433 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -101,16 +101,21 @@ const chapters: number | undefined = result?.chapters; // 无需强制类型转 ```ts import { loadRouteModule, testManifest } from 'agent-bundle/test'; -for (const route of Object.values(testManifest().routes)) { - if (route.kind === 'app') continue; // 浏览器构建在浏览器级别加载 +// schema 是 MCP 路由的契约;事件路由与脚本不导出 schema。 +const mcpRoutes = Object.values(testManifest().routes) + .filter((route) => route.kind === 'tool' || route.kind === 'prompt' || route.kind === 'resource'); + +for (const route of mcpRoutes) { const module = await loadRouteModule(route.id); expect(module.inputSchema).toBeInstanceOf(z.ZodObject); + expect(module.resultSchema).toBeInstanceOf(z.ZodObject); expect(module.config ?? {}).toEqual(route.config); } ``` 字面量 id 会像 `renderRoute` 检查其目标那样对照已注册的 id 进行检查,schema 的解析结果也从注册中获得类型, -因此被移除的放置会成为类型错误。加载会像 import 一样对模块求值。在不是由 `agentBundleRstest()` 构建的 +因此被移除的放置会成为类型错误。约定式脚本可以加载,但不属于注册的一部分,因此 `script:` 字面量会不经检查地 +被接受。加载会像 import 一样对模块求值——自执行的普通脚本会运行。在不是由 `agentBundleRstest()` 构建的 测试池之外,调用会以 `manifest-unavailable` 关闭式失败;描述另一个项目的 manifest 会给出与 `renderRoute` 相同的不匹配报告并拒绝,因为路由加载器绑定到产生它们的那次编译。