diff --git a/.changeset/adopt-effect-rstest.md b/.changeset/adopt-effect-rstest.md new file mode 100644 index 000000000..28aee0bc9 --- /dev/null +++ b/.changeset/adopt-effect-rstest.md @@ -0,0 +1,6 @@ +--- +"@agent-bundle/runtime": patch +"agent-bundle": patch +--- + +Adopt effect-rstest for Effect-native tests and scoped test resources. diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 79073c890..8ad528ffd 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -83,6 +83,14 @@ on the Promise edge. Do not widen public error types to satisfy Effect. - Host `AbortSignal` at a Promise edge goes on `runPromise(..., { signal })`. Inside Effect, `interruptWhenAborted` or `yield* scopedAbortSignal`. +## Test helpers + +Use `effect-rstest` when a test can return an Effect directly: `it.effect` +provides `TestClock` and `TestConsole`, while `it.live` keeps real services for +host I/O. Use `layer` for a shared service graph and scoped acquisition for +test resources. Keep ordinary rstest tests for Promise-only public APIs and +keep boundary-runner assertions on the package boundary they are testing. + ## Streams and concurrency Stage 2 uses Effect `Stream` for the #145 dispatcher: Flight bytes via diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 31531604f..34b6e40db 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -102,6 +102,7 @@ "@modelcontextprotocol/server": "2.0.0", "@types/react": "19.2.18", "@types/ws": "8.18.1", + "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f", "react": "19.2.8", "zod": "4.4.3" }, diff --git a/packages/agent-bundle/tests/event-ipc.test.ts b/packages/agent-bundle/tests/event-ipc.test.ts index 177c7fbd0..7cf28699c 100644 --- a/packages/agent-bundle/tests/event-ipc.test.ts +++ b/packages/agent-bundle/tests/event-ipc.test.ts @@ -1,6 +1,7 @@ import { stat } from 'node:fs/promises'; -import { expect, it } from '@rstest/core'; +import { Effect } from 'effect'; +import { expect, it } from 'effect-rstest'; import { createEventRuntimeServer, @@ -8,76 +9,86 @@ import { requestEventRuntime, } from '../src/events/ipc.ts'; -it('round-trips a bounded event envelope through the epoch-bound runtime socket', async () => { +it.live('round-trips a bounded event envelope through the epoch-bound runtime socket', () => Effect.gen(function*() { const endpointId = `event-ipc-${crypto.randomUUID()}`; - const server = await createEventRuntimeServer({ - artifactEpoch: 'epoch-1', - endpointId, - handle: async (request) => ({ - echoed: request.native, - event: request.event, - }), - }); - - try { - if (process.platform !== 'win32') { - expect((await stat(server.endpoint)).mode & 0o777).toBe(0o600); - } - await expect(requestEventRuntime({ + const server = yield* Effect.acquireRelease( + Effect.promise(() => createEventRuntimeServer({ artifactEpoch: 'epoch-1', endpointId, - event: 'tool/after', - hostContractRevision: '2.1.250', - native: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, - signal: new AbortController().signal, - target: 'claude', - timeoutMs: 1_000, - })).resolves.toEqual({ - echoed: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, - event: 'tool/after', - }); - } finally { - await server.close(); - } -}); + handle: async (request) => ({ + echoed: request.native, + event: request.event, + }), + })), + (server) => Effect.promise(() => server.close()), + ); -it('fails closed on artifact epoch mismatch and missing runtimes', async () => { - const endpointId = `event-ipc-${crypto.randomUUID()}`; - const server = await createEventRuntimeServer({ + if (process.platform !== 'win32') { + const endpoint = yield* Effect.promise(() => stat(server.endpoint)); + expect(endpoint.mode & 0o777).toBe(0o600); + } + const response = yield* Effect.promise(() => requestEventRuntime({ artifactEpoch: 'epoch-1', endpointId, - handle: async () => undefined, + event: 'tool/after', + hostContractRevision: '2.1.250', + native: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, + signal: new AbortController().signal, + target: 'claude', + timeoutMs: 1_000, + })); + expect(response).toEqual({ + echoed: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, + event: 'tool/after', }); +})); + +it.live('fails closed on artifact epoch mismatch and missing runtimes', () => Effect.gen(function*() { + const endpointId = `event-ipc-${crypto.randomUUID()}`; + yield* Effect.scoped(Effect.gen(function*() { + yield* Effect.acquireRelease( + Effect.promise(() => createEventRuntimeServer({ + artifactEpoch: 'epoch-1', + endpointId, + handle: async () => undefined, + })), + (server) => Effect.promise(() => server.close()), + ); - try { - await expect(requestEventRuntime({ - artifactEpoch: 'epoch-2', + const mismatch = yield* Effect.tryPromise({ + try: () => requestEventRuntime({ + artifactEpoch: 'epoch-2', + endpointId, + event: 'session/start', + hostContractRevision: '2.1.250', + native: { hook_event_name: 'SessionStart' }, + signal: new AbortController().signal, + target: 'claude', + timeoutMs: 1_000, + }), + catch: (error) => error, + }).pipe(Effect.flip); + expect(mismatch).toMatchObject({ + code: 'epoch-mismatch', + name: EventRuntimeTransportError.name, + }); + })); + + const unavailable = yield* Effect.tryPromise({ + try: () => requestEventRuntime({ + artifactEpoch: 'epoch-1', endpointId, event: 'session/start', hostContractRevision: '2.1.250', native: { hook_event_name: 'SessionStart' }, signal: new AbortController().signal, target: 'claude', - timeoutMs: 1_000, - })).rejects.toMatchObject({ - code: 'epoch-mismatch', - name: EventRuntimeTransportError.name, - }); - } finally { - await server.close(); - } - - await expect(requestEventRuntime({ - artifactEpoch: 'epoch-1', - endpointId, - event: 'session/start', - hostContractRevision: '2.1.250', - native: { hook_event_name: 'SessionStart' }, - signal: new AbortController().signal, - target: 'claude', - timeoutMs: 100, - })).rejects.toMatchObject({ + timeoutMs: 100, + }), + catch: (error) => error, + }).pipe(Effect.flip); + expect(unavailable).toMatchObject({ code: 'runtime-unavailable', name: EventRuntimeTransportError.name, }); -}); +})); diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 11cd97150..c34fecd8f 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -78,6 +78,7 @@ "@rspack/core": "2.2.1", "@rstest/core": "0.11.10", "@types/react": "19.2.18", + "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f", "react": "19.2.8", "react-dom": "19.2.8" } diff --git a/packages/rsc-runtime/tests/agent-document.test.ts b/packages/rsc-runtime/tests/agent-document.test.ts index 7383f51d1..5a545071b 100644 --- a/packages/rsc-runtime/tests/agent-document.test.ts +++ b/packages/rsc-runtime/tests/agent-document.test.ts @@ -1,5 +1,5 @@ -import { Stream } from 'effect'; -import { describe, expect, it } from '@rstest/core'; +import { Effect, Stream } from 'effect'; +import { describe, expect, it } from 'effect-rstest'; import { Agent, @@ -9,7 +9,6 @@ import { type AgentDocumentNode, type AgentRenderInvocation, } from '../src/index.js'; -import { runPromise } from '../src/effect/boundary.js'; import { boundRenderEventStream } from '../src/effect/render-stream.js'; const root = (): AgentDocumentNode => ({ @@ -259,16 +258,16 @@ describe('Agent render events', () => { }); describe('boundRenderEventStream', () => { - it('assigns sequence numbers and fails closed after complete', async () => { - const events = await runPromise(Stream.runCollect( + it.effect('assigns sequence numbers and fails closed after complete', () => Effect.gen(function*() { + const events = yield* Stream.runCollect( Stream.make( { completed: 0, type: 'progress' as const }, { completed: 1, type: 'progress' as const }, ).pipe(boundRenderEventStream()), - )); + ); expect(events.map((event) => event.sequence)).toEqual([0, 1]); - await expect(runPromise(Stream.runCollect( + const failure = yield* Stream.runCollect( Stream.make( { document: { root: root(), status: 'success' as const, version: 1 as const }, @@ -276,8 +275,9 @@ describe('boundRenderEventStream', () => { }, { completed: 2, type: 'progress' as const }, ).pipe(boundRenderEventStream()), - ))).rejects.toMatchObject({ code: 'handoff-required' }); - }); + ).pipe(Effect.flip); + expect(failure).toMatchObject({ code: 'handoff-required' }); + })); }); describe('AgentRenderInvocation', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be63f1bb0..d0f935c6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,6 +266,9 @@ importers: '@types/ws': specifier: 8.18.1 version: 8.18.1 + effect-rstest: + specifier: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f + version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f(@rstest/core@0.11.10)(effect@4.0.0-rc.112) react: specifier: 19.2.8 version: 19.2.8 @@ -321,6 +324,9 @@ importers: '@types/react': specifier: 19.2.18 version: 19.2.18 + effect-rstest: + specifier: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f + version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f(@rstest/core@0.11.10)(effect@4.0.0-rc.112) react: specifier: 19.2.8 version: 19.2.8 @@ -1625,6 +1631,13 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect-rstest@https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f: + resolution: {integrity: sha512-vAoaIALD1PEKZDHdX8RdFKzJQtV5FTTzmGt9+xSbjbCJyPX/yKXvL9kJlR9QjSXRxGwFcxy+v+QxEK2ja7JKpA==, tarball: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f} + version: 0.1.0 + peerDependencies: + '@rstest/core': ^0.11.11 + effect: ^4.0.0-rc.108 + effect@4.0.0-rc.112: resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} @@ -3779,6 +3792,11 @@ snapshots: ee-first@1.1.1: {} + effect-rstest@https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f(@rstest/core@0.11.10)(effect@4.0.0-rc.112): + dependencies: + '@rstest/core': 0.11.10 + effect: 4.0.0-rc.112 + effect@4.0.0-rc.112: dependencies: fast-check: 4.9.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 38aacb47a..0ba3a87dd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,9 @@ packages: # here instead of through a devDependency in the shipped package.json. overrides: '@agent-bundle/runtime': workspace:* +peerDependencyRules: + allowedVersions: + 'effect-rstest>@rstest/core': 0.11.10 allowBuilds: '@google/genai': false msgpackr-extract: false