fix(typert): satisfy workspace static gates
This commit is contained in:
@@ -43,9 +43,7 @@
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -90,7 +90,8 @@ class ClientApiService extends Service implements ClientApi {
|
||||
}
|
||||
}, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`)
|
||||
} catch (error) {
|
||||
disposeRemote().catch(() => {})
|
||||
/* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */
|
||||
Promise.resolve(disposeRemote()).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
@@ -148,6 +149,7 @@ class ClientApiService extends Service implements ClientApi {
|
||||
const projection = scopedProjection(descriptor)
|
||||
if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token))
|
||||
return () => {
|
||||
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
|
||||
if (!token.active) return
|
||||
token.active = false
|
||||
for (const dispose of installed.reverse()) dispose()
|
||||
@@ -173,6 +175,7 @@ class ClientApiService extends Service implements ClientApi {
|
||||
value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args),
|
||||
})
|
||||
return () => {
|
||||
/* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */
|
||||
if (namespace.tokens.get(descriptor.method) !== token) return
|
||||
Reflect.deleteProperty(namespace.value, descriptor.method)
|
||||
namespace.tokens.delete(descriptor.method)
|
||||
@@ -203,6 +206,7 @@ class ClientApiService extends Service implements ClientApi {
|
||||
namespace.tokens.set(descriptor.method, token)
|
||||
namespace.service.install(descriptor, projection, token)
|
||||
return () => {
|
||||
/* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */
|
||||
if (namespace.tokens.get(descriptor.method) !== token) return
|
||||
namespace.service.remove(descriptor.method)
|
||||
namespace.tokens.delete(descriptor.method)
|
||||
@@ -289,9 +293,6 @@ class ScopedRemoteNamespace extends Service {
|
||||
},
|
||||
})
|
||||
this.methods.add(method)
|
||||
if (this.methods.size === 1 && this.ownerCtx.get(this.name, false) === undefined) {
|
||||
this.ownerCtx.set(this.name, this)
|
||||
}
|
||||
}
|
||||
|
||||
remove(method: string): void {
|
||||
|
||||
@@ -156,11 +156,10 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
private async invokeRpc(endpoint: string, payload: unknown): Promise<ConnectionRpcResult> {
|
||||
try {
|
||||
const segments = endpoint.split('/')
|
||||
const namespace = segments[0]
|
||||
const method = segments[1]
|
||||
if (segments.length !== 2 || namespace === undefined || namespace === '' || method === undefined || method === '') {
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
|
||||
throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
|
||||
}
|
||||
const [namespace, method] = segments as [string, string]
|
||||
if (!isObject(payload)
|
||||
|| !isPlainObject(payload)
|
||||
|| Reflect.ownKeys(payload).length !== 1
|
||||
@@ -358,6 +357,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
|
||||
if (parameter.source === 'json') return value
|
||||
const key = parameter.lookup
|
||||
/* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */
|
||||
if (key === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-unavailable',
|
||||
@@ -492,11 +492,11 @@ function methodParameterNames(service: object, method: string, endpoint: string)
|
||||
const source = Function.prototype.toString.call(implementation)
|
||||
const open = source.indexOf('(')
|
||||
const close = source.indexOf(')', open + 1)
|
||||
/* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */
|
||||
if (open < 0 || close < 0) return invalidSignature(endpoint, method)
|
||||
const body = source.slice(open + 1, close).trim()
|
||||
if (body.length === 0) return []
|
||||
const parts = body.split(',').map(part => part.trim())
|
||||
if (parts.at(-1) === '') parts.pop()
|
||||
const names = new Set<string>()
|
||||
for (const part of parts) {
|
||||
if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method)
|
||||
@@ -579,8 +579,8 @@ function assertJsonValue(value: unknown, ancestors: Set<object>): void {
|
||||
if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe')
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe')
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (typeof key !== 'string') throw new TypeError('symbol property is not JSON-safe')
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||
/* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */
|
||||
if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError('non-data property is not JSON-safe')
|
||||
}
|
||||
|
||||
@@ -203,6 +203,144 @@ describe('Client TypeRT API', () => {
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/direct-duplicates',
|
||||
descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
|
||||
})).toThrow('repeats direct method')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/scoped-duplicates',
|
||||
descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
|
||||
})).toThrow('repeats scoped method')
|
||||
|
||||
const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] })
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
|
||||
})).toThrow('direct method goals/create is already mounted')
|
||||
await disposeDirect()
|
||||
|
||||
const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] })
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
|
||||
})).toThrow('scoped method goals/rename is already mounted')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/service-method-conflict',
|
||||
descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }],
|
||||
})).toThrow('conflicts with its namespace service')
|
||||
await disposeScoped()
|
||||
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/context-property-conflict',
|
||||
descriptors: [{ ...context, namespace: 'typert' }],
|
||||
})).toThrow('conflicts with an existing Context property')
|
||||
|
||||
const disposeMultipleScoped = ctx.api.mount({
|
||||
package: '@fixture/multiple-scoped',
|
||||
descriptors: [directDescriptor(), contextDescriptor()],
|
||||
})
|
||||
await disposeMultipleScoped()
|
||||
})
|
||||
|
||||
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/weak-parameter',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
parameters: direct.parameters.map((parameter, index) => index === 0
|
||||
? { ...parameter, codec: { mode: 'src-json' } }
|
||||
: parameter),
|
||||
}],
|
||||
})).toThrow('has no strict codec')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/weak-context',
|
||||
descriptors: [{
|
||||
...context,
|
||||
invocation: { ...context.invocation, codec: { mode: 'src-json' } },
|
||||
} as InvocationDescriptor],
|
||||
})).toThrow('has no strict codec')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/malformed-scope',
|
||||
descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }],
|
||||
})).toThrow('scope must select its only lookup parameter')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/ambiguous-scope',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
parameters: [...direct.parameters, {
|
||||
name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}],
|
||||
}],
|
||||
})).toThrow('scope must select its only lookup parameter')
|
||||
})
|
||||
|
||||
it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const descriptor = directDescriptor()
|
||||
const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] })
|
||||
const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
|
||||
await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1')
|
||||
await expect((ctx as FixtureContext).goals.create({ objective: 'ship' }))
|
||||
.rejects.toThrow('no Client Context binder')
|
||||
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
|
||||
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
|
||||
|
||||
ctx.set('connection', undefined)
|
||||
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => {
|
||||
let resolveCall!: (result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>) => void
|
||||
const pending = new Promise<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>((resolve) => {
|
||||
resolveCall = resolve
|
||||
})
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>().mockReturnValue(pending)
|
||||
const ctx = await bench(call)
|
||||
const { scope: _scope, ...first } = directDescriptor()
|
||||
const second: InvocationDescriptor = {
|
||||
...first,
|
||||
id: '@fixture/goals#goals/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] })
|
||||
const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' })
|
||||
await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
||||
await dispose()
|
||||
resolveCall({ ok: true, value: { ref: 'goal-1' } })
|
||||
|
||||
await expect(invocation).rejects.toThrow('withdrawn during invocation')
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rolls back Remote registration when concrete method installation fails', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'goals') throw new Error('fixture installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
|
||||
.toThrow('fixture installation failure')
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('throws RPC failures with the structured error as its cause', async () => {
|
||||
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
|
||||
|
||||
@@ -229,6 +229,96 @@ class WrongBindingService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
class ExportedMethodService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'exportedMethod', { namespace: 'exported' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'exportedMethod')
|
||||
}
|
||||
|
||||
@Remote('execute')
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyMethodService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'emptyMethod', { namespace: 'empty' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'emptyMethod')
|
||||
}
|
||||
|
||||
@Remote
|
||||
ping(): string {
|
||||
return 'pong'
|
||||
}
|
||||
}
|
||||
|
||||
class CollidingWireService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'collidingWire', { namespace: 'colliding-wire' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'collidingWire')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(agent: FixtureAgent, agentId: string): string {
|
||||
return `${agent.id}:${agentId}`
|
||||
}
|
||||
}
|
||||
|
||||
class ContextWireService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'contextWire', { namespace: 'context-wire' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'contextWire')
|
||||
}
|
||||
|
||||
@RemoteContext('gatewayFixture')
|
||||
run(agentId: string): string {
|
||||
return agentId
|
||||
}
|
||||
}
|
||||
|
||||
class NoBindingService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'noBinding')
|
||||
}
|
||||
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class MissingMethodService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'missingMethod', { namespace: 'missing-method' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'missingMethod')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class InheritedMethodBase extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'inheritedMethod', { namespace: 'inherited' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'inheritedMethod')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class InheritedMethodService extends InheritedMethodBase {}
|
||||
|
||||
describe('TypertGatewayService', () => {
|
||||
it('invokes a strict direct method with schema decoding and a live lookup', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
@@ -284,6 +374,53 @@ describe('TypertGatewayService', () => {
|
||||
})).resolves.toEqual({ title: 'land', scope: 'agent-src' })
|
||||
})
|
||||
|
||||
it('derives exported, empty, inherited, and distinct-namespace SRC methods', async () => {
|
||||
const ctx = await setupGateway()
|
||||
await ctx.plugin(ExportedMethodService)
|
||||
await ctx.plugin(EmptyMethodService)
|
||||
await ctx.plugin(InheritedMethodService)
|
||||
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'exported', method: 'execute', args: { value: 'ship' },
|
||||
})).resolves.toBe('ship')
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'empty', method: 'ping', args: {},
|
||||
})).resolves.toBe('pong')
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'inherited', method: 'run', args: { value: 'land' },
|
||||
})).resolves.toBe('land')
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'other', method: 'absent', args: {},
|
||||
}), 'invocation-unavailable')
|
||||
})
|
||||
|
||||
it('rejects SRC wire collisions and unavailable Context providers', async () => {
|
||||
const colliding = await setupGateway()
|
||||
await colliding.plugin(CollidingWireService)
|
||||
registerAgentLookup(colliding, { id: 'agent-1' })
|
||||
await expectCode(colliding.typertGateway.invoke({
|
||||
namespace: 'colliding-wire',
|
||||
method: 'run',
|
||||
args: { agentId: 'agent-1' },
|
||||
}), 'signature-invalid')
|
||||
|
||||
const missing = await setup()
|
||||
await expectCode(missing.ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
}), 'context-unavailable')
|
||||
|
||||
const contextCollision = await setupGateway()
|
||||
await contextCollision.plugin(ContextWireService)
|
||||
contextCollision.typert.contexts.registerHost('gatewayFixture', contextProvider(contextCollision.extend()))
|
||||
await expectCode(contextCollision.typertGateway.invoke({
|
||||
namespace: 'context-wire',
|
||||
method: 'run',
|
||||
args: { agentId: 'agent-1' },
|
||||
}), 'signature-invalid')
|
||||
})
|
||||
|
||||
it('re-reads Service and providers on every strict invocation', async () => {
|
||||
const { ctx, serviceFiber } = await setup()
|
||||
const agent = { id: 'agent-1' }
|
||||
@@ -331,6 +468,58 @@ describe('TypertGatewayService', () => {
|
||||
expect(error.cause).toEqual(new Error('provider failed'))
|
||||
})
|
||||
|
||||
it('reports Context provider metadata mismatch and unresolved identities', async () => {
|
||||
const { ctx } = await setup()
|
||||
registerStrict(ctx, [renameDescriptor()])
|
||||
const scoped = ctx.extend()
|
||||
const mismatch = ctx.typert.contexts.registerHost('gatewayFixture', {
|
||||
...contextProvider(scoped),
|
||||
wire: 'differentAgentId',
|
||||
})
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
}), 'provider-mismatch')
|
||||
await mismatch()
|
||||
|
||||
ctx.typert.contexts.registerHost('gatewayFixture', {
|
||||
...contextProvider(scoped),
|
||||
resolve: () => undefined,
|
||||
})
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
}), 'context-not-found')
|
||||
})
|
||||
|
||||
it('contains lookup provider failures and missing identities', async () => {
|
||||
const { ctx } = await setup()
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
const throwing = ctx.typert.lookups.register('gatewayFixture', {
|
||||
...agentLookup({ id: 'agent-1' }),
|
||||
resolve: () => { throw new Error('lookup failed') },
|
||||
})
|
||||
const failure = await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'lookup-failed')
|
||||
expect(failure.cause).toEqual(new Error('lookup failed'))
|
||||
await throwing()
|
||||
|
||||
ctx.typert.lookups.register('gatewayFixture', {
|
||||
...agentLookup({ id: 'agent-1' }),
|
||||
resolve: () => undefined,
|
||||
})
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'lookup-not-found')
|
||||
})
|
||||
|
||||
it('never downgrades an observed strict endpoint after definition disposal', async () => {
|
||||
const { ctx } = await setup()
|
||||
const dispose = registerStrict(ctx, [passthroughDescriptor()])
|
||||
@@ -434,6 +623,11 @@ describe('TypertGatewayService', () => {
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true },
|
||||
}), 'arguments-invalid')
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: [] as unknown as Record<string, unknown>,
|
||||
}), 'arguments-invalid')
|
||||
expect(service.calls).toEqual([])
|
||||
})
|
||||
|
||||
@@ -492,6 +686,31 @@ describe('TypertGatewayService', () => {
|
||||
}), 'result-invalid')
|
||||
})
|
||||
|
||||
it('accepts dense JSON and rejects decorated arrays and object properties', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value: [1, { nested: true }] },
|
||||
})).resolves.toEqual([1, { nested: true }])
|
||||
|
||||
const sparseWithExtra = Array(1) as unknown[] & { extra?: boolean }
|
||||
sparseWithExtra.extra = true
|
||||
const symbolArray = [1]
|
||||
Object.defineProperty(symbolArray, Symbol('extra'), { value: true })
|
||||
const symbolObject = { value: true }
|
||||
Object.defineProperty(symbolObject, Symbol('extra'), { value: true })
|
||||
const hidden = {}
|
||||
Object.defineProperty(hidden, 'value', { value: true, enumerable: false })
|
||||
const accessor = {}
|
||||
Object.defineProperty(accessor, 'value', { get: () => true, enumerable: true })
|
||||
for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) {
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals', method: 'passthrough', args: { value },
|
||||
}), 'input-invalid')
|
||||
}
|
||||
})
|
||||
|
||||
it('validates strict provider identity against generated wire metadata', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.typert.lookups.register('gatewayFixture', {
|
||||
@@ -525,6 +744,61 @@ describe('TypertGatewayService', () => {
|
||||
}), 'method-unavailable')
|
||||
})
|
||||
|
||||
it('requires a visible binding and supports explicitly provided plain Services', async () => {
|
||||
const ctx = await setupGateway()
|
||||
await ctx.plugin(NoBindingService)
|
||||
registerStrict(ctx, [{
|
||||
...passthroughDescriptor(),
|
||||
id: '@fixture/gateway#no-binding/run',
|
||||
service: 'noBinding',
|
||||
namespace: 'no-binding',
|
||||
method: 'run',
|
||||
}])
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'no-binding', method: 'run', args: { value: 'ship' },
|
||||
}), 'binding-invalid')
|
||||
|
||||
const plain: {
|
||||
typertGateway?: ReturnType<typeof bindTypeRTGateway>
|
||||
run(value: string): string
|
||||
} = { run: value => value }
|
||||
plain.typertGateway = bindTypeRTGateway(plain, 'plainRemote', { namespace: 'plain' })
|
||||
ctx.provide('plainRemote', plain)
|
||||
ctx.typert.register({
|
||||
package: '@fixture/plain',
|
||||
face: 'host',
|
||||
schemas: [],
|
||||
model: emptyModel,
|
||||
invocations: [{
|
||||
...passthroughDescriptor(),
|
||||
id: '@fixture/plain#plain/run',
|
||||
service: 'plainRemote',
|
||||
namespace: 'plain',
|
||||
method: 'run',
|
||||
}],
|
||||
})
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'plain', method: 'run', args: { value: 'land' },
|
||||
})).resolves.toBe('land')
|
||||
})
|
||||
|
||||
it('reports a SRC marker whose prototype implementation disappeared', async () => {
|
||||
const ctx = await setupGateway()
|
||||
await ctx.plugin(MissingMethodService)
|
||||
const descriptor = Object.getOwnPropertyDescriptor(MissingMethodService.prototype, 'run')!
|
||||
Object.defineProperty(MissingMethodService.prototype, 'run', {
|
||||
configurable: true,
|
||||
value: 42,
|
||||
})
|
||||
try {
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'missing-method', method: 'run', args: { value: 'ship' },
|
||||
}), 'method-unavailable')
|
||||
} finally {
|
||||
Object.defineProperty(MissingMethodService.prototype, 'run', descriptor)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves business exception identity after invocation begins', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const failure = new Error('business identity')
|
||||
@@ -575,6 +849,26 @@ describe('TypertGatewayService', () => {
|
||||
if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
|
||||
expect(invalid.error.message).toMatch(/exactly one plain-object args field/)
|
||||
|
||||
for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) {
|
||||
const result = await handler(endpoint, { args: {} }, signal)
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
if (result.ok) throw new Error('invalid Remote endpoint unexpectedly succeeded')
|
||||
expect(result.error.message).toContain('invalid Remote endpoint')
|
||||
}
|
||||
for (const payload of [null, [], { args: {}, extra: true }, { only: true }, { args: null }, { args: [] }]) {
|
||||
const result = await handler('goals/create', payload, signal)
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
if (result.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
|
||||
expect(result.error.message).toContain('plain-object args field')
|
||||
}
|
||||
|
||||
const service = rawGoalService(ctx)
|
||||
service.businessError = 'non-error failure' as unknown as Error
|
||||
await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'non-error failure', details: {} },
|
||||
})
|
||||
|
||||
await gatewayFiber.dispose()
|
||||
expect(connection.handler).toBeUndefined()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user