From 8b51a1e95c4bc87d69ac5c060ec390ea571b08f4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:11:42 +0800 Subject: [PATCH] test(api-gateway): cover namespace rollback paths --- packages/api/gateway/src/client/index.ts | 58 +++++++++-------------- packages/api/gateway/tests/client.spec.ts | 40 ++++++++++++++++ 2 files changed, 62 insertions(+), 36 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index d0429339c8..e49e9e5822 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -194,7 +194,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { throw error } return async () => { - if (!namespace.service.remove('direct', descriptor.method, token)) return + namespace.service.remove('direct', descriptor.method, token) await this.disposeNamespace(descriptor.namespace, namespace) } } @@ -212,7 +212,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { throw error } return async () => { - if (!namespace.service.remove('scoped', descriptor.method, token)) return + namespace.service.remove('scoped', descriptor.method, token) await this.disposeNamespace(descriptor.namespace, namespace) } } @@ -390,50 +390,36 @@ class RemoteNamespaceService extends Service { let record = this.methods.get(method) const fresh = record === undefined record ??= {} - if (record[kind] !== undefined) { - throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`) - } - try { - if (fresh) { - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { - const callerCtx = this.ctx - const current = this.methods.get(method) - const direct = current?.direct - const scoped = current?.scoped - return (...args: unknown[]) => { - return this.invokeRemote(direct, scoped, callerCtx, args) - } - }, - }) - this.methods.set(method, record) - } - if (kind === 'direct') record.direct = value - else record.scoped = value as ScopedMethod - } catch (error) { - if (kind === 'direct') delete record.direct - else delete record.scoped - if (fresh) { - this.methods.delete(method) - Reflect.deleteProperty(this, method) - } - throw error + if (fresh) { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { + const callerCtx = this.ctx + const current = this.methods.get(method) + const direct = current?.direct + const scoped = current?.scoped + return (...args: unknown[]) => { + return this.invokeRemote(direct, scoped, callerCtx, args) + } + }, + }) + this.methods.set(method, record) } + if (kind === 'direct') record.direct = value + else record.scoped = value as ScopedMethod } - remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean { + remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void { const record = this.methods.get(method) const current = record?.[kind] /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ - if (record === undefined || current?.token !== token) return false + if (record === undefined || current?.token !== token) return if (kind === 'direct') delete record.direct else delete record.scoped - if (record.direct !== undefined || record.scoped !== undefined) return true + if (record.direct !== undefined || record.scoped !== undefined) return this.methods.delete(method) Reflect.deleteProperty(this, method) - return true } } diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index d253c38acc..01bb9c53b7 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -316,6 +316,32 @@ describe('Client TypeRT API', () => { await retry() }) + it('rolls back a direct projection when its scoped projection fails to install', async () => { + const ctx = await bench(vi.fn()) + const disposeContext = await ctx.remote.$mount({ + package: '@fixture/context-anchor', + descriptors: [contextDescriptor()], + }) + const namespace = ctx.get('remote.goals') as unknown as { + installScoped: (...args: unknown[]) => void + readonly create?: unknown + } + const installScoped = vi.spyOn(namespace, 'installScoped').mockImplementation(() => { + throw new Error('fixture scoped projection failure') + }) + try { + await expect(ctx.remote.$mount({ + package: '@fixture/direct-projection-failure', + descriptors: [directDescriptor()], + })).rejects.toThrow('fixture scoped projection failure') + } finally { + installScoped.mockRestore() + } + + expect(namespace.create).toBeUndefined() + await disposeContext() + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() @@ -406,6 +432,20 @@ describe('Client TypeRT API', () => { expect((ctx.remote as unknown as Record).goals).toBeUndefined() }) + it('rejects a method obtained from a withdrawn namespace getter', async () => { + const ctx = await bench(vi.fn()) + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + const namespace = ctx.get('remote.goals') as unknown as object + const getter = Object.getOwnPropertyDescriptor(namespace, 'create')?.get + + await dispose() + + expect(getter).toBeTypeOf('function') + const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise + await expect(withdrawn('agent-1', { objective: 'ship' })) + .rejects.toThrow('Remote method is no longer mounted') + }) + it('preserves a __proto__ wire parameter as an own named argument', async () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })