diff --git a/.gitattributes b/.gitattributes
index 36c6964..f4fefa5 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,11 +1,14 @@
* text=auto eol=lf
-/.github export-ignore
+/art export-ignore
/tests export-ignore
+/.github export-ignore
/.editorconfig export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/phpunit.xml export-ignore
/phpstan.neon export-ignore
/rector.php export-ignore
+/CHANGELOG.md export-ignore
/CONTRIBUTING.md export-ignore
+/SECURITY.md export-ignore
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..69355b2
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+# These are supported funding model platforms
+
+github: JonPurvis
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..e1dd647
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,13 @@
+**Why**
+
+
+
+**What**
+
+
+
+**Checklist**
+
+- [ ] `composer test` passes locally (Pint, Rector, PHPStan, type coverage, Pest)
+- [ ] Coding style: `composer lint`
+- [ ] Commits are meaningful and follow SemVer-friendly changes
diff --git a/.github/workflows/formats.yml b/.github/workflows/formats.yml
new file mode 100644
index 0000000..2707c28
--- /dev/null
+++ b/.github/workflows/formats.yml
@@ -0,0 +1,58 @@
+name: Formats
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ ci:
+ runs-on: ubuntu-latest
+
+ name: Formats P8.5 - ubuntu-latest
+
+ steps:
+
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
+ with:
+ php-version: 8.5
+ extensions: dom, mbstring, zip, pcntl
+ coverage: pcov
+
+ - name: Get Composer cache directory
+ id: composer-cache
+ shell: bash
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache dependencies
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: dependencies-php-8.5-os-ubuntu-latest-composer-${{ hashFiles('composer.json') }}
+ restore-keys: dependencies-php-8.5-os-ubuntu-latest-composer-
+
+ - name: Install Composer dependencies
+ run: composer update --prefer-stable --no-interaction --prefer-dist
+
+ - name: Coding Style Checks
+ run: composer test:lint
+
+ - name: Type Checks
+ run: composer test:types
+
+ - name: Type Coverage Checks
+ run: composer test:type-coverage
+
+ - name: Refactor Checks
+ run: composer test:refactor
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 0c1605c..322669a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -4,48 +4,60 @@ on:
push:
branches: [main]
pull_request:
- branches: [main]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
jobs:
- test:
+ ci:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest, windows-latest]
- php: [8.4, 8.5]
- stability: [prefer-stable]
-
- name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }}
+ os: [ubuntu-latest]
+ php: ['8.3', '8.4', '8.5']
+ dependency-version: [prefer-stable]
+ include:
+ - os: windows-latest
+ php: '8.5'
+ dependency-version: prefer-stable
+ - os: ubuntu-latest
+ php: '8.3'
+ dependency-version: prefer-lowest
+
+ name: Tests P${{ matrix.php }} - ${{ matrix.os }} - ${{ matrix.dependency-version }}
steps:
- - name: Checkout code
- # actions/checkout@v6.1.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
- name: Setup PHP
- # shivammathur/setup-php@2.37.2
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240
+ uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: ${{ matrix.php }}
# pcntl is Unix-only (used by php-fuzzer timeouts); omit on Windows.
- # Do not use `&& '' || …` — empty string is falsy and still selects pcntl.
- extensions: ${{ matrix.os == 'windows-latest' && 'dom, curl, libxml, mbstring, zip, fileinfo' || 'dom, curl, libxml, mbstring, zip, fileinfo, pcntl' }}
- coverage: none
-
- - name: Setup problem matchers
- run: |
- echo "::add-matcher::${{ runner.tool_cache }}/php.json"
- echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
+ extensions: ${{ matrix.os == 'windows-latest' && 'dom, mbstring, zip' || 'dom, mbstring, zip, pcntl' }}
+ coverage: pcov
- - name: Install dependencies
- run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction
+ - name: Get Composer cache directory
+ id: composer-cache
+ shell: bash
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- - name: Execute tests
- run: vendor/bin/pest
+ - name: Cache dependencies
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: dependencies-php-${{ matrix.php }}-os-${{ matrix.os }}-version-${{ matrix.dependency-version }}-composer-${{ hashFiles('composer.json') }}
+ restore-keys: dependencies-php-${{ matrix.php }}-os-${{ matrix.os }}-version-${{ matrix.dependency-version }}-composer-
- - name: Run Pint
- run: vendor/bin/pint --test --dirty
+ - name: Install Composer dependencies
+ run: composer update --${{ matrix.dependency-version }} --no-interaction --prefer-dist
- - name: Run Analysis
- run: vendor/bin/phpstan analyse
+ - name: Unit Tests
+ run: composer test:unit
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..95717c4
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,10 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](http://keepachangelog.com/)
+and this project adheres to [Semantic Versioning](http://semver.org/).
+
+## [Unreleased]
+
+- Adds first version
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2670d23..f43df70 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,17 +1,62 @@
-# Contributing
+# CONTRIBUTING
-Thank you for considering contributing to `jonpurvis/fuzz`.
+Contributions are welcome, and are accepted via pull requests.
+Please review these guidelines before submitting any pull requests.
+
+## Process
+
+1. Fork the project
+1. Create a new branch
+1. Code, test, commit and push
+1. Open a pull request detailing your changes. Make sure to follow the [template](.github/PULL_REQUEST_TEMPLATE.md)
+
+## Guidelines
+
+- PHP 8.3+, Pest 4 or 5.
+- Keep the fuzzer worker isolated from the Pest parent process.
+- Please ensure the coding style running `composer lint`.
+- Send a coherent commit history, making sure each individual commit in your pull request is meaningful.
+- You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts.
+- Please remember that we follow [SemVer](http://semver.org/).
## Setup
+Clone your fork, then install the dev dependencies:
+
```bash
composer install
+```
+
+## Lint
+
+Lint your code:
+
+```bash
+composer lint
+```
+
+## Tests
+
+Run all tests:
+
+```bash
composer test
```
-## Guidelines
+Check types:
+
+```bash
+composer test:types
+```
+
+Type coverage:
-- PHP 8.4+, Pest 5
-- Keep the fuzzer worker isolated from the Pest parent process
-- Prefer small, focused pull requests
-- Run `composer test` before opening a PR (Pint, PHPStan, Pest)
+```bash
+composer test:type-coverage
+```
+
+Unit tests:
+
+```bash
+composer test:unit
+```
diff --git a/README.md b/README.md
index 12d6649..7b6265d 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,25 @@
-
-
+
+
+
+
+
+
+
+
+
+
+------
# Fuzz - Coverage-guided fuzzing for Pest
A PestPHP plugin that wraps [nikic/php-fuzzer](https://github.com/nikic/PHP-Fuzzer) so you can write coverage-guided fuzz tests in the Pest style you already know and love: drop a `fuzz(...)->run()` call inside a normal `test()`, and it sits alongside the rest of your suite. Under the hood, the fuzzer is steered by which lines of your code each input actually runs. Meaning: if a mutated input only revisits the same happy-path lines the seed already hit, it gets discarded; if it suddenly takes a different `if` / null / error path, that input is kept and mutated further. That is what “coverage-guided” is.
-[](https://github.com/JonPurvis/fuzz/actions/workflows/tests.yml)
-
-
-
-
-
-
## Introduction
Fuzz is a PestPHP plugin for coverage-guided fuzz testing. It searches for inputs that crash your code (or break invariants), using the familiar Pest syntax so fuzz cases read like any other test in your suite.
-Pest [datasets](https://pestphp.com/docs/datasets) are great for confirming cases you already thought of. Fuzz mutates seeds, keeps the ones that run new lines of your code, and hunts for the cases you forgot — `TypeError`s, non-finite math, leaky sanitizers, hostile JSON shapes, and more.
+Pest [datasets](https://pestphp.com/docs/datasets) are great for confirming cases you already thought of. Fuzz mutates seeds, keeps the ones that run new lines of your code, and hunts for the cases you forgot - `TypeError`s, non-finite math, leaky sanitizers, hostile JSON shapes, and more.
-Requires PHP 8.4+ and Pest 5:
+Requires PHP 8.3+ and Pest 4 or 5:
```bash
composer require jonpurvis/fuzz --dev
@@ -36,7 +38,7 @@ final class PayloadParser
{
$data = json_decode($json, true);
- // Assumes an object with a string "event" — fine for happy paths…
+ // Assumes an object with a string "event" - fine for happy paths…
return $data['event'];
}
}
@@ -44,7 +46,7 @@ final class PayloadParser
That helper is open to hostile shapes you probably never typed by hand: `null`, `[]`, `{}`, `{"type":"ping"}`, truncated JSON, nested junk, and whatever else arrives on the wire.
-You *could* cover it with a Pest dataset — a predetermined list of values you already thought of:
+You *could* cover it with a Pest dataset - a predetermined list of values you already thought of:
```php
it('parses known webhook payloads', function (string $json, string $expected): void {
@@ -64,7 +66,7 @@ it('rejects known bad webhook payloads', function (string $json): void {
Datasets are great for **regressions** and examples you care about by name. They only ever send what you listed.
-Or you *could* use fuzz testing — because the input space is huge, and the fuzzer keeps mutating around your seeds (preferring inputs that hit new lines) until something crashes:
+Or you *could* use fuzz testing - because the input space is huge, and the fuzzer keeps mutating around your seeds (preferring inputs that hit new lines) until something crashes:
```php
use function Fuzz\fuzz;
@@ -87,11 +89,11 @@ Prefer **static** callables (or `Closure::fromCallable`) so the isolated worker
Unlike a dataset, this does **not** check a fixed list of JSON strings. It **searches**:
-1. Starts from `seed(...)` — your known-good examples become the initial library.
+1. Starts from `seed(...)` - your known-good examples become the initial library.
2. Mutates those bytes repeatedly (flip/delete/insert, splice in dictionary tokens).
3. Watches which lines of your PHP ran for that input (coverage).
4. If the input hit something new, keeps it and mutates it further. If it only revisited the same lines as before, throws it away.
-5. Fails the Pest test if the target throws an uncaught `Error` / `TypeError` / times out — and, with `saveCrashes()` (default), writes the payload to `.pest/fuzz-crashes/{hash}/crash-*.txt`.
+5. Fails the Pest test if the target throws an uncaught `Error` / `TypeError` / times out - and, with `saveCrashes()` (default), writes the payload to `.pest/fuzz-crashes/{hash}/crash-*.txt`.
That loop is what **coverage-guided** means. Imagine your seed only walks the happy path. A mutation that still only walks that path teaches the fuzzer nothing, so it moves on. A mutation that suddenly takes a different `if` / `switch` / error branch is interesting: the fuzzer keeps that input and breeds more variants from it. Over thousands of runs, that pushes the search toward the weird shapes that actually stress your code, instead of wasting the budget on noise that never leaves the happy path.
@@ -99,23 +101,23 @@ So a dataset answers “do these cases I thought of behave?” This fuzz test an
Starting from a seed like `{"event":"invoice.paid","id":"in_123"}`, mutations might wander into inputs such as:
-- `{}` / `[]` / `null` — valid JSON, wrong shape (no string `event`)
-- `{"type":"invoice.paid"}` — object, but the key you assumed is missing
-- `{"event":null}` / `{"event":[]}` — key present, value not a string
-- `{"event":"invoice.paid"` — truncated / unbalanced braces
-- `{event:"invoice.paid"}` — almost-JSON after dictionary splice
-- `{"event":"invoice.paid","event":1}` — duplicate keys, odd types
+- `{}` / `[]` / `null` - valid JSON, wrong shape (no string `event`)
+- `{"type":"invoice.paid"}` - object, but the key you assumed is missing
+- `{"event":null}` / `{"event":[]}` - key present, value not a string
+- `{"event":"invoice.paid"` - truncated / unbalanced braces
+- `{event:"invoice.paid"}` - almost-JSON after dictionary splice
+- `{"event":"invoice.paid","event":1}` - duplicate keys, odd types
- binary junk under 64 bytes that still reaches `json_decode`
You would rarely hand-author all of those into a dataset. The fuzzer is there to stumble into them (and similar) within the `runs` budget.
What the chain is doing:
-- **`seed([...])`** — starting examples. Without seeds the fuzzer begins from empty/random input and spends longer reaching interesting JSON.
-- **`withDictionary([...])`** — fragments the mutator is allowed to insert (here: JSON punctuation and `null` / `event`). That biases mutations toward structurally relevant junk instead of pure noise. You can also pass a path to a `.dict` file.
-- **`runs(2000)`** — budget: at most 2000 target executions this test. Higher = more searching, slower CI. Keep this small in the default suite; raise it for overnight/soak runs.
-- **`maxLen(64)`** — hard cap on input size in bytes. Stops the fuzzer from growing huge payloads when a small crash is enough.
-- **`saveCrashes()`** — when a crash is found, persist the payload as `.pest/fuzz-crashes/{hash}/crash-*.txt` (override with `crashDir()`). Saving does not suppress the failure — Pest still fails so you can replay the input or promote it into a dataset.
+- **`seed([...])`** - starting examples. Without seeds the fuzzer begins from empty/random input and spends longer reaching interesting JSON.
+- **`withDictionary([...])`** - fragments the mutator is allowed to insert (here: JSON punctuation and `null` / `event`). That biases mutations toward structurally relevant junk instead of pure noise. You can also pass a path to a `.dict` file.
+- **`runs(2000)`** - budget: at most 2000 target executions this test. Higher = more searching, slower CI. Keep this small in the default suite; raise it for overnight/soak runs.
+- **`maxLen(64)`** - hard cap on input size in bytes. Stops the fuzzer from growing huge payloads when a small crash is enough.
+- **`saveCrashes()`** - when a crash is found, persist the payload as `.pest/fuzz-crashes/{hash}/crash-*.txt` (override with `crashDir()`). Saving does not suppress the failure - Pest still fails so you can replay the input or promote it into a dataset.
Use **both**: datasets lock in known good/bad cases; fuzz hunts for the ones you forgot. When fuzz finds a crash, paste that payload into a named dataset so it never slips back in.
@@ -132,18 +134,20 @@ Use **both**: datasets lock in known good/bad cases; fuzz hunts for the ones you
| `allow(array)` | Domain exception classes to ignore |
| `run()` | Execute in an isolated worker |
-- **Seed** — starting example you provide
-- **Library** — growing set of interesting inputs (kept because they found new code paths)
-- **Dictionary** — fragments the mutator may insert (`null`, `{`, `'])
diff --git a/tests/Feature/DomainWebhookParserTest.php b/tests/Feature/DomainWebhookParserTest.php
index 291e55b..944658d 100644
--- a/tests/Feature/DomainWebhookParserTest.php
+++ b/tests/Feature/DomainWebhookParserTest.php
@@ -8,7 +8,7 @@
use function Fuzz\fuzz;
it('datasets regress known crashing webhook payloads', function (string $json): void {
- expect(fn () => WebhookPayloadParser::parse($json))->toThrow(TypeError::class);
+ expect(fn (): string => WebhookPayloadParser::parse($json))->toThrow(TypeError::class);
})->with([
'empty object' => ['{}'],
'null json' => ['null'],
@@ -24,10 +24,10 @@
it('fuzz finds hostile webhook JSON around seeds', function (): void {
[$library, $crashes] = fuzzScratchDirs('fuzz-webhook');
- $dict = dirname(__DIR__).'/Fixtures/dictionaries/json.dict';
+ $dict = __DIR__.'/../Fixtures/dictionaries/json.dict';
expect(function () use ($library, $crashes, $dict): void {
- fuzz(Closure::fromCallable([WebhookPayloadParser::class, 'parse']))
+ fuzz(WebhookPayloadParser::parse(...))
->runs(150)
->maxLen(64)
->seed(['{"event":"ping"}', '{"event":"pong","id":1}'])
diff --git a/tests/Feature/FuzzAllowTest.php b/tests/Feature/FuzzAllowTest.php
index 1aee416..0af025d 100644
--- a/tests/Feature/FuzzAllowTest.php
+++ b/tests/Feature/FuzzAllowTest.php
@@ -11,7 +11,7 @@
it('allows configured domain exceptions without treating them as crashes', function (): void {
[$library, $crashes] = fuzzScratchDirs('fuzz-allow');
- $result = fuzz(Closure::fromCallable([DomainExceptionThrower::class, 'handle']))
+ $result = fuzz(DomainExceptionThrower::handle(...))
->runs(40)
->maxLen(16)
->seed(['bad', 'ok'])
@@ -27,7 +27,7 @@
[$library, $crashes] = fuzzScratchDirs('fuzz-disallow');
expect(function () use ($library, $crashes): void {
- fuzz(Closure::fromCallable([DomainExceptionThrower::class, 'handle']))
+ fuzz(DomainExceptionThrower::handle(...))
->runs(40)
->maxLen(16)
->seed(['bad'])
diff --git a/tests/Feature/FuzzBoundaryTest.php b/tests/Feature/FuzzBoundaryTest.php
index 565d967..3a5f4ce 100644
--- a/tests/Feature/FuzzBoundaryTest.php
+++ b/tests/Feature/FuzzBoundaryTest.php
@@ -11,7 +11,7 @@
it('runs with an empty library start and tiny maxLen', function (): void {
[$library, $crashes] = fuzzScratchDirs('fuzz-boundary-empty');
- $result = fuzz(Closure::fromCallable([SafeEcho::class, 'handle']))
+ $result = fuzz(SafeEcho::handle(...))
->runs(20)
->maxLen(1)
->libraryDir($library)
@@ -24,7 +24,7 @@
it('accepts a null-byte seed without crashing a safe target', function (): void {
[$library, $crashes] = fuzzScratchDirs('fuzz-boundary-nul');
- $result = fuzz(Closure::fromCallable([SafeEcho::class, 'handle']))
+ $result = fuzz(SafeEcho::handle(...))
->runs(20)
->maxLen(8)
->seed(["\0", "a\0b"])
@@ -40,7 +40,7 @@
// "{}" is 2 bytes; with seeds that already crash we only need tiny runs.
expect(function () use ($library, $crashes): void {
- fuzz(Closure::fromCallable([CrashOnEmpty::class, 'headKey']))
+ fuzz(CrashOnEmpty::headKey(...))
->runs(30)
->maxLen(8)
->seed(['{}'])
diff --git a/tests/Feature/FuzzCrashReportingTest.php b/tests/Feature/FuzzCrashReportingTest.php
index 2f4d879..71fc643 100644
--- a/tests/Feature/FuzzCrashReportingTest.php
+++ b/tests/Feature/FuzzCrashReportingTest.php
@@ -12,7 +12,7 @@
try {
// Seed the known crashing payload so CORPUS CRASH is deterministic and persisted.
- fuzz(Closure::fromCallable([CrashOnEmpty::class, 'headKey']))
+ fuzz(CrashOnEmpty::headKey(...))
->runs(10)
->maxLen(64)
->seed(['{}'])
@@ -22,10 +22,10 @@
->run();
$this->fail('Expected FuzzCrashException');
- } catch (FuzzCrashException $exception) {
- expect($exception->getMessage())->toContain('Crash saved:')
- ->and($exception->getMessage())->toContain('Payload (')
- ->and($exception->getMessage())->toMatch('/[0-9A-F]{2}/');
+ } catch (FuzzCrashException $fuzzCrashException) {
+ expect($fuzzCrashException->getMessage())->toContain('Crash saved:')
+ ->and($fuzzCrashException->getMessage())->toContain('Payload (')
+ ->and($fuzzCrashException->getMessage())->toMatch('/[0-9A-F]{2}/');
$files = glob($crashes.DIRECTORY_SEPARATOR.'crash-*.txt') ?: [];
expect($files)->not->toBeEmpty();
@@ -36,7 +36,7 @@
[$library, $crashes] = fuzzScratchDirs('fuzz-nocatch');
try {
- fuzz(Closure::fromCallable([CrashOnEmpty::class, 'headKey']))
+ fuzz(CrashOnEmpty::headKey(...))
->runs(10)
->maxLen(64)
->seed(['{}'])
@@ -46,8 +46,8 @@
->run();
$this->fail('Expected FuzzCrashException');
- } catch (FuzzCrashException $exception) {
- expect($exception->getMessage())->not->toContain('Crash saved:');
+ } catch (FuzzCrashException $fuzzCrashException) {
+ expect($fuzzCrashException->getMessage())->not->toContain('Crash saved:');
$files = glob($crashes.DIRECTORY_SEPARATOR.'crash-*.txt') ?: [];
expect($files)->toBeEmpty();
@@ -59,7 +59,7 @@
try {
// Safe seeds only — the crash must come from mutation, not a CORPUS CRASH seed.
- fuzz(Closure::fromCallable([CrashOnEmpty::class, 'headKey']))
+ fuzz(CrashOnEmpty::headKey(...))
->runs(400)
->maxLen(64)
->seed(['{"key":"ok"}'])
@@ -70,8 +70,8 @@
->run();
$this->fail('Expected FuzzCrashException');
- } catch (FuzzCrashException $exception) {
- expect($exception->getMessage())->toContain('Crash saved:');
+ } catch (FuzzCrashException $fuzzCrashException) {
+ expect($fuzzCrashException->getMessage())->toContain('Crash saved:');
$files = glob($crashes.DIRECTORY_SEPARATOR.'crash-*.txt') ?: [];
expect($files)->not->toBeEmpty();
diff --git a/tests/Feature/FuzzDictionaryFileTest.php b/tests/Feature/FuzzDictionaryFileTest.php
index 5bea130..8d263f5 100644
--- a/tests/Feature/FuzzDictionaryFileTest.php
+++ b/tests/Feature/FuzzDictionaryFileTest.php
@@ -9,10 +9,10 @@
it('accepts dictionary entries from a .dict file path', function (): void {
[$library, $crashes] = fuzzScratchDirs('fuzz-dict-file');
- $dict = dirname(__DIR__).'/Fixtures/dictionaries/json.dict';
+ $dict = __DIR__.'/../Fixtures/dictionaries/json.dict';
expect(function () use ($library, $crashes, $dict): void {
- fuzz(Closure::fromCallable([CrashOnEmpty::class, 'headKey']))
+ fuzz(CrashOnEmpty::headKey(...))
->runs(200)
->maxLen(64)
->seed(['{"key":"a"}'])
diff --git a/tests/Feature/FuzzPassesTest.php b/tests/Feature/FuzzPassesTest.php
index 6e33c5f..cce935d 100644
--- a/tests/Feature/FuzzPassesTest.php
+++ b/tests/Feature/FuzzPassesTest.php
@@ -9,7 +9,7 @@
it('passes when the target never crashes', function (): void {
[$library, $crashes] = fuzzScratchDirs('fuzz-safe');
- $result = fuzz(Closure::fromCallable([SafeEcho::class, 'handle']))
+ $result = fuzz(SafeEcho::handle(...))
->runs(30)
->maxLen(32)
->seed(['hello', 'world'])
diff --git a/tests/Fixtures/JwtVerifier.php b/tests/Fixtures/JwtVerifier.php
index ad73c3e..b445d4c 100644
--- a/tests/Fixtures/JwtVerifier.php
+++ b/tests/Fixtures/JwtVerifier.php
@@ -19,9 +19,7 @@ public static function verify(string $token): string
$headerJson = base64_decode($parts[0], true);
/** @var mixed $header */
$header = json_decode($headerJson === false ? '' : $headerJson, true);
- /** @var mixed $alg */
- $alg = is_array($header) ? ($header['alg'] ?? null) : null;
- return $alg;
+ return is_array($header) ? ($header['alg'] ?? null) : null;
}
}
diff --git a/tests/Fixtures/Slugger.php b/tests/Fixtures/Slugger.php
new file mode 100644
index 0000000..ffc1bd0
--- /dev/null
+++ b/tests/Fixtures/Slugger.php
@@ -0,0 +1,16 @@
+extend('toBeOne', function (): Expectation {
- return $this->toBe(1);
-});
+expect()->extend('toBeOne', fn (): Expectation => $this->toBe(1));
/*
|--------------------------------------------------------------------------
diff --git a/tests/Unit/FuzzCallTest.php b/tests/Unit/FuzzCallTest.php
index ac60448..cb490b0 100644
--- a/tests/Unit/FuzzCallTest.php
+++ b/tests/Unit/FuzzCallTest.php
@@ -11,7 +11,7 @@
use function Fuzz\fuzz;
it('fluent chain mutates configuration without running the worker', function (): void {
- $call = fuzz(Closure::fromCallable([SafeEcho::class, 'handle']))
+ $call = fuzz(SafeEcho::handle(...))
->runs(42)
->maxLen(128)
->timeout(7)
@@ -38,7 +38,7 @@
});
it('run description updates configuration before execution', function (): void {
- $call = fuzz(Closure::fromCallable([SafeEcho::class, 'handle']))
+ $call = fuzz(SafeEcho::handle(...))
->runs(1)
->maxLen(8)
->seed(['x']);
@@ -56,7 +56,7 @@
$before = Assert::getCount();
- fuzz(Closure::fromCallable([SafeEcho::class, 'handle']))
+ fuzz(SafeEcho::handle(...))
->runs(10)
->maxLen(8)
->seed(['ok'])
@@ -68,7 +68,7 @@
});
it('starts from default configuration when none is provided', function (): void {
- $config = fuzz(Closure::fromCallable([SafeEcho::class, 'handle']))->configuration();
+ $config = fuzz(SafeEcho::handle(...))->configuration();
expect($config->runs)->toBe(FuzzConfiguration::DEFAULT_RUNS)
->and($config->maxLen)->toBe(FuzzConfiguration::DEFAULT_MAX_LEN)
diff --git a/tests/Unit/LibraryManagerTest.php b/tests/Unit/LibraryManagerTest.php
index 81f9ccd..a281ec4 100644
--- a/tests/Unit/LibraryManagerTest.php
+++ b/tests/Unit/LibraryManagerTest.php
@@ -29,7 +29,7 @@
it('materializes dictionary file paths alongside inline keywords', function (): void {
$jobDir = sys_get_temp_dir().'/fuzz-dict-job-'.bin2hex(random_bytes(4));
- $dictFile = dirname(__DIR__).'/Fixtures/dictionaries/json.dict';
+ $dictFile = __DIR__.'/../Fixtures/dictionaries/json.dict';
$manager = new LibraryManager;
$paths = $manager->materializeDictionaries($jobDir, [$dictFile, '{', 'null']);
diff --git a/tests/Unit/SluggerTest.php b/tests/Unit/SluggerTest.php
new file mode 100644
index 0000000..634988b
--- /dev/null
+++ b/tests/Unit/SluggerTest.php
@@ -0,0 +1,30 @@
+seed([
+ 'Hello World',
+ 'Laravel is Awesome !',
+ 'A blog post updated',
+ ])
+ ->withDictionary([
+ ' ',
+ '/',
+ '-',
+ '.',
+ '_',
+ ])
+ ->runs(2000)
+ ->maxLen(16)
+ ->run('slugger');
+});
diff --git a/tests/Unit/TargetSerializerTest.php b/tests/Unit/TargetSerializerTest.php
index b9726dc..6a7e812 100644
--- a/tests/Unit/TargetSerializerTest.php
+++ b/tests/Unit/TargetSerializerTest.php
@@ -7,9 +7,7 @@
it('serializes and restores a simple closure', function (): void {
$serializer = new TargetSerializer;
- $encoded = $serializer->serialize(static function (string $input): string {
- return strtoupper($input);
- });
+ $encoded = $serializer->serialize(static fn (string $input): string => strtoupper($input));
$restored = $serializer->unserialize($encoded);
@@ -20,7 +18,8 @@
$path = sys_get_temp_dir().'/fuzz-target-'.bin2hex(random_bytes(4)).'.ser';
$serializer = new TargetSerializer;
- $serializer->write($path, Closure::fromCallable([SafeEcho::class, 'handle']));
+ $serializer->write($path, SafeEcho::handle(...));
+
$restored = $serializer->read($path);
expect($restored)->toBeInstanceOf(Closure::class);
@@ -31,9 +30,7 @@
it('strips bound this so Pest closures can round-trip', function (): void {
$serializer = new TargetSerializer;
- $bound = function (string $input): string {
- return strrev($input);
- };
+ $bound = (fn (string $input): string => strrev($input));
$restored = $serializer->unserialize($serializer->serialize($bound));