Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/effect-bun-test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
208 changes: 208 additions & 0 deletions packages/effect-bun-test/__tests__/command.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>) => 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);
});
});
38 changes: 36 additions & 2 deletions packages/effect-bun-test/__tests__/log-capture.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -53,7 +53,7 @@

it('degrades a cycle to a marker instead of throwing', () => {
const cyclic: Record<string, unknown> = { a: 1 };
cyclic['self'] = cyclic;

Check notice on line 56 in packages/effect-bun-test/__tests__/log-capture.test.ts

View workflow job for this annotation

GitHub Actions / dod

lint/complexity/useLiteralKeys

The computed expression can be simplified without the use of a string literal.
expect(() => renderLogMessage(cyclic)).not.toThrow();
expect(renderLogMessage(cyclic)).toContain('[circular]');
});
Expand Down Expand Up @@ -136,11 +136,26 @@
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();
Expand Down Expand Up @@ -236,3 +251,22 @@
}),
);
});

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');
});
});
71 changes: 71 additions & 0 deletions packages/effect-bun-test/__tests__/prop.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 13 additions & 1 deletion packages/effect-bun-test/src/log-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = { All: 'ALL', None: 'OFF' };

export const logLevelLabel = (level: string): string => LOG_LEVEL_LABEL[level] ?? level.toUpperCase();

const withMinimumLogLevel = (base: Layer.Layer<never>, level: MinimumLogLevel): Layer.Layer<never> =>
level === LEAVE_AMBIENT_LOG_LEVEL
? base
Expand All @@ -102,7 +114,7 @@ const replaceDefaultLogger = (logger: Logger.Logger<unknown, void>): 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
Expand Down
Loading