Testing and validation
Validation rules applied at load time and the test helpers packages can use.
Two layers keep a package honest. At load time, the host validates the
manifest and every contribution before anything registers — a package that
fails validation does not load. At development time,
@neuralis/package-system/testing provides the shared helpers every
first-party suite uses, so your tests exercise the same contract shapes the
runtime dispatches.
Load-time validation
| Validator | What it enforces |
|---|---|
validateManifest | Identity present, package id not in the reserved host-route set, runtime/trust consistency (in-process node is first-party-only; untrusted packages cannot declare exec, full network, or full filesystem permissions; skill-script execution above 'none' requires at least trusted), URI policy namespace rejection, requires.* shape checks |
validateContribution | Per-tool schema checks, duplicate tool/widget/dock/card/command/connector ids, the renderer-by-trust matrix for App surfaces, the full source-connector contract (kind, configSchema, mandatory list+read capabilities, factory format, scope rules), requires shape on files and commands |
validateToolSchema | MCP root shape ($schema, title, type: "object", root additionalProperties: false), reserved session-context keys rejected as top-level input properties (root-only), legacy extensions rejected, open-bag normalisation — see Tools |
validateXNeuralis | The canonical x-neuralis extension: dotted operation, fixed transport enum, annotations shape, mandatory risk category for native tools |
validateWorkflowTemplate | Every workflows/*.json template, strictly (additionalProperties: false throughout): the trigger and execution-policy shapes, engine-owned input keys rejected, choice inputs and their defaults — see Workflow templates |
validateRequiresShape | The one shared requires shape check the tool, file, command, and workflow validators all call, so a feature gate is spelled identically everywhere |
Validation results are structured ({ valid, errors, warnings }) — errors
block registration, warnings surface in logs.
The testing toolkit
Everything below imports from @neuralis/package-system/testing and runs
under vitest.
Session factories
| Helper | Returns |
|---|---|
createMockSession(overrides?) | Full-access SessionContext — owner role, ['*'] features |
createDeniedSession(overrides?) | Zero feature grants — every gate denies |
createTrustedSession(overrides?) | Member role with a specific (non-wildcard) feature list |
createViewerSession(overrides?) | Viewer role, read-only agent access, no grants |
All four accept partial overrides, so boundary tests pin exactly the fields they care about (see Sessions for the shape).
Request and context builders
Route helpers build the PackageRouteRequest your handler receives —
routeGet(path, query?, session?), routePost(path, body?, session?),
routePatch(path, body?, session?), routeDelete(path, session?) (path is
the segment array after /api/packages/<id>/), plus the generic
createMockRouteRequest(overrides?). You then call the handler directly:
import { describe, it, expect } from 'vitest';
import { routeGet, createDeniedSession } from '@neuralis/package-system/testing';
import { GET } from '../src/routes/items';
describe('items route', () => {
const state = { db: { listItems: async () => [{ id: '1' }] } };
it('returns items for an authorized caller', async () => {
const res = await GET(routeGet(['items'], undefined, { grantedFeatures: ['my-pkg.read'] }), state);
expect(res.status).toBe(200);
});
it('denies a caller without the feature', async () => {
const res = await GET(routeGet(['items'], undefined, createDeniedSession()), state);
expect(res.status).toBe(403);
});
});For tools and lifecycle code:
createMockToolCallContext()/createDeniedToolCallContext()— the context a tool handler receives (session+ init context), withsessionOverrides/ctxOverridesknobs.createMockInitContext({ packageSlug?, dataDir?, globalDir?, ... })— a first-party context by default (globalDir: nullbuilds a project-installed package's context) — aPackageInitContextwith a realFileStore/JsonlAppenderand a workingdataPathwired into a temp directory, so a test exercises the real scoping rather than a permissive double.createMockPackagesAccessor({ '@scope/pkg': api })— cross-package API stubbing forgetPackageApi()consumers.createDiscoveredRoute/createDiscoveredToolHandler— dispatcher test subjects for exercisingRouteDispatcher/ToolDispatcherbehavior.createPackageDefinition/createToolDefinitionplus the hosted-entry builders — minimal valid contract shapes with overrides.
Filesystem and async helpers
createTempDir(prefix)/cleanupTempDirs()/withTempDir(prefix, fn)— auto-cleaned scratch directories for filesystem-touching tests.waitFor(predicate, { timeoutMs?, intervalMs?, message? })— flake-safe condition polling; throws on timeout (default 5 s).
Connector compliance
import { assertConnectorCompliance } from '@neuralis/package-system/testing';
await assertConnectorCompliance(connector, {
rootUri: `${connector.source}://`,
sampleFilePath: `${connector.source}://README.md`,
packageTrust: 'trusted',
});The harness exercises every capability the connector declares — including
the declared-versus-actual capability match, write/read/delete round-trips
(only when advertised), and the typed error taxonomy — and throws a
ConnectorComplianceFailure listing every violation. It never writes outside
the rootUri you provide. Strongly recommended for every
source connector.
Cross-package mirror drift
Where a dependency edge exists, a cross-package call site simply imports the
provider's canonical type and tsc does the work. Where it must not — a cycle,
a package that deliberately does not depend on the provider, a generic host —
the call site declares a small structural shape instead, and nothing holds the
two together. assertApiMirror closes that by compiling the mirror against the
file that DECLARES the provider type, addressed by absolute path so no
dependency edge is created:
import { assertApiMirror } from '@neuralis/package-system/testing';
expect(assertApiMirror({
mirrorFile: `${repo}/packages/your-package/src/theirApi.ts`,
mirrorType: 'TheirApiLike',
providerDts: `${repo}/packages/their-package/dist/src/lifecycle.d.ts`,
providerType: 'TheirApi',
})).toEqual([]);It reports both directions of drift — a key the provider does not have (the
diagnostic names it) and a changed argument or return shape — and treats vacuity
as a failure, never a skip: a missing declaration file, or any unresolved
first-party module in the program, is reported, because an unresolved provider
collapses to any and both checks would pass for free. It reads the
declaration as last emitted, so build the provider before the suite that pins
it. Pass
providerType: '*' to compile against the module shape a dynamic
import('<pkg>') hands back, and exclude for a key the call site owns itself.
Feature audit
auditFeatures({ provided, references }) is the analysis core for keeping
feature wiring clean: it reports ids that are referenced (by a tool, route,
skill, or role grant) but that no package provides, and declared features
nothing references. formatFeatureAudit(result) renders the findings for
logs. Feed it the ids from your manifests and the references your suite
collects.
What to cover
Test the contract, not just the happy path:
- Authorization — every route and tool against a denied session; verify 403/error results, not exceptions.
- Feature gates — each declared feature with and without the grant, plus
the
'*'wildcard (see Features and access). - Cross-project isolation — handlers must scope queries by
req.session.projectId; assert that data from another project never leaks. - Schema strictness — run
validateToolSchemaover yourtools/*.jsonin a test so a drifting schema fails CI, not production load. - Failure paths — malformed input, oversized payloads, missing state, connector errors; assert structured error results.
- Result envelopes — runtime-only accounting belongs in
_meta, never in model-visiblecontent.
App surface pre-flight
A App surface is the contribution whose failures are almost all silent: it validates, it loads, the snapshot lists it, and the panel is blank or unstyled. Load-time validation covers only what the manifest object can express, so the rest is a pre-flight and a look at the rendered surface.
| Check | Where it is caught |
|---|---|
Removed renderer (inline-html, wasm-ui), or a legacy fallback key | load-time validation — a migration error, and it fails the whole package |
Entry outside its own app/surfaces/{kind}/{surfaceId}/ root, under app/shared/, traversal-capable, or a root that collides after case-folding | load-time validation |
Absolute iframe URL from an untrusted package; bridge.enabled below trusted; direct below first-party | load-time validation |
A self-contained entry (the default) references an external stylesheet, script, image, or url(…) that is not a data: URI | packaging pre-flight only |
An assetMode: "bundle" entry references something resolving outside its own surface root and app/shared/, or links to a second document | packaging pre-flight only (the serving lane also refuses it at request time) |
assetMode: "bundle" declared on a direct renderer, an absolute URL, or a non-HTML entry | load-time validation — a warning; the declaration is ignored and the surface is served self-contained |
app.module with an unknown key, an entry/css that is not a package-relative .js/.mjs/.css path under dist/, or a provides id that is not a subpath of the package's own name (or is duplicated, or more than 16); or declared by a package that is not host-assigned first-party | load-time validation — an error; for the trust case a warning, and the field is dropped |
A first-party UI module whose bundle would carry its own React or platform client copy, imports a module another UI package does not list in its app.module.provides, reads React with no resolvable react package, or whose output does not match its app.module | build time — neuralis-build ui fails (BUNDLED_SHARED_MODULE / UNDECLARED_SHARED_MODULE / NO_REACT / MANIFEST_MISMATCH) |
| A UI module built for a different host API version or React major, or a React-reading build whose record names no React major (one built before an upgrade) | attach time — the host refuses the module and its surfaces show the placeholder; rebuild it with neuralis-build ui |
Entry is root-absolute, carries a projectId, or points at a package-id-bearing asset path | packaging pre-flight only |
| Entry over the 2 MiB asset cap | packaging pre-flight; the serving route also refuses it |
A tool's ui.cardType matching no declared card surface | packaging pre-flight — a warning; the result falls back to the generic default card |
One card type declared twice with different renderer/URL profiles | packaging pre-flight — the later declaration wins; at runtime the host only logs a duplicate-type warning, nothing in the manifest stops it |
A widget with no kind: "dock" companion, no defaultOpen, and no explicit dock: { "show": false } opt-out | packaging pre-flight |
| The surface renders styled, in all three tool states, for the least-privileged role that should see it — and is absent for one that should not | live, as the real role |
Which subresource rules apply depends on the entry's
asset mode,
and neither set can be checked at load time: the validator receives the
manifest object with no package root and no filesystem access, so it cannot
inspect a file's content. A self-contained entry's linked stylesheet is simply
never served — the request leaves an opaque origin carrying no session cookie
and nothing answers it. A bundle entry's siblings are served, from a lane
that refuses anything resolving outside the surface root and app/shared/. The
pre-flight script ships with the package-creation skill; run it before every
publish.