diff --git a/packages/effect-bun-test/README.md b/packages/effect-bun-test/README.md index 1636bc9..b570e83 100644 --- a/packages/effect-bun-test/README.md +++ b/packages/effect-bun-test/README.md @@ -355,6 +355,7 @@ structural changes, as opposed to the mechanical renames: | `Schedule.recurs(n) ∘ elapsed ∘ whileOutput(≤ t)` | `Schedule.recurs(n)` + `Schedule.upTo({ duration: t })` | `Schedule` lost `compose`, `elapsed` and `whileOutput`; `upTo` expresses the same bound directly. | | `Arbitrary.make(schema)` | `Schema.toArbitrary(schema)(fc)` | The standalone `Arbitrary` module is gone; derivation now returns a factory over the FastCheck namespace. | | `Logger.replace(defaultLogger, l)` | swap over `References.CurrentLoggers` | `Logger.layer([l])` would install *only* `l`, silently dropping the ambient `tracerLogger`; `log-capture` does the one-for-one swap explicitly to preserve v3 behaviour. | +| `logLevel.label` (`'WARN'`, `'OFF'`) | `logLevelLabel(logLevel)` | v4 models a level as a bare string union member (`'Warn'`, `'None'`) rather than an object carrying a `label`. Every level differs in case and the off sentinel differs in spelling, so a caller that kept `level === 'WARN'` would not fail — it would match nothing and quietly assert nothing. `CapturedLog.level` therefore stays on the v3 labels, and `logLevelLabel` is exported so a caller building its own `Logger.make` sink can label a raw v4 level the same way. | ### Additions (no upstream counterpart) diff --git a/packages/effect-bun-test/__tests__/command.test.ts b/packages/effect-bun-test/__tests__/command.test.ts new file mode 100644 index 0000000..72dffbc --- /dev/null +++ b/packages/effect-bun-test/__tests__/command.test.ts @@ -0,0 +1,208 @@ +import { Effect, Exit, Layer } from 'effect'; +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { + argv, + commandArgv, + commandExecutorLayer, + commandLine, + ScriptedCommandExecutor, + ScriptedProcess, + TestCommandExecutor, +} from '../src/command'; +import { describe, expect, it } from '../src/index'; + +const spawner = ChildProcessSpawner.ChildProcessSpawner; + +// v4's array form takes the arguments as one array; v3's `Command.make` was +// variadic. Every command below goes through this helper so the difference is +// stated once. +const cmd = (command: string, ...args: ReadonlyArray) => ChildProcess.make(command, [...args]); + +describe('commandArgv / commandLine', () => { + it('flattens a StandardCommand into its argv', () => { + // A v4 `Command` is itself an `Effect`, so these read in pipe style: the + // Effect language service rejects `commandArgv(command)` as a missed + // pipeable opportunity. + expect(cmd('git', 'status', '--short').pipe(commandArgv)).toEqual(['git', 'status', '--short']); + expect(cmd('git', 'status').pipe(commandLine)).toBe('git status'); + }); + + it('a command with no arguments is just the executable', () => { + expect(cmd('ls').pipe(commandArgv)).toEqual(['ls']); + }); +}); + +describe('argv matchers', () => { + it('exact matches only the whole argv', () => { + const m = argv.exact('git', 'status'); + expect(m.matches(['git', 'status'])).toBe(true); + expect(m.matches(['git', 'status', '--short'])).toBe(false); + expect(m.matches(['git'])).toBe(false); + }); + + it('prefix ignores trailing arguments', () => { + const m = argv.prefix('git', 'status'); + expect(m.matches(['git', 'status', '--short'])).toBe(true); + expect(m.matches(['git', 'log'])).toBe(false); + }); + + it('matching wraps an arbitrary predicate', () => { + const m = argv.matching((a) => a.includes('--json'), 'json flag'); + expect(m.matches(['x', '--json'])).toBe(true); + expect(m.matches(['x'])).toBe(false); + expect(m.describe).toContain('json flag'); + }); +}); + +describe('TestCommandExecutor', () => { + it.effect('serves the scripted exit code and stdout', () => + Effect.gen(function* () { + const s = yield* spawner; + // `exitCode` is a branded number in v4; widening keeps `toBe` comparable. + const code: number = yield* s.exitCode(cmd('anything')); + expect(code).toBe(7); + expect(yield* s.string(cmd('anything'))).toBe('scripted-out'); + }).pipe(Effect.provide(TestCommandExecutor(() => ({ exitCode: 7, stdout: 'scripted-out' })))), + ); + + it.effect('the script sees the argv it was called with', () => + Effect.gen(function* () { + const s = yield* spawner; + expect(yield* s.string(cmd('echo', 'hello'))).toBe('echo hello'); + }).pipe(Effect.provide(TestCommandExecutor((c) => ({ stdout: commandLine(c) })))), + ); + + // `string` reads stdout alone; `all` is the interleaved stream, and the v4 + // handle gained it as a required member. Reading it proves the double does + // not simply return an empty stream there. + it.effect('`all` carries stdout followed by stderr', () => + Effect.gen(function* () { + const s = yield* spawner; + const c = cmd('x'); + expect(yield* s.string(c)).toBe('OUT'); + expect(yield* s.string(c, { includeStderr: true })).toBe('OUTERR'); + }).pipe(Effect.provide(TestCommandExecutor(() => ({ stdout: 'OUT', stderr: 'ERR' })))), + ); + + it.effect('an exit code the script omits defaults to 0', () => + Effect.gen(function* () { + const s = yield* spawner; + const code: number = yield* s.exitCode(cmd('x')); + expect(code).toBe(0); + }).pipe(Effect.provide(TestCommandExecutor(() => ({})))), + ); +}); + +describe('ScriptedCommandExecutor', () => { + it.effect('serves an expectation whose argv matcher accepts the spawn', () => + Effect.gen(function* () { + const s = yield* spawner; + expect(yield* s.string(cmd('git', 'status'))).toBe('on branch main'); + const code: number = yield* s.exitCode(cmd('git', 'push')); + expect(code).toBe(3); + }).pipe( + Effect.provide( + ScriptedCommandExecutor([ + { argv: ['git', 'status'], stdout: 'on branch main' }, + { argv: ['git', 'push'], exitCode: 3 }, + ]), + ), + ), + ); + + // Two separate guarantees, and the second is easy to lose: the rejection + // reaches the caller as a TYPED PlatformError (so `catch` sees it, not just + // `catchDefect`), and the violation is ALSO replayed when the layer's scope + // closes — so a test that swallowed the error still fails rather than + // passing on a subprocess that was never scripted. + it.effect('an unmatched spawn is a typed failure AND fails the scope on close', () => + Effect.gen(function* () { + let caught = 'never-ran'; + const exit = yield* Effect.exit( + Effect.gen(function* () { + const s = yield* spawner; + yield* s.exitCode(cmd('rm', '-rf')).pipe( + Effect.catch((e) => + Effect.sync(() => { + caught = String(e); + }), + ), + ); + }).pipe(Effect.provide(ScriptedCommandExecutor([{ argv: ['git', 'status'] }]))), + ); + + // `Effect.catch` only sees the typed error channel. + expect(caught).toContain('unexpected spawn'); + expect(caught).toContain('rm'); + + expect(Exit.isFailure(exit)).toBe(true); + expect(Exit.isFailure(exit) ? String(exit.cause) : '').toContain('rm'); + }), + ); + + it.effect('a predicate matcher selects the expectation', () => + Effect.gen(function* () { + const s = yield* spawner; + expect(yield* s.string(cmd('tool', '--json', 'x'))).toBe('{}'); + }).pipe(Effect.provide(ScriptedCommandExecutor([{ argv: (a) => a.includes('--json'), stdout: '{}' }]))), + ); +}); + +describe('ScriptedProcess exhaustion', () => { + it('assertExhausted throws while an expectation is unconsumed', () => { + const builder = ScriptedProcess([{ argv: ['a'] }, { argv: ['b'] }]); + builder.next(['a']); + expect(() => builder.assertExhausted()).toThrow(/b/); + }); + + it('assertExhausted is silent once every expectation is consumed', () => { + const builder = ScriptedProcess([{ argv: ['a'] }]); + builder.next(['a']); + expect(() => builder.assertExhausted()).not.toThrow(); + }); + + it('allowUnconsumed tolerates a leftover expectation', () => { + const builder = ScriptedProcess([{ argv: ['a'] }, { argv: ['b'] }], { allowUnconsumed: true }); + builder.next(['a']); + expect(() => builder.assertExhausted()).not.toThrow(); + }); + + it('records each call and its outcome', () => { + const builder = ScriptedProcess([{ argv: ['a'] }]); + builder.next(['a']); + expect(builder.calls).toEqual([['a']]); + expect(builder.log[0]?.outcome).toBe('consumed'); + }); + + // `commandExecutorLayer` registers a finalizer, so an unconsumed expectation + // has to surface when the layer's scope closes rather than passing silently. + it.effect('an unconsumed expectation becomes a defect when the layer scope closes', () => + Effect.gen(function* () { + const builder = ScriptedProcess([{ argv: ['never-run'] }]); + const exit = yield* Effect.exit(Effect.void.pipe(Effect.provide(commandExecutorLayer(builder)))); + + expect(Exit.isFailure(exit)).toBe(true); + expect(Exit.isFailure(exit) ? String(exit.cause) : '').toContain('never-run'); + }), + ); + + it.effect('a fully consumed script closes its scope cleanly', () => + Effect.gen(function* () { + const builder = ScriptedProcess([{ argv: ['git'] }]); + const exit = yield* Effect.exit( + Effect.gen(function* () { + const s = yield* spawner; + yield* s.exitCode(cmd('git')); + }).pipe(Effect.provide(commandExecutorLayer(builder))), + ); + + expect(Exit.isSuccess(exit)).toBe(true); + }), + ); +}); + +describe('layer wiring', () => { + it('TestCommandExecutor produces a Layer providing the spawner', () => { + expect(Layer.isLayer(TestCommandExecutor(() => ({})))).toBe(true); + }); +}); diff --git a/packages/effect-bun-test/__tests__/log-capture.test.ts b/packages/effect-bun-test/__tests__/log-capture.test.ts index 59611b0..9455130 100644 --- a/packages/effect-bun-test/__tests__/log-capture.test.ts +++ b/packages/effect-bun-test/__tests__/log-capture.test.ts @@ -1,6 +1,6 @@ import { DateTime, Effect, Logger, References } from 'effect'; import { describe, expect, it } from '../src/index'; -import { makeLogCapture, renderLogMessage } from '../src/log-capture'; +import { logLevelLabel, makeLogCapture, renderLogMessage } from '../src/log-capture'; const LF = String.fromCharCode(10); const CR = String.fromCharCode(13); @@ -136,11 +136,26 @@ describe('makeLogCapture — end-to-end through the real Effect logger', () => { Effect.gen(function* () { const cap = makeLogCapture(); yield* Effect.logWarning('warn-here').pipe(Effect.provide(cap.layer)); - expect(cap.entries[0]?.level).toBe('Warn'); + expect(cap.entries[0]?.level).toBe('WARN'); expect(cap.entries[0]?.message).toContain('warn-here'); }), ); + // v4 renamed every level ('WARN' -> 'Warn') and respelled the off sentinel + // ('OFF' -> 'None'). A caller's `level === 'WARN'` filter would go silently + // empty rather than fail, so the captured level stays on the v3 labels. + it.live('reports levels under their v3 labels, not the raw v4 level names', () => + Effect.gen(function* () { + const cap = makeLogCapture(); + yield* Effect.logError('e').pipe( + Effect.andThen(Effect.logWarning('w')), + Effect.andThen(Effect.logInfo('i')), + Effect.andThen(Effect.logDebug('d')), + Effect.provide(cap.layer), + ); + expect(cap.entries.map((e) => e.level)).toEqual(['ERROR', 'WARN', 'INFO', 'DEBUG']); + }), + ); it.live('exposes the untouched payload on `raw` for structural assertions', () => Effect.gen(function* () { const cap = makeLogCapture(); @@ -236,3 +251,22 @@ describe('makeLogCapture — end-to-end through the real Effect logger', () => { }), ); }); + +describe('logLevelLabel', () => { + it('maps a v4 level name onto the v3 label', () => { + expect(logLevelLabel('Fatal')).toBe('FATAL'); + expect(logLevelLabel('Error')).toBe('ERROR'); + expect(logLevelLabel('Warn')).toBe('WARN'); + expect(logLevelLabel('Info')).toBe('INFO'); + expect(logLevelLabel('Debug')).toBe('DEBUG'); + expect(logLevelLabel('Trace')).toBe('TRACE'); + }); + + // 'All' upper-cases to the right answer by luck; 'None' does not, and v3 + // called that level 'OFF'. Both are pinned so the table cannot rot into a + // bare `toUpperCase()`. + it('respells the sentinels, which a plain upper-casing gets wrong', () => { + expect(logLevelLabel('All')).toBe('ALL'); + expect(logLevelLabel('None')).toBe('OFF'); + }); +}); diff --git a/packages/effect-bun-test/__tests__/prop.test.ts b/packages/effect-bun-test/__tests__/prop.test.ts new file mode 100644 index 0000000..b3e8d32 --- /dev/null +++ b/packages/effect-bun-test/__tests__/prop.test.ts @@ -0,0 +1,71 @@ +import { Effect, Schema } from 'effect'; +import * as fc from 'effect/testing/FastCheck'; +import { describe, expect, it } from '../src/index'; + +// v4 deleted the standalone `Arbitrary` module and reshaped `Schema`, so every +// `prop` form goes through a rewritten schema-to-arbitrary bridge. None of it +// is visible to `tsc`: a bridge that silently produced `undefined` for every +// case would still typecheck, and a property body that never ran would still +// report as a passing test. + +describe('prop — array form', () => { + it.prop('derives values from a Schema', [Schema.String, Schema.Number], ([s, n]) => { + expect(typeof s).toBe('string'); + expect(typeof n).toBe('number'); + }); + + it.prop('accepts a raw FastCheck arbitrary', [fc.constant('fixed')], ([s]) => { + expect(s).toBe('fixed'); + }); + + it.prop('mixes a Schema and a raw arbitrary in one list', [Schema.String, fc.constant(42)], ([s, n]) => { + expect(typeof s).toBe('string'); + expect(n).toBe(42); + }); +}); + +describe('prop — record form', () => { + it.prop('derives values from a Schema', { name: Schema.String, age: Schema.Number }, ({ name, age }) => { + expect(typeof name).toBe('string'); + expect(typeof age).toBe('number'); + }); + + it.prop( + 'derives a composite Schema, not just the scalars', + { point: Schema.Struct({ x: Schema.Number, y: Schema.Number }), flag: Schema.Boolean }, + ({ point, flag }) => { + expect(typeof point.x).toBe('number'); + expect(typeof point.y).toBe('number'); + expect(typeof flag).toBe('boolean'); + }, + ); +}); + +describe('prop — Effect-returning forms', () => { + it.effect.prop('it.effect.prop runs the body as an Effect', { s: Schema.String }, ({ s }) => + Effect.sync(() => { + expect(typeof s).toBe('string'); + }), + ); + + it.live.prop('it.live.prop runs the body as an Effect', { n: Schema.Number }, ({ n }) => + Effect.sync(() => { + expect(typeof n).toBe('number'); + }), + ); +}); + +// A property whose body never executes is the failure mode `tsc` cannot see, +// so one case counts its own invocations and asserts the count moved. +describe('prop actually runs the body', () => { + let runs = 0; + + it.prop('the body is invoked once per generated case', [Schema.String], ([s]) => { + expect(typeof s).toBe('string'); + runs += 1; + }); + + it('the counter proves the property body ran', () => { + expect(runs).toBeGreaterThan(1); + }); +}); diff --git a/packages/effect-bun-test/src/log-capture.ts b/packages/effect-bun-test/src/log-capture.ts index 5de3cbf..f01962c 100644 --- a/packages/effect-bun-test/src/log-capture.ts +++ b/packages/effect-bun-test/src/log-capture.ts @@ -82,6 +82,18 @@ export type LogCaptureOptions = { readonly minimumLogLevel?: MinimumLogLevel; }; +// v3 exposed a log level as an object whose `label` was upper case ('WARN', +// 'INFO', and 'OFF' for the off sentinel); v4 models a level as a bare string +// union member ('Warn', 'Info', 'None'). Every level differs in case and the +// off sentinel differs in spelling, so a caller that kept v3's +// `level === 'WARN'` comparison would not fail — it would match nothing and +// quietly assert nothing. `CapturedLog.level` therefore stays on the v3 labels, +// and this mapping is exported so a caller that builds its own capture logger +// can label a raw v4 level the same way. +const LOG_LEVEL_LABEL: Readonly> = { All: 'ALL', None: 'OFF' }; + +export const logLevelLabel = (level: string): string => LOG_LEVEL_LABEL[level] ?? level.toUpperCase(); + const withMinimumLogLevel = (base: Layer.Layer, level: MinimumLogLevel): Layer.Layer => level === LEAVE_AMBIENT_LOG_LEVEL ? base @@ -102,7 +114,7 @@ const replaceDefaultLogger = (logger: Logger.Logger): Layer.Layer export const makeLogCapture = (options: LogCaptureOptions = {}): LogCapture => { const entries: CapturedLog[] = []; const logger = Logger.make(({ logLevel, message }) => { - entries.push({ level: logLevel, message: renderLogMessage(message), raw: message }); + entries.push({ level: logLevelLabel(logLevel), message: renderLogMessage(message), raw: message }); }); // v4 removed `Logger.add` / `Logger.replace`. `add` maps cleanly onto // `mergeWithExisting`, but `replace` does not: `Logger.layer([logger])` would