diff --git a/packages/agent/test-live/rfc64-legacy-swm-boundary-v1.blazegraph.test.ts b/packages/agent/test-live/rfc64-legacy-swm-boundary-v1.blazegraph.test.ts index 70281f36ca..402de2f3fe 100644 --- a/packages/agent/test-live/rfc64-legacy-swm-boundary-v1.blazegraph.test.ts +++ b/packages/agent/test-live/rfc64-legacy-swm-boundary-v1.blazegraph.test.ts @@ -85,18 +85,6 @@ describe('RFC-64 legacy SWM boundary (live Blazegraph)', () => { ); expect(HISTORICAL_OPERATION_ROWS).toBeGreaterThan(100_000); expect(firstHeadReadCount).toBe(1); - const captureQueries = querySpy.mock.calls.filter(([, options]) => ( - options?.source === 'agent.rfc64.legacySwmBoundary.readHeads' - )).map(([sparql]) => sparql); - expect(captureQueries).toHaveLength(1); - expect(captureQueries[0]).toContain( - 'BIND(?operationUal AS ?ual)', - ); - expect(captureQueries[0]!.match( - / \?shareId/g, - )).toHaveLength(2); - expect(captureQueries[0]).toContain('LIMIT 100001'); - // A second owner represents the next process start. It must load the // durable capture rather than repeat the capture query. The late-entry // marker read still runs on every start, so observe the sources directly. diff --git a/packages/agent/test/_helpers/swm-entity-share-publisher-fixture.ts b/packages/agent/test/_helpers/swm-entity-share-publisher-fixture.ts index f30666b6dc..3595e7fb8e 100644 --- a/packages/agent/test/_helpers/swm-entity-share-publisher-fixture.ts +++ b/packages/agent/test/_helpers/swm-entity-share-publisher-fixture.ts @@ -63,9 +63,7 @@ export async function makeEntitySharePublisherFixture(options: { rootEntity, options.subGraphName, )); - // Preserve the recovery suite's manifest order independently of CONSTRUCT ordering. - const meta = result.quads.map(quad => ({ ...quad, graph: metaGraph })) - .sort((a, b) => sliceSubjects.indexOf(a.subject) - sliceSubjects.indexOf(b.subject)); + const meta = result.quads.map(quad => ({ ...quad, graph: metaGraph })); const decodedSlices = decodeEntityShareMetadata(options.contextGraphId, meta) .filter(record => record.kind === 'slice'); const slices = options.rootEntities.map((rootEntity, index): EntitySharePublisherSlice => { diff --git a/packages/agent/test/_helpers/swm-recovery-fixture.ts b/packages/agent/test/_helpers/swm-recovery-fixture.ts index c28c30ecd7..120dbc5ecd 100644 --- a/packages/agent/test/_helpers/swm-recovery-fixture.ts +++ b/packages/agent/test/_helpers/swm-recovery-fixture.ts @@ -3,13 +3,14 @@ import { contextGraphWorkspaceMetaGraphUri, type OperationContext, } from '@origintrail-official/dkg-core'; -import type { WorkspacePublicSnapshotStore } from '@origintrail-official/dkg-publisher'; import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage'; import type { SyncPhase } from '../../src/sync/auth/request-build.js'; import type { SyncPageResult } from '../../src/sync/requester/page-fetch.js'; import { createSharedMemorySnapshotMaterializer } from '../../src/sync/requester/swm-snapshot-materializer.js'; +export { MemoryWorkspaceSnapshotStore as MemorySnapshotStore } from + './memory-workspace-snapshot-store.js'; export const CG = 'ws00-recovery'; export const WS = contextGraphWorkspaceGraphUri(CG); @@ -25,19 +26,6 @@ export const XSD_INTEGER = 'http://www.w3.org/2001/XMLSchema#integer'; export const UAL = 'did:dkg:hardhat:31337/0x00000000000000000000000000000000000000ab/7'; export const UAL_2 = 'did:dkg:hardhat:31337/0x00000000000000000000000000000000000000ab/8'; -export class MemorySnapshotStore implements WorkspacePublicSnapshotStore { - readonly snapshots = new Map(); - - async putSnapshot(input: { readonly digest: string; readonly quads: readonly Quad[] }) { - this.snapshots.set(input.digest, input.quads.map((quad) => ({ ...quad }))); - return { ref: input.digest, byteLength: 0 }; - } - - async getSnapshot(ref: string): Promise { - return this.snapshots.get(ref)?.map((quad) => ({ ...quad })) ?? null; - } -} - export function recoveryPage(quads: Quad[], completed = true): SyncPageResult { return { quads, diff --git a/packages/agent/test/entity-share-recovery.test.ts b/packages/agent/test/entity-share-recovery.test.ts index 24d1e31f97..9440aa4e8c 100644 --- a/packages/agent/test/entity-share-recovery.test.ts +++ b/packages/agent/test/entity-share-recovery.test.ts @@ -44,6 +44,14 @@ describe('entity-share recovery beside malformed KA heads', () => { publish([root, siblingRoot], [...payload, ...siblingPayload]), publish([root], payload, 'research'), ]); + const recoveryOrderedMeta = ( + fixture: Awaited>, + ): Quad[] => { + const sliceSubjects = fixture.slices.map((slice) => slice.sliceSubject); + return [...fixture.meta].sort( + (a, b) => sliceSubjects.indexOf(a.subject) - sliceSubjects.indexOf(b.subject), + ); + }; const sliceFor = (fixture: Awaited>, rootEntity: string) => { const slice = fixture.slices.find(candidate => candidate.rootEntity === rootEntity); if (!slice) throw new Error(`Entity-share recovery fixture did not publish ${rootEntity}`); @@ -61,9 +69,11 @@ describe('entity-share recovery beside malformed KA heads', () => { } const metaGraph = entityPublished.metaGraph; const namedMetaGraph = namedPublished.metaGraph; - const entityMeta = entityPublished.meta; - const twoRootMeta = twoRootPublished.meta; - const namedMeta = namedPublished.meta; + // Recovery traversal owns its deterministic manifest order; the publisher + // fixture returns the publisher/store result without consumer policy. + const entityMeta = recoveryOrderedMeta(entityPublished); + const twoRootMeta = recoveryOrderedMeta(twoRootPublished); + const namedMeta = recoveryOrderedMeta(namedPublished); const sliceSubject = entitySlice.sliceSubject; const siblingSubject = siblingSlice.sliceSubject; const ka = swmFixtures(COVERAGE_CG).manifest(1)[0]!; diff --git a/packages/agent/test/pca-v10-facade.test.ts b/packages/agent/test/pca-v10-facade.test.ts index 477b8ab107..0d31aa75ee 100644 --- a/packages/agent/test/pca-v10-facade.test.ts +++ b/packages/agent/test/pca-v10-facade.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; import { ethers } from 'ethers'; import { DKGAgent } from '../src/index.js'; -import { MockChainAdapter, NoChainAdapter } from '@origintrail-official/dkg-chain'; +import { + MockChainAdapter, + NoChainAdapter, + PcaUnavailableError, +} from '@origintrail-official/dkg-chain'; async function makeAgent(chain: MockChainAdapter | NoChainAdapter): Promise { return DKGAgent.create({ @@ -126,6 +130,10 @@ describe('DKGAgent V10 PCA facade', () => { const legacyAgent = await makeAgent(legacyOnly); await expect(legacyAgent.requestBrowserWalletRpc('eth_blockNumber', [])).resolves.toBe('0xlegacy'); expect(legacyRpc).toHaveBeenCalledWith('eth_blockNumber', []); + + const unsupported = await makeAgent(new NoChainAdapter()); + await expect(unsupported.requestPublishingConvictionRpc('eth_chainId', [])) + .rejects.toBeInstanceOf(PcaUnavailableError); }); it('getPublishingConvictionAgents delegates to the adapter (checksummed list)', async () => { diff --git a/packages/agent/test/rfc64-legacy-swm-boundary-v1.test.ts b/packages/agent/test/rfc64-legacy-swm-boundary-v1.test.ts index 35a1f77008..afaad998a8 100644 --- a/packages/agent/test/rfc64-legacy-swm-boundary-v1.test.ts +++ b/packages/agent/test/rfc64-legacy-swm-boundary-v1.test.ts @@ -67,11 +67,16 @@ describe('RFC-64 10.0.16 legacy SWM boundary', () => { it('captures once, remains private by count, and retires only after explicit republish', async () => { const root = await secureTempRoot(roots); - const heads = new Map([ - [META_GRAPH, [UAL_ONE]], - [SUBGRAPH_META_GRAPH, []], - ]); - const store = fakeStore(heads); + const store = new OxigraphStore(); + const listGraphs = vi.spyOn(store, 'listGraphs'); + await store.insert(legacySwmBoundaryFixtureQuadsV1({ + graph: META_GRAPH, + contextGraphId: CONTEXT_GRAPH_ID, + ual: UAL_ONE, + operation: 'urn:dkg:workspace-operation:first-capture', + head: { shareOperationId: 'first-capture' }, + operationShareOperationIds: ['first-capture'], + })); const firstOwner = {}; await initializeRfc64LegacySwmBoundaryV1(firstOwner, root, store); @@ -79,7 +84,14 @@ describe('RFC-64 10.0.16 legacy SWM boundary', () => { // A later restart must load the immutable first-upgrade capture instead of // silently classifying a new 10.0.16 share as historical. - heads.set(SUBGRAPH_META_GRAPH, [UAL_TWO]); + await store.insert(legacySwmBoundaryFixtureQuadsV1({ + graph: SUBGRAPH_META_GRAPH, + contextGraphId: CONTEXT_GRAPH_ID, + ual: UAL_TWO, + operation: 'urn:dkg:workspace-operation:late-named', + head: { shareOperationId: 'late-named' }, + operationShareOperationIds: ['late-named'], + })); const restartedOwner = {}; await initializeRfc64LegacySwmBoundaryV1(restartedOwner, root, store); expect(readRfc64LegacySwmBoundaryCountV1(restartedOwner, CONTEXT_GRAPH_ID)).toBe(1); @@ -102,7 +114,7 @@ describe('RFC-64 10.0.16 legacy SWM boundary', () => { const secondRestartOwner = {}; await initializeRfc64LegacySwmBoundaryV1(secondRestartOwner, root, store); expect(readRfc64LegacySwmBoundaryCountV1(secondRestartOwner, CONTEXT_GRAPH_ID)).toBe(0); - expect(store.listGraphs).not.toHaveBeenCalled(); + expect(listGraphs).not.toHaveBeenCalled(); }); it('persists an atomic post-capture legacy SHARE companion until that exact UAL is republished', async () => { @@ -540,56 +552,33 @@ describe('RFC-64 10.0.16 legacy SWM boundary', () => { expect(readRfc64LegacySwmBoundaryCountV1(owner, CONTEXT_GRAPH_ID)).toBe(0); }); - it('does not let named metadata graphs consume the root graph capture cap', async () => { + it('captures the root graph with one behavioral store read and no graph enumeration', async () => { const root = await secureTempRoot(roots); - const heads = new Map([[META_GRAPH, [UAL_ONE]]]); - for (let index = 0; index < 16_384; index += 1) { - heads.set( - contextGraphSharedMemoryMetaUri(CONTEXT_GRAPH_ID, `named-${index}`), - [], - ); - } - const store = fakeStore(heads); + const store = new OxigraphStore(); + await store.insert(legacySwmBoundaryFixtureQuadsV1({ + graph: META_GRAPH, + contextGraphId: CONTEXT_GRAPH_ID, + ual: UAL_ONE, + operation: 'urn:dkg:workspace-operation:bounded-read', + head: { shareOperationId: 'bounded-read' }, + operationShareOperationIds: ['bounded-read'], + })); + const listGraphs = vi.spyOn(store, 'listGraphs'); + const query = vi.spyOn(store, 'query'); const owner = {}; await initializeRfc64LegacySwmBoundaryV1(owner, root, store); expect(readRfc64LegacySwmBoundaryCountV1(owner, CONTEXT_GRAPH_ID)).toBe(1); - expect(store.listGraphs).not.toHaveBeenCalled(); - expect(store.query).toHaveBeenCalledWith( - expect.stringContaining('GRAPH ?metaGraph'), - expect.objectContaining({ - source: 'agent.rfc64.legacySwmBoundary.readHeads', - }), - ); - }); - - it('uses one bounded fully correlated capture query', async () => { - const root = await secureTempRoot(roots); - const store = fakeStore(new Map([[META_GRAPH, [UAL_ONE]]])); - - await initializeRfc64LegacySwmBoundaryV1({}, root, store); - - const captureQueryCalls = vi.mocked(store.query).mock.calls.filter(([, options]) => ( + expect(listGraphs).not.toHaveBeenCalled(); + const captureQueryCalls = query.mock.calls.filter(([, options]) => ( options?.source === 'agent.rfc64.legacySwmBoundary.readHeads' )); expect(captureQueryCalls).toHaveLength(1); - const captureQuery = captureQueryCalls[0]![0]; - expect(captureQuery).toContain( - '?operation ', - ); - expect(captureQuery).toContain( - 'BIND(?operationUal AS ?ual)', - ); - expect(captureQuery).toContain( - '?head ?ual ; ?shareId', - ); - expect(captureQuery).toContain('LIMIT 100001'); - expect(captureQuery).toContain( - 'FILTER(STRENDS(STR(?head), "#dkg-swm-head"))', - ); - expect(captureQuery).not.toContain('VALUES'); - expect(captureQuery).not.toContain('queryHints#'); + // The fail-closed head cap (legacy-swm-boundary-v1.ts: bindings.length > + // RFC64_LEGACY_SWM_HEAD_LIMIT_V1) is only reachable while the query fetches + // one row MORE than the limit, so pin that exact relationship. + expect(captureQueryCalls[0]![0]).toContain('LIMIT 100001'); }); it.each([ @@ -753,29 +742,6 @@ async function secureTempRoot(roots: string[]): Promise { return root; } -function fakeStore( - headsByGraph: Map, -): TripleStore { - return { - listGraphs: vi.fn(async () => [...headsByGraph.keys()]), - query: vi.fn(async (_sparql: string, options?: { source?: string }) => { - const rootHeads = headsByGraph.get(META_GRAPH) ?? []; - if (options?.source === 'agent.rfc64.legacySwmBoundary.readHeads') { - return { - type: 'bindings' as const, - bindings: rootHeads.map((ual) => ({ - metaGraph: META_GRAPH, - head: `${ual}#dkg-swm-head`, - ual, - contextGraphId: `"${CONTEXT_GRAPH_ID}"`, - })), - }; - } - return { type: 'bindings' as const, bindings: [] }; - }), - } as unknown as TripleStore; -} - function captureBinding( contextGraphId = CONTEXT_GRAPH_ID, ual = UAL_ONE, diff --git a/packages/agent/test/sync-fetch-coalescing.test.ts b/packages/agent/test/sync-fetch-coalescing.test.ts index 3440566a12..9bf5f42255 100644 --- a/packages/agent/test/sync-fetch-coalescing.test.ts +++ b/packages/agent/test/sync-fetch-coalescing.test.ts @@ -209,6 +209,53 @@ describe('exact VM recovery lifecycle', () => { } }); + it.each(['configured-store', 'storeless'] as const)( + 'awaits strict membership reconciliation and propagates failure with a %s', + async (storeCase) => { + const membershipUpsert = vi.fn(async () => undefined); + const agent = await createAgentWithSend( + async () => new Uint8Array(0), + undefined, + storeCase === 'configured-store' + ? { + loadAll: async () => [], + upsert: membershipUpsert, + delete: async () => undefined, + } + : undefined, + ); + const reconciliation = deferred(); + const reconciliationFailure = new Error('responsibility reconciliation failed'); + const reconcile = vi.spyOn(agent, 'reconcileRfc64CatalogResponsibilityV1') + .mockReturnValue(reconciliation.promise); + try { + let settled = false; + const strictWrite = agent.upsertContextGraphMember({ + contextGraphId: `strict-membership-${storeCase}`, + principalType: 'node', + principalId: PEER_A, + status: 'active', + }, { strict: true }); + const failure = strictWrite.then( + () => { settled = true; return undefined; }, + (error: unknown) => { settled = true; return error; }, + ); + + await vi.waitFor(() => expect(reconcile).toHaveBeenCalledOnce()); + expect(membershipUpsert).toHaveBeenCalledTimes( + storeCase === 'configured-store' ? 1 : 0, + ); + expect(settled).toBe(false); + + reconciliation.reject(reconciliationFailure); + expect(await failure).toBe(reconciliationFailure); + } finally { + reconciliation.resolve(); + await agent.stop().catch(() => {}); + } + }, + ); + it('quarantines a physically active reconcile until shutdown is retried', async () => { const timeoutDescriptor = Object.getOwnPropertyDescriptor( DKGAgentBase, diff --git a/packages/chain/test/rpc-usage.unit.test.ts b/packages/chain/test/rpc-usage.unit.test.ts index a5ceda741a..282ec077bb 100644 --- a/packages/chain/test/rpc-usage.unit.test.ts +++ b/packages/chain/test/rpc-usage.unit.test.ts @@ -299,6 +299,53 @@ describe('RPC usage accounting — raw request counts EQUAL the server-received }); }); + it('gives canonical tracker attribution precedence over its legacy compatibility map', () => { + const tracker = new RpcUsageTracker(() => 'evm:31337'); + withRpcUsageConsumer('token.balanceOf', () => tracker.record('eth_call')); + + const dualFormatWindow = tracker.drainWindow(); + expect(dualFormatWindow.ethCallByConsumer).toEqual({ 'token.balanceOf': 1 }); + expect(dualFormatWindow.attributions).toEqual([ + { method: 'eth_call', consumer: 'token.balanceOf', count: 1 }, + ]); + const normalized = normalizeRpcUsageWindow(dualFormatWindow); + expect(normalized.ethCallByConsumer).toEqual({ 'token.balanceOf': 1 }); + expect(normalized.attributions).toEqual([ + { method: 'eth_call', consumer: 'token.balanceOf', count: 1 }, + ]); + + // A tracker-drained window carries both representations built from the + // SAME counters, so it can only ever show them agreeing. Precedence is + // only observable when they disagree — hand-build that case. Reverting + // normalizeRpcUsageWindow to the legacy branch yields + // { 'stale.legacy': 9 } here and drops the eth_getLogs attribution. + const contradictory = normalizeRpcUsageWindow({ + byMethod: { eth_call: 3, eth_getLogs: 1 }, + ethCallByConsumer: { 'stale.legacy': 9 }, + ethGetLogsByConsumerAndEndpointSlot: { 'stale.legacy': { primary: 9 } }, + attributions: [ + { method: 'eth_call', consumer: 'token.balanceOf', count: 3 }, + { + method: 'eth_getLogs', + consumer: 'cg.authority.history', + endpointSlot: 'fallback_1', + count: 1, + }, + ], + lifetimeTotal: 4, + }); + expect(contradictory.ethCallByConsumer).toEqual({ 'token.balanceOf': 3 }); + expect(contradictory.attributions).toEqual([ + { method: 'eth_call', consumer: 'token.balanceOf', count: 3 }, + { + method: 'eth_getLogs', + consumer: 'cg.authority.history', + endpointSlot: 'fallback_1', + count: 1, + }, + ]); + }); + it('returns a concrete empty RPC usage window from the mock adapter', () => { expect(new MockChainAdapter().drainRpcUsage()).toEqual({ byMethod: {}, diff --git a/packages/cli/test/status-route-rpc.test.ts b/packages/cli/test/status-route-rpc.test.ts index d2c7d94b73..5672ab3d8d 100644 --- a/packages/cli/test/status-route-rpc.test.ts +++ b/packages/cli/test/status-route-rpc.test.ts @@ -22,6 +22,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; +import { ethers } from 'ethers'; import { ChainRpcTransportError, noteRpcFailover, @@ -190,6 +191,7 @@ async function requestStatusWithAgent( rfc64CatalogOverride?: RequestContext['rfc64Catalog'], rfc64PublicCatalogOverride?: RequestContext['rfc64PublicCatalog'], routeRpcTransport?: DaemonRouteRpcTransport, + opWalletsOverride: RequestContext['opWallets'] = { wallets: [] }, ): Promise<{ status: number; body: any }> { const server = createServer(async (req, res) => { const url = new URL(req.url ?? '/', 'http://127.0.0.1'); @@ -232,6 +234,7 @@ async function requestStatusWithAgent( nodeCommit: '', admission: { inFlight: 0, max: 0, rejectedTotal: 0 }, routeRpcTransport, + opWallets: opWalletsOverride, } as unknown as RequestContext); }); @@ -247,6 +250,159 @@ async function requestStatusWithAgent( } } +describe('/api/wallets/balances governed transport', () => { + const hubAddress = '0x1111111111111111111111111111111111111111'; + const tokenAddress = '0x2222222222222222222222222222222222222222'; + const walletAddress = '0x3333333333333333333333333333333333333333'; + const opWallets: RequestContext['opWallets'] = { + wallets: [{ + address: walletAddress, + privateKey: `0x${'11'.repeat(32)}`, + }], + }; + + it('accounts every wallet RPC through the daemon-owned governor', async () => { + const methods: string[] = []; + const abi = ethers.AbiCoder.defaultAbiCoder(); + const rpc = createServer((req, res) => { + let raw = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { raw += chunk; }); + req.on('end', () => { + const call = JSON.parse(raw) as { + id: string | number; + method: string; + params?: Array<{ data?: string }>; + }; + methods.push(call.method); + const data = call.params?.[0]?.data?.toLowerCase(); + const result = call.method === 'eth_chainId' + ? '0x7a69' + : call.method === 'eth_getBalance' + ? ethers.toBeHex(ethers.parseEther('1.5')) + : call.method === 'eth_call' && data?.startsWith('0x95d89b41') + ? abi.encode(['string'], ['TRAC']) + : call.method === 'eth_call' && data?.startsWith('0x70a08231') + ? abi.encode(['uint256'], [ethers.parseEther('7')]) + : '0x10'; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ jsonrpc: '2.0', id: call.id, result })); + }); + }); + await new Promise((resolve) => rpc.listen(0, '127.0.0.1', resolve)); + const address = rpc.address() as AddressInfo; + const rpcUrl = `http://127.0.0.1:${address.port}`; + const chain = { + type: 'evm', + rpcUrl, + chainId: 'evm:31337', + hubAddress, + tokenAddress, + rpcRequestBudget: { + maxRequestsPerSecond: 100, + foregroundReservePercent: 80, + burstRequests: 10, + maxQueueSize: 8, + startupJitterMs: 0, + }, + }; + const runtime = createDaemonRpcRuntime(chain)!; + try { + const response = await requestStatusWithAgent( + {}, + { chain }, + '/api/wallets/balances', + null, + undefined, + undefined, + runtime.routeTransport, + opWallets, + ); + + expect(response).toMatchObject({ + status: 200, + body: { + wallets: [walletAddress], + balances: [{ + address: walletAddress, + eth: '1.5', + trac: '7.0', + symbol: 'TRAC', + }], + symbol: 'TRAC', + }, + }); + const usage = runtime.drainRouteRpcUsage(); + expect(usage.byMethod.eth_getBalance).toBe(1); + expect(usage.byMethod.eth_call).toBe(2); + expect(methods).toEqual(expect.arrayContaining([ + 'eth_chainId', + 'eth_getBalance', + 'eth_call', + ])); + expect(runtime.governor.snapshot().foregroundAdmitted).toBe(methods.length); + } finally { + await new Promise((resolve, reject) => { + rpc.close((error) => error ? reject(error) : resolve()); + }); + } + }, 10_000); + + it('fails locally without reaching RPC when the governed queue is saturated', async () => { + let hits = 0; + const rpc = createServer((_req, res) => { + hits += 1; + res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x7a69' })); + }); + await new Promise((resolve) => rpc.listen(0, '127.0.0.1', resolve)); + const address = rpc.address() as AddressInfo; + const rpcUrl = `http://127.0.0.1:${address.port}`; + const chain = { + type: 'evm', + rpcUrl, + chainId: 'evm:31337', + hubAddress, + tokenAddress, + rpcRequestBudget: { + maxRequestsPerSecond: 1, + foregroundReservePercent: 99, + burstRequests: 1, + maxQueueSize: 1, + startupJitterMs: 0, + }, + }; + const runtime = createDaemonRpcRuntime(chain)!; + const controller = new AbortController(); + await runtime.governor.acquire('foreground'); + const queued = runtime.governor.acquire('foreground', controller.signal); + await vi.waitFor(() => expect(runtime.governor.snapshot().foregroundQueued).toBe(1)); + try { + const response = await requestStatusWithAgent( + {}, + { chain }, + '/api/wallets/balances', + null, + undefined, + undefined, + runtime.routeTransport, + opWallets, + ); + + expect(response.status).toBe(200); + expect(response.body.balances).toEqual([]); + expect(response.body.error).toContain('RPC request governor queue is full'); + expect(hits).toBe(0); + expect(runtime.governor.snapshot().rejected).toBeGreaterThanOrEqual(1); + } finally { + controller.abort(new Error('test cleanup')); + await expect(queued).rejects.toThrow('test cleanup'); + await new Promise((resolve, reject) => { + rpc.close((error) => error ? reject(error) : resolve()); + }); + } + }, 10_000); +}); + describe('/api/chain/rpc-health partial adapter configuration', () => { it('uses the governed daemon transport when rpcUrl exists without a Hub address', async () => { let hits = 0; diff --git a/scripts/lib/__tests__/ci-delta.test.mjs b/scripts/lib/__tests__/ci-delta.test.mjs index 89a9f55009..5faac09307 100644 --- a/scripts/lib/__tests__/ci-delta.test.mjs +++ b/scripts/lib/__tests__/ci-delta.test.mjs @@ -26,6 +26,7 @@ import { validatePrimaryResults, } from '../ci-results.mjs'; import { validateTrustedControllerPins } from '../../ci/trusted-controller-pins.mjs'; +import { EVM_TEST_SCOPES } from '../../ci/evm-test-scopes.mjs'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); // This SHA is already reachable from the protected default branch. Candidate @@ -349,6 +350,42 @@ test('ordinary network-sim changes remain a narrow delta after the trust hardeni assert.deepEqual(plan.evmScopes, []); }); +test('identity-wallet browser actions select the real-EVM chain scope', () => { + // Every shape isIdentityWalletEvmPath matches, including the extension + // alternation (the .tsx spelling is a shape probe, not an existing file). + for (const filePath of [ + 'packages/node-ui/src/ui/web3/identityWalletActions.ts', + 'packages/node-ui/src/ui/web3/identityWalletActions.tsx', + 'packages/node-ui/src/ui/web3/browserWalletTransaction.ts', + 'packages/node-ui/src/ui/pages/identity-wallets/useIdentityWalletManagement.ts', + 'packages/node-ui/integration/identity-wallet-actions-v10.test.ts', + ]) { + const plan = pullRequestPlan([change(filePath)]); + assert.deepEqual(plan.evmScopes, ['chain'], filePath); + assert.match(plan.reasons.join('\n'), /identity-wallet browser actions/, filePath); + } + + for (const filePath of [ + 'packages/node-ui/src/ui/pages/Dashboard.tsx', + 'packages/node-ui/src/ui/web3/session.ts', + ]) { + assert.deepEqual(pullRequestPlan([change(filePath)]).evmScopes, [], filePath); + } + + // ci-delta.mjs cannot import the manifest (it runs from the four-file + // trusted-controller sparse checkout), so link the two copies from here: + // every node-ui file the chain scope actually RUNS must also be a planner + // trigger, and renaming or moving the journey fails here instead of + // silently shrinking the lane. + const nodeUiChainFiles = EVM_TEST_SCOPES.chain.files + .filter((file) => file.startsWith('../node-ui/')) + .map((file) => file.replace('../node-ui/', 'packages/node-ui/')); + assert.ok(nodeUiChainFiles.length > 0); + for (const filePath of nodeUiChainFiles) { + assert.deepEqual(pullRequestPlan([change(filePath)]).evmScopes, ['chain'], filePath); + } +}); + test('Blazegraph provisioning changes include the native arm64 contract lane', () => { const rootContract = pullRequestPlan([change('blazegraph-image.json')]); assert.deepEqual(selectedLanes(rootContract), ['bura_cli', 'bura_blazegraph_arm64']); diff --git a/scripts/lib/ci-delta.mjs b/scripts/lib/ci-delta.mjs index 0e38e5df10..90f6b9077c 100644 --- a/scripts/lib/ci-delta.mjs +++ b/scripts/lib/ci-delta.mjs @@ -308,6 +308,26 @@ function isBlazegraphArm64Path(filePath) { const NODE_LANES = NODE_EVM_LANES.filter((lane) => lane !== 'bura_blazegraph_arm64'); const MAX_REPORTED_FILES = 200; +// Source of truth for WHAT this protects: EVM_TEST_SCOPES.chain.files in +// scripts/ci/evm-test-scopes.mjs — packages/chain/test/evm-adapter.test.ts plus +// ../node-ui/integration/identity-wallet-actions-v10.test.ts. That module +// cannot be imported here: ci-delta.mjs runs from the trusted-controller sparse +// checkout, whose file list is pinned to CONTROLLER_POLICY_FILES in +// scripts/ci/trusted-controller-pins.mjs and enforced by sparseCheckoutPaths(), +// so a fifth entry hard-fails every workflow that runs the planner. The +// manifest names the test files the chain scope RUNS; the patterns below name +// the SOURCE changes that must trigger it. Drift between the two copies is +// guarded from the test side in scripts/lib/__tests__/ci-delta.test.mjs. +const IDENTITY_WALLET_EVM_PATTERNS = [ + /^packages\/node-ui\/src\/ui\/web3\/(?:identityWalletActions|browserWalletTransaction)\.[cm]?[jt]sx?/, + /^packages\/node-ui\/src\/ui\/pages\/identity-wallets\//, + /^packages\/node-ui\/integration\/identity-wallet-actions-v10\.test\.ts$/, +]; + +function isIdentityWalletEvmPath(filePath) { + return IDENTITY_WALLET_EVM_PATTERNS.some((pattern) => pattern.test(filePath)); +} + function emptyLanes() { return Object.fromEntries(CI_LANES.map((lane) => [lane, false])); } @@ -548,6 +568,10 @@ export function planCi({ for (const lane of rule.lanes) lanes[lane] = true; for (const scope of rule.evmScopes) evmScopes.add(scope); + if (isIdentityWalletEvmPath(filePath)) { + evmScopes.add('chain'); + reasons.push('identity-wallet browser actions require real EVM coverage'); + } reasons.push(`${workspace} and its downstream consumers`); }