From 2815e93edd6b44557dd712ba20a33ebac3695e56 Mon Sep 17 00:00:00 2001 From: "agentfront[bot]" Date: Mon, 2 Nov 2026 07:05:35 +0000 Subject: [PATCH] Cherry-pick: fix(sdk): derive a provider's name from its token and add a CommonJS unit Jest preset Cherry-picked from #987 (merged to release/1.10.x) Original commit: e4001693d46d17b350748d83d01b9a1f8dc0040e Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> --- .../e2e/dts-consumer.e2e.spec.ts | 5 + .../fixture/providers-must-compile.ts | 79 ++++++++++++ .../fixture/tsconfig.json | 3 +- .../e2e/jest-preset-unit.e2e.spec.ts | 35 ++++++ .../fixture/ts-jest-cjs/jest.config.js | 4 + .../ts-jest-cjs/jest.without-preset.config.js | 4 + .../ts-jest-cjs/src/auth-server.spec.ts | 109 +++++++++++++++++ .../fixture/ts-jest-cjs/tsconfig.json | 13 ++ .../src/__tests__/provider.metadata.spec.ts | 10 +- libs/di/src/__tests__/token.utils.spec.ts | 29 ++++- libs/di/src/metadata/provider.metadata.ts | 6 +- libs/di/src/metadata/provider.schema.ts | 2 +- libs/di/src/utils/token.utils.ts | 9 +- .../common/decorators/provider.decorator.ts | 2 +- .../__tests__/provider-type.spec.ts | 73 +++++++++-- .../src/common/metadata/provider.metadata.ts | 2 +- .../common/schemas/annotated-class.schema.ts | 2 +- .../decorator-object-providers.spec.ts | 28 ++--- libs/sdk/src/provider/provider.utils.ts | 7 +- .../configurable-dynamic-plugin.md | 2 +- .../plugin-with-context-extension.md | 2 +- .../references/create-plugin.md | 32 ++--- .../references/create-provider.md | 6 +- .../references/decorators-guide.md | 2 +- libs/skills/catalog/frontmcp-testing/SKILL.md | 56 ++++----- .../references/setup-testing.md | 19 ++- libs/testing/ARCHITECTURE.md | 4 +- libs/testing/README.md | 23 +++- libs/testing/jest-preset-unit.js | 11 ++ libs/testing/jest-preset.base.js | 35 ++++++ libs/testing/jest-preset.js | 38 +----- libs/testing/package.json | 6 + libs/testing/project.json | 9 +- .../src/__tests__/jest-preset-unit.spec.ts | 114 ++++++++++++++++++ 34 files changed, 646 insertions(+), 135 deletions(-) create mode 100644 apps/e2e/demo-e2e-dts-consumer/fixture/providers-must-compile.ts create mode 100644 apps/e2e/demo-e2e-testing/e2e/jest-preset-unit.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/jest.config.js create mode 100644 apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/jest.without-preset.config.js create mode 100644 apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/src/auth-server.spec.ts create mode 100644 apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/tsconfig.json create mode 100644 libs/testing/jest-preset-unit.js create mode 100644 libs/testing/jest-preset.base.js create mode 100644 libs/testing/src/__tests__/jest-preset-unit.spec.ts diff --git a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts index fcbfb5a3f..f3585a8dc 100644 --- a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts +++ b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts @@ -32,6 +32,7 @@ const fixtureFiles = [ 'host-must-compile.ts', 'skills-must-compile.ts', 'output-schemas-must-compile.ts', + 'providers-must-compile.ts', ].map((fileName) => path.join(fixtureDir, fileName)); function compileFixture(withoutExpectErrors?: string): { diagnostics: FixtureDiagnostic[]; sourceFiles: string[] } { @@ -137,6 +138,10 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => { expect(describeEach(inFixtureFile('output-schemas-must-compile.ts'))).toEqual([]); }); + it('types providers without a name, AsyncProvider with and without inject, useValue and symbol or string tokens in every entry point', () => { + expect(describeEach(inFixtureFile('providers-must-compile.ts'))).toEqual([]); + }); + it('ships declaration files that type-check with skipLibCheck disabled', () => { const sdkDeclarationDiagnostics = diagnostics.filter((diagnostic) => diagnostic.fileName.startsWith(sdkDeclarationsDir + path.sep), diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/providers-must-compile.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/providers-must-compile.ts new file mode 100644 index 000000000..5f90b0d70 --- /dev/null +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/providers-must-compile.ts @@ -0,0 +1,79 @@ +import 'reflect-metadata'; + +import { + App, + AsyncProvider, + FrontMcp, + FrontMcpInstance, + Provider, + ProviderScope, + type FrontMcpConfigInput, + type ProviderType, + type Token, +} from '@frontmcp/sdk'; + +interface Settings { + region: string; +} + +interface Store { + read(key: string): string | undefined; +} + +const SETTINGS: Token = Symbol('Settings'); +const STORE: Token = Symbol('Store'); +const GREETING: Token = 'greeting'; + +@Provider() +class Clock { + now(): number { + return Date.now(); + } +} + +@Provider({ scope: ProviderScope.CONTEXT }) +class RequestCounter { + count = 0; +} + +const settingsValue = { provide: SETTINGS, useValue: { region: 'eu' } }; + +const greetingValue = { provide: GREETING, useValue: 'hello', name: 'greeting' }; + +const storeWithInject = AsyncProvider({ + provide: STORE, + inject: () => [Clock] as const, + useFactory: async (clock: Clock): Promise => ({ read: (key) => `${clock.now()}:${key}` }), +}); + +const storeWithoutInject = AsyncProvider({ + provide: STORE, + useFactory: (): Store => ({ read: () => undefined }), +}); + +const providers: ProviderType[] = [Clock, RequestCounter, settingsValue, greetingValue, storeWithInject]; + +@App({ name: 'providers', providers: [settingsValue, storeWithInject, storeWithoutInject, Clock] }) +class ProvidersApp {} + +@FrontMcp({ + info: { name: 'providers', version: '1.0.0' }, + apps: [ProvidersApp], + providers: [settingsValue, greetingValue, storeWithInject, storeWithoutInject, RequestCounter], +}) +export class ProvidersServer {} + +const config: FrontMcpConfigInput = { + info: { name: 'providers', version: '1.0.0' }, + apps: [ProvidersApp], + providers, +}; + +export async function startEveryWay(): Promise { + await FrontMcpInstance.runStdio({ ...config, providers: [storeWithoutInject, settingsValue] }); + await FrontMcpInstance.bootstrap({ ...config, providers: [storeWithInject, settingsValue] }); + const direct = await FrontMcpInstance.createDirect({ ...config, providers: [storeWithInject, greetingValue] }); + await direct.dispose(); + const socket = await FrontMcpInstance.runUnixSocket({ ...config, socketPath: '/tmp/providers.sock' }); + await socket.close(); +} diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json index 23db0788c..366629440 100644 --- a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json @@ -17,6 +17,7 @@ "hooks-must-fail.ts", "host-must-compile.ts", "skills-must-compile.ts", - "output-schemas-must-compile.ts" + "output-schemas-must-compile.ts", + "providers-must-compile.ts" ] } diff --git a/apps/e2e/demo-e2e-testing/e2e/jest-preset-unit.e2e.spec.ts b/apps/e2e/demo-e2e-testing/e2e/jest-preset-unit.e2e.spec.ts new file mode 100644 index 000000000..5388ed9b6 --- /dev/null +++ b/apps/e2e/demo-e2e-testing/e2e/jest-preset-unit.e2e.spec.ts @@ -0,0 +1,35 @@ +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; + +const root = join(__dirname, '../../../..'); +const fixtureDir = join(__dirname, '../fixture/ts-jest-cjs'); +const jestBin = join(root, 'node_modules/jest/bin/jest.js'); + +function runFixtureJest(config: string): { status: number | null; output: string } { + const result = spawnSync(process.execPath, [jestBin, '--config', config, '--ci', '--no-cache'], { + cwd: fixtureDir, + env: { ...process.env, CI: '1' }, + encoding: 'utf8', + timeout: 110_000, + }); + return { status: result.status, output: stripVTControlCharacters(`${result.stdout}${result.stderr}`) }; +} + +describe('@frontmcp/testing/jest-preset-unit in a ts-jest CommonJS project', () => { + it('runs a spec that verifies a token with jose and runs a plugin hook in process', () => { + const { status, output } = runFixtureJest('jest.config.js'); + + expect(output).not.toMatch(/Unexpected token 'export'/); + expect(output).toMatch(/Tests:\s+2 passed, 2 total/); + expect(status).toBe(0); + }); + + it('is what that project needs: without the preset, Jest cannot load jose', () => { + const { status, output } = runFixtureJest('jest.without-preset.config.js'); + + expect(output).toMatch(/node_modules[/\\]jose[/\\]/); + expect(output).toMatch(/SyntaxError: Unexpected token 'export'/); + expect(status).not.toBe(0); + }); +}); diff --git a/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/jest.config.js b/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/jest.config.js new file mode 100644 index 000000000..947f30617 --- /dev/null +++ b/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: '@frontmcp/testing/jest-preset-unit', + transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.json' }] }, +}; diff --git a/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/jest.without-preset.config.js b/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/jest.without-preset.config.js new file mode 100644 index 000000000..e1f9570b6 --- /dev/null +++ b/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/jest.without-preset.config.js @@ -0,0 +1,4 @@ +module.exports = { + testEnvironment: 'node', + transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.json' }] }, +}; diff --git a/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/src/auth-server.spec.ts b/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/src/auth-server.spec.ts new file mode 100644 index 000000000..7717a2178 --- /dev/null +++ b/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/src/auth-server.spec.ts @@ -0,0 +1,109 @@ +import 'reflect-metadata'; + +import { MCP_20260728_META, PROTOCOL_2026_07_28 } from '@frontmcp/protocol'; +import { + App, + AsyncProvider, + FrontMcpInstance, + LogLevel, + Plugin, + Tool, + ToolContext, + ToolHook, + type FlowCtxOf, +} from '@frontmcp/sdk'; +import { TestTokenFactory } from '@frontmcp/testing'; + +const ISSUER = 'https://idp.example.com'; +const GREETING = Symbol('greeting'); +const toolsSeenByHook: string[] = []; + +@Plugin({ name: 'call-recorder' }) +class CallRecorderPlugin { + @ToolHook.Will('execute') + recordCall(ctx: FlowCtxOf<'tools:call-tool'>): void { + toolsSeenByHook.push(ctx.toolName); + } +} + +@Tool({ name: 'greet', inputSchema: {} }) +class GreetTool extends ToolContext { + async execute() { + return { greeting: `${this.get(GREETING)} ${this.auth.user.sub}` }; + } +} + +@App({ + name: 'greeter', + tools: [GreetTool], + plugins: [CallRecorderPlugin], + providers: [AsyncProvider({ provide: GREETING, useFactory: async () => 'hello' })], +}) +class GreeterApp {} + +const tokens = new TestTokenFactory({ issuer: ISSUER, audience: ISSUER }); +let handler: (request: Request) => Promise; + +beforeAll(async () => { + handler = await FrontMcpInstance.createFetchHandler({ + info: { name: 'ts-jest-cjs', version: '1.0.0' }, + apps: [GreeterApp], + logging: { level: LogLevel.Off }, + auth: { + mode: 'transparent', + provider: ISSUER, + providerConfig: { jwks: await tokens.getPublicJwks() }, + expectedAudience: ISSUER, + }, + }); +}); + +function callGreet(token: string): Promise { + return handler( + new Request('http://localhost/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': PROTOCOL_2026_07_28, + 'mcp-method': 'tools/call', + 'mcp-name': 'greet', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'greet', + arguments: {}, + _meta: { + [MCP_20260728_META.protocolVersion]: PROTOCOL_2026_07_28, + [MCP_20260728_META.clientInfo]: { name: 'ts-jest-cjs', version: '1.0.0' }, + [MCP_20260728_META.clientCapabilities]: {}, + }, + }, + }), + }), + ); +} + +describe('a FrontMCP server in a ts-jest CommonJS project', () => { + it('verifies the bearer token with jose and runs the plugin hook in process', async () => { + const response = await callGreet(await tokens.createTestToken({ sub: 'ada' })); + const body = (await response.json()) as { result?: { structuredContent?: unknown } }; + + expect(response.status).toBe(200); + expect(body.result?.structuredContent).toEqual({ greeting: 'hello ada' }); + expect(toolsSeenByHook).toEqual(['greet']); + }); + + it('refuses a token signed with another key', async () => { + const forged = await new TestTokenFactory({ issuer: ISSUER, audience: ISSUER }).createTestToken({ sub: 'eve' }); + + const response = await callGreet(forged); + + expect(response.status).toBe(401); + expect(toolsSeenByHook).toEqual(['greet']); + }); +}); diff --git a/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/tsconfig.json b/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/tsconfig.json new file mode 100644 index 000000000..c63b7be09 --- /dev/null +++ b/apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "types": ["jest", "node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/libs/di/src/__tests__/provider.metadata.spec.ts b/libs/di/src/__tests__/provider.metadata.spec.ts index 488cb91ef..ad76d4c59 100644 --- a/libs/di/src/__tests__/provider.metadata.spec.ts +++ b/libs/di/src/__tests__/provider.metadata.spec.ts @@ -31,6 +31,11 @@ describe('ProviderScope', () => { describe('providerMetadataSchema', () => { describe('valid inputs', () => { + it('should accept metadata without a name, which the token then provides', () => { + const result = providerMetadataSchema.safeParse({}); + expect(result.success).toBe(true); + }); + it('should accept minimal metadata with just name', () => { const result = providerMetadataSchema.safeParse({ name: 'TestProvider' }); expect(result.success).toBe(true); @@ -82,11 +87,6 @@ describe('providerMetadataSchema', () => { }); describe('invalid inputs', () => { - it('should reject missing name', () => { - const result = providerMetadataSchema.safeParse({}); - expect(result.success).toBe(false); - }); - it('should reject empty name', () => { const result = providerMetadataSchema.safeParse({ name: '' }); expect(result.success).toBe(false); diff --git a/libs/di/src/__tests__/token.utils.spec.ts b/libs/di/src/__tests__/token.utils.spec.ts index 72cce911a..d04ee496e 100644 --- a/libs/di/src/__tests__/token.utils.spec.ts +++ b/libs/di/src/__tests__/token.utils.spec.ts @@ -3,16 +3,17 @@ */ import 'reflect-metadata'; + +import { DESIGN_PARAMTYPES, META_ASYNC_WITH, META_ASYNC_WITH_TOKENS } from '../tokens/di.constants.js'; import { - tokenName, + depsOfClass, + depsOfFunc, + getAsyncWithTokens, isClass, isPromise, - getAsyncWithTokens, readWithParamTypes, - depsOfClass, - depsOfFunc, + tokenName, } from '../utils/token.utils.js'; -import { DESIGN_PARAMTYPES, META_ASYNC_WITH, META_ASYNC_WITH_TOKENS } from '../tokens/di.constants.js'; describe('token utils', () => { describe('tokenName', () => { @@ -35,6 +36,24 @@ describe('token utils', () => { expect(tokenName(null as any)).toBe('[ref]'); }); + it('should return the description of a symbol token', () => { + expect(tokenName(Symbol('api-url'))).toBe('api-url'); + }); + + it('should return [ref] for a symbol without a description', () => { + expect(tokenName(Symbol())).toBe('[ref]'); + expect(tokenName(Symbol(''))).toBe('[ref]'); + }); + + it('should return the function name for an arrow function token', () => { + const makeStore = () => ({}); + expect(tokenName(makeStore)).toBe('makeStore'); + }); + + it('should return a string token itself', () => { + expect(tokenName('greeting')).toBe('greeting'); + }); + it('should return function name for named functions', () => { function myFunction() {} expect(tokenName(myFunction)).toBe('myFunction'); diff --git a/libs/di/src/metadata/provider.metadata.ts b/libs/di/src/metadata/provider.metadata.ts index 5a2dff553..65041e309 100644 --- a/libs/di/src/metadata/provider.metadata.ts +++ b/libs/di/src/metadata/provider.metadata.ts @@ -34,10 +34,8 @@ export interface ProviderMetadata { */ id?: string; - /** - * Human-readable name for the provider. - */ - name: string; + /** Name shown in logs and errors; `@frontmcp/sdk` names a provider without one after its token. */ + name?: string; /** * Optional description of what the provider does. diff --git a/libs/di/src/metadata/provider.schema.ts b/libs/di/src/metadata/provider.schema.ts index ed785bbb6..36a2b01d0 100644 --- a/libs/di/src/metadata/provider.schema.ts +++ b/libs/di/src/metadata/provider.schema.ts @@ -27,7 +27,7 @@ export type RawZodShape = { export const providerMetadataSchema = z .object({ id: z.string().optional(), - name: z.string().min(1), + name: z.string().min(1).optional(), description: z.string().optional(), scope: z.nativeEnum(ProviderScope).optional().default(ProviderScope.GLOBAL), } satisfies RawZodShape) diff --git a/libs/di/src/utils/token.utils.ts b/libs/di/src/utils/token.utils.ts index 3d855a5ec..f1265b52a 100644 --- a/libs/di/src/utils/token.utils.ts +++ b/libs/di/src/utils/token.utils.ts @@ -13,10 +13,13 @@ import { getMetadata, hasAsyncWith } from './metadata.utils.js'; * Get a human-readable name for a token. * * @param t - The token to get the name for - * @returns A string representation of the token + * @returns The class name, the symbol's description, the string token itself, or `[ref]` */ -export const tokenName = (t: Token): string => - typeof t === 'function' && !!(t as any).prototype ? (t as any).name : ((t as any)?.name ?? '[ref]'); +export const tokenName = (t: Token): string => { + if (typeof t === 'string') return t; + if (typeof t === 'symbol') return t.description || '[ref]'; + return t?.name ?? '[ref]'; +}; /** * Check if a value is a class constructor. diff --git a/libs/sdk/src/common/decorators/provider.decorator.ts b/libs/sdk/src/common/decorators/provider.decorator.ts index 7a10f5407..0839b37d2 100644 --- a/libs/sdk/src/common/decorators/provider.decorator.ts +++ b/libs/sdk/src/common/decorators/provider.decorator.ts @@ -6,7 +6,7 @@ import { FrontMcpProviderTokens } from '../tokens'; /** * Decorator that marks a class as a McpProvider module and provides metadata */ -function FrontMcpProvider(providedMetadata: ProviderMetadata): ClassDecorator { +function FrontMcpProvider(providedMetadata: ProviderMetadata = {}): ClassDecorator { return (target: Function) => { const metadata = frontMcpProviderMetadataSchema.parse(providedMetadata); diff --git a/libs/sdk/src/common/interfaces/__tests__/provider-type.spec.ts b/libs/sdk/src/common/interfaces/__tests__/provider-type.spec.ts index b088185ac..03ee2c310 100644 --- a/libs/sdk/src/common/interfaces/__tests__/provider-type.spec.ts +++ b/libs/sdk/src/common/interfaces/__tests__/provider-type.spec.ts @@ -2,9 +2,12 @@ import 'reflect-metadata'; import { ProviderKind } from '@frontmcp/di'; -import { InvalidDecoratorMetadataError } from '../../../errors'; +import { DependencyCycleError } from '../../../errors'; +import ProviderRegistry from '../../../provider/provider.registry'; import { normalizeProvider } from '../../../provider/provider.utils'; import { Plugin } from '../../decorators/plugin.decorator'; +import { Provider } from '../../decorators/provider.decorator'; +import { ProviderScope } from '../../metadata'; import { annotatedFrontMcpProvidersSchema } from '../../schemas/annotated-class.schema'; import { AsyncProvider, type ProviderType } from '../provider.interface'; @@ -36,15 +39,69 @@ describe('ProviderType factory providers', () => { expect(provider.inject?.()).toEqual([Punctuation]); }); - it('require a name, as the decorators do', () => { + it('take a factory without a name, as the decorators do', () => { expect(() => { - @Plugin({ - name: 'unnamed-factory', - // @ts-expect-error -- a factory provider without `name` is refused at runtime - providers: [{ provide: GREETING, inject: () => [], useFactory: () => 'hello' }], - }) + @Plugin({ name: 'unnamed-factory', providers: [{ provide: GREETING, useFactory: () => 'hello' }] }) class UnnamedFactoryPlugin {} return UnnamedFactoryPlugin; - }).toThrow(InvalidDecoratorMetadataError); + }).not.toThrow(); + }); +}); + +describe('a provider without a name', () => { + @Provider() + class Clock {} + + @Provider({ scope: ProviderScope.CONTEXT }) + class Counter {} + + @Provider({ name: 'named-clock' }) + class NamedClock {} + + @Provider() + class WallClock extends NamedClock {} + + it.each([ + [ + 'a symbol token, from its description', + { provide: Symbol('api-url'), useValue: 'https://api.example.com' }, + 'api-url', + ], + ['a string token, from the string', { provide: 'greeting', useFactory: () => 'hello' }, 'greeting'], + ['a class token, from the class name', { provide: Punctuation, useClass: Punctuation }, 'Punctuation'], + ])('is named from %s', (_case, provider: ProviderType, name) => { + expect(annotatedFrontMcpProvidersSchema.safeParse(provider).success).toBe(true); + expect(normalizeProvider(provider).metadata.name).toBe(name); + }); + + it('is named from its class when @Provider() names none', () => { + expect(normalizeProvider(Clock).metadata.name).toBe('Clock'); + expect(normalizeProvider(Counter).metadata).toMatchObject({ name: 'Counter', scope: ProviderScope.CONTEXT }); + expect(normalizeProvider(WallClock).metadata.name).toBe('WallClock'); + }); + + it('keeps the name it is given', () => { + expect(normalizeProvider(NamedClock).metadata.name).toBe('named-clock'); + expect(normalizeProvider({ name: 'api', provide: Symbol('api-url'), useValue: 'x' }).metadata.name).toBe('api'); + }); + + it('names its symbol token by its description in a dependency cycle', () => { + const alpha = Symbol('alpha'); + const beta = Symbol('beta'); + const building = () => + new ProviderRegistry([ + { provide: alpha, inject: () => [beta], useFactory: (dependency: unknown) => dependency }, + { provide: beta, inject: () => [alpha], useFactory: (dependency: unknown) => dependency }, + ]); + + expect(building).toThrow(new DependencyCycleError('alpha -> beta -> alpha')); + }); + + it('is still refused with an empty name', () => { + expect(() => { + @Provider({ name: '' }) + class EmptyName {} + return EmptyName; + }).toThrow(); }); }); diff --git a/libs/sdk/src/common/metadata/provider.metadata.ts b/libs/sdk/src/common/metadata/provider.metadata.ts index 11b4cc1c8..5a1b1b21d 100644 --- a/libs/sdk/src/common/metadata/provider.metadata.ts +++ b/libs/sdk/src/common/metadata/provider.metadata.ts @@ -10,7 +10,7 @@ import { type RawZodShape } from '../types'; export const frontMcpProviderMetadataSchema = z .object({ id: z.string().optional(), - name: z.string().min(1), + name: z.string().min(1).optional(), description: z.string().optional(), scope: z.nativeEnum(ProviderScope).optional().default(ProviderScope.GLOBAL), } satisfies RawZodShape) diff --git a/libs/sdk/src/common/schemas/annotated-class.schema.ts b/libs/sdk/src/common/schemas/annotated-class.schema.ts index 06eb65341..a48f99019 100644 --- a/libs/sdk/src/common/schemas/annotated-class.schema.ts +++ b/libs/sdk/src/common/schemas/annotated-class.schema.ts @@ -105,7 +105,7 @@ export const annotatedFrontMcpProvidersSchema = z.custom( { message: 'providers items must be classes annotated with @Provider() | @FrontMcpProvider(), ' + - 'or { provide, name, useValue | useClass | useFactory } objects.', + 'or { provide, useValue | useClass | useFactory } objects.', }, ); diff --git a/libs/sdk/src/provider/__tests__/decorator-object-providers.spec.ts b/libs/sdk/src/provider/__tests__/decorator-object-providers.spec.ts index 0a17b2182..ab1427ca3 100644 --- a/libs/sdk/src/provider/__tests__/decorator-object-providers.spec.ts +++ b/libs/sdk/src/provider/__tests__/decorator-object-providers.spec.ts @@ -1,7 +1,7 @@ /** * `@FrontMcp`, `@App`, `@Plugin` and `@Agent` take the value and class providers `ProviderType` - * describes (`{ provide, name, useValue }`, `{ provide, name, useClass }`), as `dynamicProviders()` - * and the provider registry already do, and still refuse one without a `name`. + * describes (`{ provide, useValue }`, `{ provide, useClass }`), as `dynamicProviders()` and the + * provider registry already do. `name` is optional. */ import 'reflect-metadata'; @@ -87,24 +87,24 @@ describe('value and class providers in decorator metadata', () => { }).not.toThrow(); }); - it('still require a name', () => { + it('take value and class providers without a name', () => { expect(() => { - @App({ - name: 'unnamed-value', - // @ts-expect-error -- a value provider without `name` is refused at runtime - providers: [{ provide: API_URL, useValue: 'https://api.example.com' }], - }) + @App({ name: 'unnamed-value', providers: [{ provide: API_URL, useValue: 'https://api.example.com' }] }) class UnnamedValueApp {} return UnnamedValueApp; - }).toThrow(InvalidDecoratorMetadataError); + }).not.toThrow(); expect(() => { - @Plugin({ - name: 'unnamed-class', - // @ts-expect-error -- a class provider without `name` is refused at runtime - providers: [{ provide: GREETER, useClass: Greeter }], - }) + @Plugin({ name: 'unnamed-class', providers: [{ provide: GREETER, useClass: Greeter }] }) class UnnamedClassPlugin {} return UnnamedClassPlugin; + }).not.toThrow(); + }); + + it('refuse an empty name', () => { + expect(() => { + @App({ name: 'empty-name', providers: [{ name: '', provide: API_URL, useValue: 'https://api.example.com' }] }) + class EmptyNameApp {} + return EmptyNameApp; }).toThrow(InvalidDecoratorMetadataError); }); diff --git a/libs/sdk/src/provider/provider.utils.ts b/libs/sdk/src/provider/provider.utils.ts index 97ac4bb1c..dadb9e0a2 100644 --- a/libs/sdk/src/provider/provider.utils.ts +++ b/libs/sdk/src/provider/provider.utils.ts @@ -22,11 +22,14 @@ interface ProviderLike { } function collectProviderMetadata(cls: Type): ProviderMetadata { - return Object.entries(FrontMcpProviderTokens).reduce((metadata, [key, token]) => { - return Object.assign(metadata, { + const metadata = Object.entries(FrontMcpProviderTokens).reduce((collected, [key, token]) => { + return Object.assign(collected, { [key]: getMetadata(token, cls), }); }, {} as ProviderMetadata); + const ownName: string | undefined = Reflect.getOwnMetadata(FrontMcpProviderTokens.name, cls); + metadata.name = ownName ?? tokenName(cls); + return metadata; } /** diff --git a/libs/skills/catalog/frontmcp-development/examples/create-plugin/configurable-dynamic-plugin.md b/libs/skills/catalog/frontmcp-development/examples/create-plugin/configurable-dynamic-plugin.md index 00b6e6734..c07cdbc38 100644 --- a/libs/skills/catalog/frontmcp-development/examples/create-plugin/configurable-dynamic-plugin.md +++ b/libs/skills/catalog/frontmcp-development/examples/create-plugin/configurable-dynamic-plugin.md @@ -176,7 +176,7 @@ class MyApp {} class MyServer {} ``` -Providers that `dynamicProviders()` returns reach the apps the plugin is installed for without an `exports` entry, which is why `this.myService` resolves in `DeleteUserTool`. Each provider needs a `name`; `inject` is optional and only needed when the factory takes dependencies. Inside the plugin, `this.get(token)` resolves from the plugin's own providers. +Providers that `dynamicProviders()` returns reach the apps the plugin is installed for without an `exports` entry, which is why `this.myService` resolves in `DeleteUserTool`. `name` is optional and defaults to the token's name; `inject` is optional too, and only needed when the factory takes dependencies. Inside the plugin, `this.get(token)` resolves from the plugin's own providers. ## What This Demonstrates diff --git a/libs/skills/catalog/frontmcp-development/examples/create-plugin/plugin-with-context-extension.md b/libs/skills/catalog/frontmcp-development/examples/create-plugin/plugin-with-context-extension.md index d58d0a4c3..a4aa9fe79 100644 --- a/libs/skills/catalog/frontmcp-development/examples/create-plugin/plugin-with-context-extension.md +++ b/libs/skills/catalog/frontmcp-development/examples/create-plugin/plugin-with-context-extension.md @@ -127,7 +127,7 @@ class RecordsApp {} export default class Server {} ``` -`providers` and `exports` take provider definitions, not bare tokens: `exports: [AuditLoggerToken]` throws when `@Plugin` runs (`providers items must be annotated with @Provider()`). Every provider object needs a `name`, whether it binds the token with `useFactory` (as here), `useClass` or `useValue`. The tool reaches `AuditLoggerToken` (and so `this.auditLog`) only because the plugin exports that definition to the app. The caller's identity comes from `this.auth.user.sub`; `this.scope` is the server scope, not the user. +`providers` and `exports` take provider definitions, not bare tokens: `exports: [AuditLoggerToken]` throws when `@Plugin` runs (`providers items must be annotated with @Provider()`). A provider object binds the token with `useFactory` (as here), `useClass` or `useValue`, and its `name` is optional: it defaults to the token's name. The tool reaches `AuditLoggerToken` (and so `this.auditLog`) only because the plugin exports that definition to the app. The caller's identity comes from `this.auth.user.sub`; `this.scope` is the server scope, not the user. ## What This Demonstrates diff --git a/libs/skills/catalog/frontmcp-development/references/create-plugin.md b/libs/skills/catalog/frontmcp-development/references/create-plugin.md index e44975c4a..4873320d5 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-plugin.md +++ b/libs/skills/catalog/frontmcp-development/references/create-plugin.md @@ -140,7 +140,7 @@ class MainApp {} class MyServer {} ``` -A provider that `dynamicProviders()` returns reaches the apps the plugin is installed for without an `exports` entry, so `GreetTool` resolves `GreeterService` directly. Every provider object needs a `name`; `GreeterService` is built by the factory, so it needs no `@Provider` decorator. +A provider that `dynamicProviders()` returns reaches the apps the plugin is installed for without an `exports` entry, so `GreetTool` resolves `GreeterService` directly. A provider object's `name` is optional and defaults to its token's name; `GreeterService` is built by the factory, so it needs no `@Provider` decorator. ## Step 1: Create a Simple Plugin @@ -216,9 +216,9 @@ export default class AuditLogPlugin {} A tool in the host app then resolves `this.get(AuditLoggerToken)`. -- `@Plugin({ providers, exports })` accepts a class decorated with `@Provider({ name })` (for example `providers: [AuditLogger], exports: [AuditLogger]`), or a provider object with a `name`: `{ name, provide, useFactory, inject? }`, `{ name, provide, useClass }` or `{ name, provide, useValue }`. `inject: () => [Dep] as const` is only needed when the factory takes dependencies. -- `exports` lists provider definitions from `providers`, never a bare token: `exports: [AuditLoggerToken]` throws when `@Plugin` runs (`providers items must be annotated with @Provider()`). A provider object without `name` is rejected the same way. -- `@Provider` metadata is strict: `name` is required, and `id`, `description` and `scope` (`ProviderScope.GLOBAL`, the default, or `ProviderScope.CONTEXT` for one instance per request) are the only other keys. A bare `@Provider()` throws. +- `@Plugin({ providers, exports })` accepts a class decorated with `@Provider()` (for example `providers: [AuditLogger], exports: [AuditLogger]`), or a provider object: `{ provide, useFactory, inject? }`, `{ provide, useClass }` or `{ provide, useValue }`, each with an optional `name`. `inject: () => [Dep] as const` is only needed when the factory takes dependencies. +- `exports` lists provider definitions from `providers`, never a bare token: `exports: [AuditLoggerToken]` throws when `@Plugin` runs (`providers items must be annotated with @Provider()`). +- `@Provider` metadata is strict: `name`, `id`, `description` and `scope` (`ProviderScope.GLOBAL`, the default, or `ProviderScope.CONTEXT` for one instance per request) are the only keys, and all are optional. `name` defaults to the class name, and a provider object's to its token's name (a symbol's description or a string token). Up to 1.10.15, every provider needed a `name`, and a bare `@Provider()` threw. - Without `exports`, a provider from `@Plugin({ providers })` is visible only inside the plugin (its hooks, tools and `this.get()`). Providers returned by `dynamicProviders()` reach the host app without it. ### A provider that needs the scope @@ -618,7 +618,7 @@ plugins/ | Context extension registration | `contextExtensions: [{ property: 'auditLog', token: AuditLoggerToken }]` in metadata | `Object.defineProperty(ExecutionContextBase.prototype, ...)` manually | SDK handles runtime installation; manual modification causes ordering issues | | Type augmentation | `declare module '@frontmcp/sdk' { interface ExecutionContextBase { ... } }` in a separate file | Skipping the augmentation and casting `this` in tools | Without augmentation, TypeScript cannot type-check `this.auditLog` | | Provider types | `Token = Symbol('AuditLogger')` with typed token | `provide: Symbol('AuditLogger')` without type annotation | Typed tokens enable compile-time DI resolution checking | -| Exporting a provider | `providers: [auditLoggerProvider], exports: [auditLoggerProvider]` | `exports: [AuditLoggerToken]`, or a provider object without `name` | `exports` takes named provider definitions; the other forms throw at decoration | +| Exporting a provider | `providers: [auditLoggerProvider], exports: [auditLoggerProvider]` | `exports: [AuditLoggerToken]` (a bare token) | `exports` takes the provider definitions from `providers`; a bare token throws at decoration | | Caller identity in a tool | `this.auth.user.sub` | `this.scope.userId` | `this.scope` is the server scope; the user is on `this.auth` | | Plugin scope | `scope: 'app'` (default) for app-scoped behavior | `scope: 'server'` when hooks should only apply to one app | Server scope fires hooks for every app's entries, even when declared on one `@App`; default to app | | Dynamic options | Extend `DynamicPlugin` with `static dynamicProviders()` | Constructing providers in the constructor body | `dynamicProviders` runs before instantiation, enabling proper DI wiring | @@ -631,7 +631,7 @@ plugins/ - [ ] `@Plugin` decorator has `name` and `description` - [ ] Providers are listed in `providers` array with typed tokens - [ ] Exported providers are listed in `exports` array as the same definitions, not bare tokens -- [ ] Every provider object has a `name`, and every `@Provider` decorator passes `{ name }` +- [ ] A provider sets `name` only where its default (the class name, or a provider object's token name) is not the name you want in logs and errors - [ ] Context extensions have `property`, `token`, and `errorMessage` fields ### Type Safety @@ -651,16 +651,16 @@ plugins/ ## Troubleshooting -| Problem | Cause | Solution | -| ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `this.auditLog` has type `any` or is unrecognized | Module augmentation file not imported | Add side-effect import: `import './audit-log.context-extension'` in plugin file | -| Circular dependency error at startup | Calling `installExtension()` at module top level | Remove manual installation; use `contextExtensions` metadata array instead | -| Provider not found in tool context | Provider not listed in plugin `exports` | List the same provider definition in both `providers` and `exports` (the definition, not its token), or return it from `dynamicProviders()` | -| Hooks fire for unrelated apps in gateway | Plugin `scope` set to `'server'` | Change to `scope: 'app'` (default) unless server-wide behavior is intended | -| `DynamicPlugin.init()` options ignored | Overriding constructor without calling `super()` | Ensure constructor calls `super()` and merges defaults properly | -| `this.auditLog` throws your `errorMessage` although the plugin is installed | The token is registered in `@Plugin({ providers })` but not exported, so the app cannot resolve it | Add the provider definition to `exports`, or return it from `dynamicProviders()` | -| `@Plugin invalid metadata for "providers"` or `providers items must be annotated with @Provider()` when the module loads | A bare token in `exports`, a provider object (`useFactory`, `useClass` or `useValue`) without `name`, or an undecorated class | Use a `@Provider({ name })` class or a provider object with a `name` (`{ name, provide, useFactory }`), and export that same definition | -| Error thrown where a provider class is declared | Bare `@Provider()`: `name` is required | `@Provider({ name: 'my-plugin:service' })`, optionally with `description` and `scope: ProviderScope.CONTEXT` | +| Problem | Cause | Solution | +| ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `this.auditLog` has type `any` or is unrecognized | Module augmentation file not imported | Add side-effect import: `import './audit-log.context-extension'` in plugin file | +| Circular dependency error at startup | Calling `installExtension()` at module top level | Remove manual installation; use `contextExtensions` metadata array instead | +| Provider not found in tool context | Provider not listed in plugin `exports` | List the same provider definition in both `providers` and `exports` (the definition, not its token), or return it from `dynamicProviders()` | +| Hooks fire for unrelated apps in gateway | Plugin `scope` set to `'server'` | Change to `scope: 'app'` (default) unless server-wide behavior is intended | +| `DynamicPlugin.init()` options ignored | Overriding constructor without calling `super()` | Ensure constructor calls `super()` and merges defaults properly | +| `this.auditLog` throws your `errorMessage` although the plugin is installed | The token is registered in `@Plugin({ providers })` but not exported, so the app cannot resolve it | Add the provider definition to `exports`, or return it from `dynamicProviders()` | +| `@Plugin invalid metadata for "providers"` or `providers items must be annotated with @Provider()` when the module loads | A bare token in `exports`, or an undecorated class | Use a `@Provider()` class or a provider object (`{ provide, useFactory }`), and export that same definition | +| Error thrown where a provider class is declared | `@Provider({...})` got an empty `name` or a key it does not take (`provide`, `useFactory`, ...) | Pass only `name`, `id`, `description` and `scope`, and bind a token with `AsyncProvider({ provide, useFactory })` | ## Examples diff --git a/libs/skills/catalog/frontmcp-development/references/create-provider.md b/libs/skills/catalog/frontmcp-development/references/create-provider.md index 84fab7608..ccd005b38 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-provider.md +++ b/libs/skills/catalog/frontmcp-development/references/create-provider.md @@ -130,7 +130,9 @@ class MyApp {} class Server {} ``` -Besides a `@Provider` class and a factory, `providers` takes a value or class binding, `{ name, provide, useValue }` or `{ name, provide, useClass }`. Every provider object needs a `name`. Up to 1.9.0, `@FrontMcp`, `@App`, `@Plugin` and `@Agent` refused `useClass` objects, and `useValue` objects unless the value was an instance of a `@Provider` class. +A config object takes the same `providers`, `AsyncProvider` factories included, wherever it goes: `FrontMcpInstance.runStdio(config)`, `bootstrap(config)`, `createDirect(config)` and `runUnixSocket({ ...config, socketPath })`. + +Besides a `@Provider` class and a factory, `providers` takes a value or class binding, `{ provide, useValue }` or `{ provide, useClass }`. `name` is optional everywhere and shows in logs and errors. It defaults to the class name for a `@Provider()` class, and to the token's name for a provider object: the class name, a symbol's description or a string token. Up to 1.10.15, every provider needed a `name`. Up to 1.9.0, `@FrontMcp`, `@App`, `@Plugin` and `@Agent` refused `useClass` objects, and `useValue` objects unless the value was an instance of a `@Provider` class. ## Step 4: Use in Tools @@ -362,7 +364,7 @@ frontmcp dev ### Configuration -- [ ] Provider class has `@Provider` decorator with `name` (or factory uses `AsyncProvider`) +- [ ] Provider class has the `@Provider()` decorator (or factory uses `AsyncProvider`) - [ ] Token is defined with `Token` using a `Symbol` and typed interface - [ ] Provider (class or `AsyncProvider` factory) is registered in `providers` array of `@App` or `@FrontMcp` - [ ] Sync setup happens in the constructor (throws fast on missing config) diff --git a/libs/skills/catalog/frontmcp-development/references/decorators-guide.md b/libs/skills/catalog/frontmcp-development/references/decorators-guide.md index 99edf8aa3..8737c0b67 100644 --- a/libs/skills/catalog/frontmcp-development/references/decorators-guide.md +++ b/libs/skills/catalog/frontmcp-development/references/decorators-guide.md @@ -469,7 +469,7 @@ class GitHubAdapter { | Field | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `name` | Provider name (required) | +| `name?` | Provider name, shown in logs and errors; defaults to the class name | | `id?` | Optional stable identifier | | `description?` | Human-readable description | | `scope?` | `ProviderScope.GLOBAL` (default) or `ProviderScope.CONTEXT` (`SESSION` and `REQUEST` are deprecated aliases of `CONTEXT`) | diff --git a/libs/skills/catalog/frontmcp-testing/SKILL.md b/libs/skills/catalog/frontmcp-testing/SKILL.md index 2d26aa7cd..02f61bbc7 100644 --- a/libs/skills/catalog/frontmcp-testing/SKILL.md +++ b/libs/skills/catalog/frontmcp-testing/SKILL.md @@ -95,18 +95,18 @@ This is a router skill. Follow this order to pick a testing approach, then move ## Cross-Cutting Testing Patterns -| Pattern | Rule | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| File naming | Always `.spec.ts` (not `.test.ts`); E2E uses `.e2e.spec.ts` | -| File organization | Split E2E tests by app/feature: `e2e/calc.e2e.spec.ts`, `e2e/ecommerce.e2e.spec.ts`. Never put all tests in a single `server.e2e.spec.ts` | -| Environment | `frontmcp test` loads `.env` / `.env.local` into the Jest child, same precedence as `dev` (real environment wins over the files, config `env.shared` + `env.test` underneath). `--no-env` skips it | -| Test runner | Standalone projects: use `frontmcp test` (auto-generates Jest/SWC config; discovers `src/**/*.spec.ts(x)`, `__tests__/**/*.spec.ts(x)`, and `e2e/**/*.e2e.spec.ts(x)`; transforms both `.ts` and `.tsx` with the automatic JSX runtime; transpiles ESM-only deps such as `jose`, `@noble/hashes` and `@noble/ciphers` (CodeCall) under npm, yarn AND pnpm's `node_modules/.pnpm/` store — add your own via `test.esmPackages` in `frontmcp.config.ts`; does not force `NODE_OPTIONS=--experimental-vm-modules` (it would bypass the ESM transforms; scaffolded projects use Jest 30); delegates to a user-provided `jest.config.{ts,js,mjs,cjs,json}` if present, which drops the injected config unless it starts from `preset: '@frontmcp/testing'`, which carries the same transform, discovery, timeout and ESM transforms). Nx monorepos: use `nx test ` (resolves the project's `jest.config.ts`). Never invoke `jest --config ...` directly | -| Coverage threshold | 95%+ across statements, branches, functions, lines | -| Test descriptions | Plain English, no prefixes like "PT-001"; describe behavior not implementation | -| Mocking | Mock providers via DI token replacement, never mock the framework | -| httpMock scope | `httpMock` intercepts HTTP in the **test process** only, NOT in the MCP server subprocess. Do not use httpMock to intercept server-to-API calls — those happen in the child process. Use httpMock for verifying client-to-server request shapes or mocking external APIs called from the test itself | -| Error testing | Assert `instanceof` specific error class AND MCP error code | -| Async | Always `await` async operations; use `expect(...).rejects.toThrow()` for async errors | +| Pattern | Rule | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| File naming | Always `.spec.ts` (not `.test.ts`); E2E uses `.e2e.spec.ts` | +| File organization | Split E2E tests by app/feature: `e2e/calc.e2e.spec.ts`, `e2e/ecommerce.e2e.spec.ts`. Never put all tests in a single `server.e2e.spec.ts` | +| Environment | `frontmcp test` loads `.env` / `.env.local` into the Jest child, same precedence as `dev` (real environment wins over the files, config `env.shared` + `env.test` underneath). `--no-env` skips it | +| Test runner | Standalone projects: use `frontmcp test` (auto-generates Jest/SWC config; discovers `src/**/*.spec.ts(x)`, `__tests__/**/*.spec.ts(x)`, and `e2e/**/*.e2e.spec.ts(x)`; transforms both `.ts` and `.tsx` with the automatic JSX runtime; transpiles ESM-only deps such as `jose`, `@noble/hashes` and `@noble/ciphers` (CodeCall) under npm, yarn AND pnpm's `node_modules/.pnpm/` store — add your own via `test.esmPackages` in `frontmcp.config.ts`; does not force `NODE_OPTIONS=--experimental-vm-modules` (it would bypass the ESM transforms; scaffolded projects use Jest 30); delegates to a user-provided `jest.config.{ts,js,mjs,cjs,json}` if present, which drops the injected config unless it starts from `preset: '@frontmcp/testing'`, which carries the same transform, discovery, timeout and ESM transforms; for unit tests, a CommonJS or ts-jest project included, start from `preset: '@frontmcp/testing/jest-preset-unit'`: the same transforms, Jest's 5 s timeout, no setup file, `*.spec.ts(x)` other than `.e2e`/`.perf`/`.pw` specs). Nx monorepos: use `nx test ` (resolves the project's `jest.config.ts`). Never invoke `jest --config ...` directly | +| Coverage threshold | 95%+ across statements, branches, functions, lines | +| Test descriptions | Plain English, no prefixes like "PT-001"; describe behavior not implementation | +| Mocking | Mock providers via DI token replacement, never mock the framework | +| httpMock scope | `httpMock` intercepts HTTP in the **test process** only, NOT in the MCP server subprocess. Do not use httpMock to intercept server-to-API calls — those happen in the child process. Use httpMock for verifying client-to-server request shapes or mocking external APIs called from the test itself | +| Error testing | Assert `instanceof` specific error class AND MCP error code | +| Async | Always `await` async operations; use `expect(...).rejects.toThrow()` for async errors | ## Common Patterns @@ -146,22 +146,22 @@ This is a router skill. Follow this order to pick a testing approach, then move ## Troubleshooting -| Problem | Cause | Solution | -| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Jest not finding test files | Wrong file extension (`.test.ts` instead of `.spec.ts`) | Rename to `.spec.ts`; check `testMatch` in jest.config | -| `SyntaxError: Unexpected token 'export'` | An ESM-only dependency is being ignored instead of transpiled | Add it to `test.esmPackages` in `frontmcp.config.ts`. With a hand-written `jest.config.ts`, start from `preset: '@frontmcp/testing'` or use the pnpm-safe `transformIgnorePatterns` in [`setup-testing`](./references/setup-testing.md#jest-configuration) AND make sure `transform` matches `.js` (`^.+\.[tj]sx?$` + `allowJs`) — un-ignoring a file does nothing if no transform matches it | -| Coverage below 95% | Untested error paths or conditional branches | Run `frontmcp test --coverage` and inspect uncovered lines in the report | -| E2E test timeout | Server startup too slow or port conflict | Increase Jest timeout; use random port allocation | -| DI resolution fails in tests | Provider not registered in test scope | Register mock providers before creating the test context | -| Istanbul shows 0% on async methods | TypeScript source-map mismatch with Istanbul | Known issue with some TS compilation settings; verify coverage with actual test output | -| Specs see no `.env` values | An older CLI did not load `.env` for `frontmcp test`, only for `dev` | Upgrade the CLI — `frontmcp test` now loads `.env` / `.env.local` with the same precedence as `dev` (real environment wins, so CI secrets still override). Pass `--no-env` for a hermetic run | -| `Invalid first argument, true` at collection time | `test.skip(condition, reason)` reached Jest's `skip(name, fn)` | Upgrade the CLI — the Playwright signature is supported: `test.skip(!hasCredentials, 'credentials not set')` skips every test registered after it in the enclosing block | -| `test.use()` in one `describe` leaks into another, or two files fight over a port | Older `@frontmcp/testing` kept one global config and per-process ports | Upgrade — `test.use()` is scoped per `describe` and ports are locked across Jest workers; use `port: 0` | -| Every spec fails with `HTTP 404` after setting `http.entryPath` | The test client always connected to the server root | Upgrade the CLI — the client now follows the `entryPaths` a 404 reports, and `test.use({ entryPath: '/mcp' })` sets it explicitly | -| `test.beforeEach(async ({ mcp }) => …)` times out waiting for `done` | Older `@frontmcp/testing` handed the hook to Jest, which read the parameter as `done` | Upgrade — hooks that take a parameter now receive the test's fixtures (same `mcp` as the test); `beforeAll` / `afterAll` never get fixtures | -| `test.use({ transport: 'sse' })` throws "SSE transport not yet implemented" | Older `@frontmcp/testing` had no legacy SSE client | Upgrade — `'sse'` is the legacy HTTP+SSE transport; the server must enable it with `transport: { protocol: { legacy: true } }` | -| `Module @swc/jest in the transform option was not found` from `frontmcp test` | Older `@frontmcp/testing` did not install the transformer the injected config uses | Upgrade — `@frontmcp/testing` depends on `@swc/jest` / `@swc/core`, and `frontmcp test` resolves the transformer through it | -| `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG` from a server started inside a spec | Older SDKs loaded the optional `vectoriadb` with `import()` at boot | Upgrade the SDK — it loads `vectoriadb` and `jose` on first use and falls back to `require` where Jest cannot run `import()`; no `--experimental-vm-modules` needed | +| Problem | Cause | Solution | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Jest not finding test files | Wrong file extension (`.test.ts` instead of `.spec.ts`) | Rename to `.spec.ts`; check `testMatch` in jest.config | +| `SyntaxError: Unexpected token 'export'` | An ESM-only dependency is being ignored instead of transpiled | Add it to `test.esmPackages` in `frontmcp.config.ts`. With a hand-written `jest.config.ts`, start from `preset: '@frontmcp/testing'` (or `'@frontmcp/testing/jest-preset-unit'`; with ts-jest, set its transform for `^.+\.tsx?$` only) or use the pnpm-safe `transformIgnorePatterns` in [`setup-testing`](./references/setup-testing.md#jest-configuration) AND make sure `transform` matches `.js` (`^.+\.[tj]sx?$` + `allowJs`) — un-ignoring a file does nothing if no transform matches it | +| Coverage below 95% | Untested error paths or conditional branches | Run `frontmcp test --coverage` and inspect uncovered lines in the report | +| E2E test timeout | Server startup too slow or port conflict | Increase Jest timeout; use random port allocation | +| DI resolution fails in tests | Provider not registered in test scope | Register mock providers before creating the test context | +| Istanbul shows 0% on async methods | TypeScript source-map mismatch with Istanbul | Known issue with some TS compilation settings; verify coverage with actual test output | +| Specs see no `.env` values | An older CLI did not load `.env` for `frontmcp test`, only for `dev` | Upgrade the CLI — `frontmcp test` now loads `.env` / `.env.local` with the same precedence as `dev` (real environment wins, so CI secrets still override). Pass `--no-env` for a hermetic run | +| `Invalid first argument, true` at collection time | `test.skip(condition, reason)` reached Jest's `skip(name, fn)` | Upgrade the CLI — the Playwright signature is supported: `test.skip(!hasCredentials, 'credentials not set')` skips every test registered after it in the enclosing block | +| `test.use()` in one `describe` leaks into another, or two files fight over a port | Older `@frontmcp/testing` kept one global config and per-process ports | Upgrade — `test.use()` is scoped per `describe` and ports are locked across Jest workers; use `port: 0` | +| Every spec fails with `HTTP 404` after setting `http.entryPath` | The test client always connected to the server root | Upgrade the CLI — the client now follows the `entryPaths` a 404 reports, and `test.use({ entryPath: '/mcp' })` sets it explicitly | +| `test.beforeEach(async ({ mcp }) => …)` times out waiting for `done` | Older `@frontmcp/testing` handed the hook to Jest, which read the parameter as `done` | Upgrade — hooks that take a parameter now receive the test's fixtures (same `mcp` as the test); `beforeAll` / `afterAll` never get fixtures | +| `test.use({ transport: 'sse' })` throws "SSE transport not yet implemented" | Older `@frontmcp/testing` had no legacy SSE client | Upgrade — `'sse'` is the legacy HTTP+SSE transport; the server must enable it with `transport: { protocol: { legacy: true } }` | +| `Module @swc/jest in the transform option was not found` from `frontmcp test` | Older `@frontmcp/testing` did not install the transformer the injected config uses | Upgrade — `@frontmcp/testing` depends on `@swc/jest` / `@swc/core`, and `frontmcp test` resolves the transformer through it | +| `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG` from a server started inside a spec | Older SDKs loaded the optional `vectoriadb` with `import()` at boot | Upgrade the SDK — it loads `vectoriadb` and `jose` on first use and falls back to `require` where Jest cannot run `import()`; no `--experimental-vm-modules` needed | ## Examples diff --git a/libs/skills/catalog/frontmcp-testing/references/setup-testing.md b/libs/skills/catalog/frontmcp-testing/references/setup-testing.md index 806426572..91b914077 100644 --- a/libs/skills/catalog/frontmcp-testing/references/setup-testing.md +++ b/libs/skills/catalog/frontmcp-testing/references/setup-testing.md @@ -552,8 +552,23 @@ defaults: the same `@swc/jest` transform (no `ts-jest` needed), the same discove `__tests__/**/*.spec.ts(x)`, `e2e/**/*.e2e.spec.ts(x)`), the same 60-second timeout, and the same `transformIgnorePatterns` for `jose`, `@noble/hashes` and `@noble/ciphers`. Setting `testMatch` or `transformIgnorePatterns` replaces the preset's list rather than extending it, so copy its entries into yours. -Name the package, not `'@frontmcp/testing/jest-preset'`: Jest looks for `jest-preset.js` at the root of the -package it is given. +Name the package, `'@frontmcp/testing'`, not `'@frontmcp/testing/jest-preset'`: Jest looks for `jest-preset.js` +under the name it is given. + +For unit tests, start from `preset: '@frontmcp/testing/jest-preset-unit'`. It shares the transform and the +`jose` / `@noble/*` patterns above, keeps Jest's default 5-second timeout, registers no setup file (add +`@frontmcp/testing/setup` to `setupFilesAfterEnv` to use the MCP matchers), and runs every `*.spec.ts(x)` except +`.e2e.spec`, `.perf.spec` and `.pw.spec` files. It also fits a CommonJS project that compiles its TypeScript with +`ts-jest`: without it, the first auth path that loads `jose` fails with `SyntaxError: Unexpected token 'export'`. +Set the ts-jest transform for `.ts` only, so the preset's transform still compiles those packages: + +```javascript +// jest.config.js +module.exports = { + preset: '@frontmcp/testing/jest-preset-unit', + transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.json' }] }, +}; +``` ## Manual Testing with frontmcp dev diff --git a/libs/testing/ARCHITECTURE.md b/libs/testing/ARCHITECTURE.md index 455afc2b6..de214eba7 100644 --- a/libs/testing/ARCHITECTURE.md +++ b/libs/testing/ARCHITECTURE.md @@ -1267,7 +1267,9 @@ libs/testing/ │ └── errors/ │ └── index.ts # Custom error classes │ -├── jest-preset.js # Jest preset configuration +├── jest-preset.js # Jest preset configuration (`frontmcp test`'s settings) +├── jest-preset-unit.js # Jest preset for unit tests, CommonJS projects included +├── jest-preset.base.js # What both presets share: transform, ESM-only packages ├── package.json # Package manifest └── ARCHITECTURE.md # This file ``` diff --git a/libs/testing/README.md b/libs/testing/README.md index 9daf048e5..389f53f50 100644 --- a/libs/testing/README.md +++ b/libs/testing/README.md @@ -36,7 +36,26 @@ test('tool execution works', async ({ mcp }) => { npx frontmcp test ``` -`frontmcp test` (from the [`frontmcp`](https://www.npmjs.com/package/frontmcp) CLI) runs `src/**/*.spec.ts`, `__tests__/**/*.spec.ts` and `e2e/**/*.e2e.spec.ts` with a Jest configuration it generates. With a `jest.config.*` of your own, use `preset: '@frontmcp/testing'` for the same settings. The server starts when the first test that needs it runs, and tests with the same `test.use()` options share it. +`frontmcp test` (from the [`frontmcp`](https://www.npmjs.com/package/frontmcp) CLI) runs `src/**/*.spec.ts`, `__tests__/**/*.spec.ts` and `e2e/**/*.e2e.spec.ts` with a Jest configuration it generates. With a `jest.config.*` of your own, start from one of the [Jest presets](#jest-presets). The server starts when the first test that needs it runs, and tests with the same `test.use()` options share it. + +## Jest presets + +| Preset | Use it for | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `preset: '@frontmcp/testing'` | The configuration `frontmcp test` generates: unit and e2e specs, a 60-second timeout, and the matchers registered through `@frontmcp/testing/setup` | +| `preset: '@frontmcp/testing/jest-preset-unit'` | Unit tests, in a CommonJS project too: Jest's default 5-second timeout, no setup file, and every `*.spec.ts(x)` except `*.e2e.spec`, `*.perf.spec` and `*.pw.spec` files. Add `@frontmcp/testing/setup` to `setupFilesAfterEnv` to use the matchers | + +Both compile TypeScript and TSX with `@swc/jest`, and both compile `jose`, `@noble/hashes` and `@noble/ciphers`, ESM-only packages the SDK loads, which Jest's CommonJS runtime otherwise fails on with `SyntaxError: Unexpected token 'export'`. To compile your TypeScript with `ts-jest`, set a transform for `.ts` only, so the preset still compiles those packages: + +```js +// jest.config.js +module.exports = { + preset: '@frontmcp/testing/jest-preset-unit', + transform: { '^.+\\.tsx?$': 'ts-jest' }, +}; +``` + +`testMatch` and `transformIgnorePatterns` of your own replace the preset's lists, so copy its entries into yours. ## Fixtures @@ -60,7 +79,7 @@ npx frontmcp test **Tool UI**: `toHaveRenderedHtml`, `toContainHtmlElement`, `toContainBoundValue`, `toBeXssSafe`, `toHaveWidgetMetadata`, `toHaveCssClass`, `toNotContainRawContent`, `toHaveProperHtmlStructure` -Use them through the `expect` this package exports, which is Jest's `expect` with these matchers typed. `frontmcp test` and the preset also register them on Jest's global `expect`, through `@frontmcp/testing/setup`, which you can add to `setupFilesAfterEnv` yourself. +Use them through the `expect` this package exports, which is Jest's `expect` with these matchers typed. `frontmcp test` and the `@frontmcp/testing` preset register them, through `@frontmcp/testing/setup`, which you can add to `setupFilesAfterEnv` yourself. ## Configuration diff --git a/libs/testing/jest-preset-unit.js b/libs/testing/jest-preset-unit.js new file mode 100644 index 000000000..00d74dfb3 --- /dev/null +++ b/libs/testing/jest-preset-unit.js @@ -0,0 +1,11 @@ +/** Jest preset for unit tests, CommonJS projects included; the @frontmcp/testing README shows how to use it. */ + +const base = require('./jest-preset.base.js'); + +module.exports = { + ...base, + + testMatch: ['/**/*.spec.ts', '/**/*.spec.tsx'], + + testPathIgnorePatterns: [...base.testPathIgnorePatterns, '\\.(e2e|perf|pw)\\.spec\\.tsx?$'], +}; diff --git a/libs/testing/jest-preset.base.js b/libs/testing/jest-preset.base.js new file mode 100644 index 000000000..a2ce7b92f --- /dev/null +++ b/libs/testing/jest-preset.base.js @@ -0,0 +1,35 @@ +/** What both @frontmcp/testing Jest presets share, so they cannot drift. */ + +module.exports = { + testEnvironment: 'node', + + // Covers `.js` too: ESM-only deps ship `.js`, and un-ignoring them in `transformIgnorePatterns` needs a matching transform. + transform: { + '^.+\\.[tj]sx?$': [ + // The transform `frontmcp test` injects, resolved from here because @frontmcp/testing installs it + require.resolve('@swc/jest'), + { + jsc: { + target: 'es2022', + parser: { syntax: 'typescript', tsx: true, decorators: true, dynamicImport: true }, + transform: { decoratorMetadata: true, legacyDecorator: true, react: { runtime: 'automatic' } }, + keepClassNames: true, + externalHelpers: false, + loose: true, + }, + module: { type: 'es6' }, + sourceMaps: true, + swcrc: false, + }, + ], + }, + + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + + // The SDK's ESM-only runtime deps: `jose` (auth) and `@noble/*` (`@frontmcp/utils` crypto). The `.pnpm` skip keeps this correct under pnpm (issue #519). + transformIgnorePatterns: [ + 'node_modules[/\\\\](?!\\.pnpm[/\\\\])(?!(jose|@noble[/\\\\]hashes|@noble[/\\\\]ciphers)[/\\\\])', + ], + + testPathIgnorePatterns: ['/node_modules/', '/dist/'], +}; diff --git a/libs/testing/jest-preset.js b/libs/testing/jest-preset.js index 2092426f7..12ee02f21 100644 --- a/libs/testing/jest-preset.js +++ b/libs/testing/jest-preset.js @@ -3,6 +3,7 @@ * @description Jest preset for @frontmcp/testing * * Mirrors the config `frontmcp test` injects (libs/cli/src/commands/dev/test.ts); a CLI test fails when they differ. + * For unit tests in a CommonJS project, use `@frontmcp/testing/jest-preset-unit`. * * Usage in jest.config.ts: * ```typescript @@ -13,33 +14,10 @@ * ``` */ -module.exports = { - // Use Node.js environment for E2E tests - testEnvironment: 'node', - - // Covers `.js` too: ESM-only deps ship `.js`, and un-ignoring them in `transformIgnorePatterns` needs a matching transform. - transform: { - '^.+\\.[tj]sx?$': [ - // The transform `frontmcp test` injects, resolved from here because @frontmcp/testing installs it - require.resolve('@swc/jest'), - { - jsc: { - target: 'es2022', - parser: { syntax: 'typescript', tsx: true, decorators: true, dynamicImport: true }, - transform: { decoratorMetadata: true, legacyDecorator: true, react: { runtime: 'automatic' } }, - keepClassNames: true, - externalHelpers: false, - loose: true, - }, - module: { type: 'es6' }, - sourceMaps: true, - swcrc: false, - }, - ], - }, +const base = require('./jest-preset.base.js'); - // File extensions to consider - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], +module.exports = { + ...base, testTimeout: 60000, @@ -56,14 +34,6 @@ module.exports = { '/e2e/**/*.e2e.spec.tsx', ], - // Transpile ESM-only deps (`@noble/*` for CodeCall); the `.pnpm` skip keeps this correct under pnpm (issue #519). - transformIgnorePatterns: [ - 'node_modules[/\\\\](?!\\.pnpm[/\\\\])(?!(jose|@noble[/\\\\]hashes|@noble[/\\\\]ciphers)[/\\\\])', - ], - - // Ignore patterns - testPathIgnorePatterns: ['/node_modules/', '/dist/'], - // Coverage settings (optional, disabled by default for E2E) collectCoverage: false, diff --git a/libs/testing/package.json b/libs/testing/package.json index 281118029..c4234b64d 100644 --- a/libs/testing/package.json +++ b/libs/testing/package.json @@ -56,6 +56,12 @@ "./jest-preset": { "default": "./jest-preset.js" }, + "./jest-preset-unit": { + "default": "./jest-preset-unit.js" + }, + "./jest-preset-unit/jest-preset": { + "default": "./jest-preset-unit.js" + }, "./fixtures": { "development": "./src/fixtures/index.ts", "require": { diff --git a/libs/testing/project.json b/libs/testing/project.json index 804b17b1e..d5b2f682d 100644 --- a/libs/testing/project.json +++ b/libs/testing/project.json @@ -19,7 +19,14 @@ "bundle": true, "thirdParty": false, "platform": "node", - "assets": ["libs/testing/README.md", "LICENSE", "libs/testing/jest-preset.js", "libs/testing/package.json"], + "assets": [ + "libs/testing/README.md", + "LICENSE", + "libs/testing/jest-preset.js", + "libs/testing/jest-preset.base.js", + "libs/testing/jest-preset-unit.js", + "libs/testing/package.json" + ], "additionalEntryPoints": [ "libs/testing/src/setup.ts", "libs/testing/src/fixtures/index.ts", diff --git a/libs/testing/src/__tests__/jest-preset-unit.spec.ts b/libs/testing/src/__tests__/jest-preset-unit.spec.ts new file mode 100644 index 000000000..2d17ec263 --- /dev/null +++ b/libs/testing/src/__tests__/jest-preset-unit.spec.ts @@ -0,0 +1,114 @@ +import * as path from 'path'; + +interface JestPreset { + transform: Record; + moduleFileExtensions: string[]; + transformIgnorePatterns: string[]; + testMatch: string[]; + testPathIgnorePatterns: string[]; + testTimeout?: number; + setupFilesAfterEnv?: string[]; +} + +interface ProjectConfig { + transform: [pattern: string, transformerPath: string, options: unknown][]; + transformIgnorePatterns: string[]; +} + +const packageRoot = path.resolve(__dirname, '../..'); +const unitPreset = require('../../jest-preset-unit.js') as JestPreset; +const e2ePreset = require('../../jest-preset.js') as JestPreset; +const { globsToMatcher } = require('jest-util') as { globsToMatcher(globs: string[]): (filePath: string) => boolean }; +const { readConfig } = require('jest-config') as { + readConfig( + argv: object, + config: object, + skipArgvConfigOption: boolean, + configDir: string, + ): Promise<{ projectConfig: ProjectConfig }>; +}; + +async function resolveConfig(config: object): Promise { + const { projectConfig } = await readConfig({}, { rootDir: packageRoot, ...config }, false, packageRoot); + return projectConfig; +} + +function transformerFor(config: ProjectConfig, filePath: string): string | undefined { + return config.transform.find(([pattern]) => new RegExp(pattern).test(filePath))?.[1]; +} + +const isIgnored = (patterns: string[], filePath: string) => + patterns.some((pattern) => new RegExp(pattern).test(filePath)); + +describe('@frontmcp/testing/jest-preset-unit', () => { + it('is what `preset: "@frontmcp/testing/jest-preset-unit"` and the package export load', async () => { + const config = await resolveConfig({ preset: '@frontmcp/testing/jest-preset-unit' }); + + expect(require('@frontmcp/testing/jest-preset-unit')).toBe(unitPreset); + expect(config.transformIgnorePatterns).toEqual(unitPreset.transformIgnorePatterns); + }); + + it('shares the transform of @frontmcp/testing/jest-preset', () => { + expect(unitPreset.transform).toBe(e2ePreset.transform); + expect(unitPreset.transformIgnorePatterns).toBe(e2ePreset.transformIgnorePatterns); + expect(unitPreset.moduleFileExtensions).toBe(e2ePreset.moduleFileExtensions); + }); + + it.each([ + '/proj/node_modules/jose/dist/webapi/index.js', + '/proj/node_modules/.pnpm/jose@6.2.3/node_modules/jose/dist/webapi/index.js', + '/proj/node_modules/@noble/hashes/sha2.js', + '/proj/node_modules/.pnpm/@noble+ciphers@2.1.1/node_modules/@noble/ciphers/aes.js', + ])('transpiles the ESM-only dependency at %s', (filePath) => { + expect(isIgnored(unitPreset.transformIgnorePatterns, filePath)).toBe(false); + }); + + it('still compiles those dependencies with its own transform when ts-jest compiles the TypeScript', async () => { + const config = await resolveConfig({ + preset: '@frontmcp/testing/jest-preset-unit', + transform: { '^.+\\.tsx?$': 'ts-jest' }, + }); + + expect(transformerFor(config, '/proj/src/server.spec.ts')).toBe(require.resolve('ts-jest')); + expect(transformerFor(config, '/proj/node_modules/jose/dist/webapi/index.js')).toBe(require.resolve('@swc/jest')); + }); + + it("keeps Jest's default timeout and registers no setup file", () => { + expect(unitPreset.testTimeout).toBeUndefined(); + expect(unitPreset.setupFilesAfterEnv).toBeUndefined(); + }); + + it.each(['/proj/src/tools/add.tool.spec.ts', '/proj/lib/__tests__/badge.spec.tsx'])('runs %s', (filePath) => { + const matches = globsToMatcher(unitPreset.testMatch.map((glob) => glob.replace('', '/proj'))); + + expect(matches(filePath)).toBe(true); + expect(isIgnored(unitPreset.testPathIgnorePatterns, filePath)).toBe(false); + }); + + it.each([ + '/proj/e2e/server.e2e.spec.ts', + '/proj/src/load.perf.spec.ts', + '/proj/src/ui.pw.spec.tsx', + '/proj/node_modules/pkg/a.spec.ts', + '/proj/dist/a.spec.ts', + ])('leaves out %s', (filePath) => { + expect(isIgnored(unitPreset.testPathIgnorePatterns, filePath)).toBe(true); + }); +}); + +describe('the @frontmcp/testing build', () => { + it('ships every file the package exports outside dist', () => { + const { exports } = require('../../package.json') as { exports: Record }; + const project = require('../../project.json') as { + targets: { 'build-cjs': { options: { assets: (string | { input: string })[] } } }; + }; + const assets = project.targets['build-cjs'].options.assets; + const shipped = Object.values(exports) + .map((target) => target?.default) + .filter((file): file is string => typeof file === 'string' && !file.startsWith('./dist/')) + .map((file) => path.posix.join('libs/testing', file)); + const requiredByPresets = ['libs/testing/jest-preset.base.js']; + + expect(assets).toEqual(expect.arrayContaining([...shipped, ...requiredByPresets])); + }); +});