diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f1ec123 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,63 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + pest: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + + # nativephp/mobile v4 is not a stable Packagist release yet, and this + # suite needs its FakeBridge testing classes. Resolve the core from the + # public mobile-air repo's main branch (dev-main) — the same override + # test-plugins.sh applies locally. The default GITHUB_TOKEN is used only + # to avoid GitHub API rate limits while reading the public repo. + - name: Point Composer at the v4 core (dev-main) + run: | + composer config repositories.nativephp-mobile vcs https://github.com/nativephp/mobile-air + composer config minimum-stability dev + composer config prefer-stable true + composer config github-oauth.github.com "${{ secrets.GITHUB_TOKEN }}" + composer require "nativephp/mobile:dev-main" --no-update --no-interaction + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run Pest + run: vendor/bin/pest + + pint: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + # Pint is a standalone formatter — it needs no project vendor/, so this + # job skips composer entirely. + - name: Setup PHP with Pint + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: pint + + - name: Check code style + run: pint --test diff --git a/README.md b/README.md index 597ba34..48cb812 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,39 @@ useEffect(() => { }, []); ``` +## Testing + +The plugin extends the NativePHP testing suite with microphone-specific helpers, so your app tests can fake recordings and assert recording activity without knowing any bridge internals: + +```php +use Native\Mobile\Testing\Native; + +it('saves a voice memo', function () { + Native::fakeBridge()->withRecording('/storage/emulated/0/audio/note.m4a'); + + Native::test(VoiceMemo::class) + ->tap('Record') + ->assertRecordingStarted(); +}); + +it('shows the paused state in the UI', function () { + Native::fakeBridge()->withMicrophoneStatus('paused'); + + Native::test(VoiceMemo::class) + ->assertSet('status', 'paused'); +}); +``` + +### Helpers + +- `withRecording(?string $path = null)` — fake a completed recording. `getStatus()` reports `"idle"` and `getRecording()` reports `$path` (a generic `.m4a` path when omitted) — the state right after a real recording session ends. +- `withMicrophoneStatus(string $status)` — script `getStatus()` directly (e.g. `"recording"` or `"paused"`), without a completed recording on disk. +- `assertRecordingStarted()` — assert a recording was started. +- `assertRecordingStopped()` — assert the recording was stopped. +- `assertNothingRecorded()` — assert no recording was ever started. + +The helpers are available on `Native::fakeBridge()` and chain directly off `Native::test(...)`. They register automatically while running tests (requires a core with a macroable FakeBridge; on older cores they simply don't register). + ## Notes - **Microphone Permission:** The first time your app requests microphone access, users will be prompted for permission. If denied, recording functions will fail silently. diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..13ad511 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,13 @@ + + + + + tests + + + diff --git a/src/MicrophoneServiceProvider.php b/src/MicrophoneServiceProvider.php index cad2b1c..4804201 100644 --- a/src/MicrophoneServiceProvider.php +++ b/src/MicrophoneServiceProvider.php @@ -4,6 +4,8 @@ use Illuminate\Support\ServiceProvider; use Native\Mobile\Microphone; +use Native\Mobile\Providers\Testing\MicrophoneMacros; +use Native\Mobile\Testing\FakeBridge; class MicrophoneServiceProvider extends ServiceProvider { @@ -12,5 +14,14 @@ public function register(): void $this->app->singleton(Microphone::class, function () { return new Microphone; }); + + // Test sugar (assertRecordingStarted() etc.) — only under a test + // runner, and only on a core whose FakeBridge is macroable (the + // method_exists guard keeps older v4 and v3 cores fatal-free). + if ($this->app->runningUnitTests() + && class_exists(FakeBridge::class) + && method_exists(FakeBridge::class, 'macro')) { + MicrophoneMacros::register(); + } } -} \ No newline at end of file +} diff --git a/src/Testing/MicrophoneMacros.php b/src/Testing/MicrophoneMacros.php new file mode 100644 index 0000000..a66b53a --- /dev/null +++ b/src/Testing/MicrophoneMacros.php @@ -0,0 +1,66 @@ +withRecording('/storage/emulated/0/audio/note.m4a'); + * + * Native::test(VoiceMemo::class) + * ->tap('record') + * ->assertRecordingStarted(); + * + * Registered by MicrophoneServiceProvider when the app is running unit + * tests on a core whose FakeBridge supports macros. + */ +class MicrophoneMacros +{ + public static function register(): void + { + /** + * Fake a completed recording: Microphone.GetStatus reports "idle" + * (recording finished, nothing in progress) and + * Microphone.GetRecording reports $path — so getStatus() and + * getRecording() read exactly as they would right after a real + * recording session ends. $path defaults to a generic .m4a path + * when omitted. + */ + FakeBridge::macro('withRecording', function (?string $path = null) { + $path ??= 'recording.m4a'; + + $this->respondTo('Microphone.GetStatus', ['status' => 'idle']); + + return $this->respondTo('Microphone.GetRecording', ['path' => $path]); + }); + + /** + * Script Microphone.GetStatus directly — for asserting on + * "recording" or "paused" states without a completed recording on + * disk. Use withRecording() instead when the test also needs + * getRecording() to resolve a path. + */ + FakeBridge::macro('withMicrophoneStatus', function (string $status) { + return $this->respondTo('Microphone.GetStatus', ['status' => $status]); + }); + + /** Assert a recording was started (record()->start(), explicit or via auto-start). */ + FakeBridge::macro('assertRecordingStarted', function () { + return $this->assertCalled('Microphone.Start'); + }); + + /** Assert the recording was stopped. */ + FakeBridge::macro('assertRecordingStopped', function () { + return $this->assertCalled('Microphone.Stop'); + }); + + /** Assert no recording was ever started. */ + FakeBridge::macro('assertNothingRecorded', function () { + return $this->assertNotCalled('Microphone.Start'); + }); + } +} diff --git a/tests/MicrophoneBridgeTest.php b/tests/MicrophoneBridgeTest.php new file mode 100644 index 0000000..d76a934 --- /dev/null +++ b/tests/MicrophoneBridgeTest.php @@ -0,0 +1,302 @@ +bridge = Native::fakeBridge(); +}); + +describe('record()/start()', function () { + it('fires Microphone.Start with the id and default MicrophoneRecorded event', function () { + (new Microphone)->record()->id('rec-1')->start(); + + $this->bridge->assertCalled('Microphone.Start', function (array $p) { + expect($p['id'])->toBe('rec-1'); + expect($p['event'])->toBe(MicrophoneRecorded::class); + + return true; + }); + }); + + it('fires Microphone.Start with a custom event class via ->event()', function () { + (new Microphone)->record()->id('rec-2')->event(MicrophoneCancelled::class)->start(); + + $this->bridge->assertCalled('Microphone.Start', function (array $p) { + expect($p['id'])->toBe('rec-2'); + expect($p['event'])->toBe(MicrophoneCancelled::class); + + return true; + }); + }); + + it('generates an id when none is supplied', function () { + (new Microphone)->record()->start(); + + $calls = $this->bridge->callsTo('Microphone.Start'); + expect($calls)->toHaveCount(1); + expect($calls[0]['params']['id'])->not->toBeEmpty(); + }); + + it('uses the supplied id verbatim', function () { + (new Microphone)->record()->id('my-custom-id')->start(); + + $this->bridge->assertCalled('Microphone.Start', function (array $p) { + expect($p['id'])->toBe('my-custom-id'); + + return true; + }); + }); + + it('throws when a non-existent event class is passed to ->event()', function () { + expect(fn () => (new Microphone)->record()->event('Not\\A\\Real\\EventClass')) + ->toThrow(InvalidArgumentException::class); + }); + + it('sends only the id and event keys in the payload', function () { + (new Microphone)->record()->id('rec-3')->start(); + + $this->bridge->assertCalled('Microphone.Start', function (array $p) { + expect(array_keys($p))->toEqualCanonicalizing(['id', 'event']); + + return true; + }); + }); + + it('returns true when the bridge reports success with no status key', function () { + $this->bridge->respondTo('Microphone.Start', ['id' => 'rec-4']); + + $result = (new Microphone)->record()->id('rec-4')->start(); + + expect($result)->toBeTrue(); + }); + + it('returns true when the bridge response decodes to an empty array', function () { + $this->bridge->respondTo('Microphone.Start', []); + + $result = (new Microphone)->record()->id('rec-5')->start(); + + expect($result)->toBeTrue(); + }); + + it('returns false when the bridge reports status error', function () { + $this->bridge->respondTo('Microphone.Start', ['status' => 'error', 'message' => 'permission denied']); + + $result = (new Microphone)->record()->id('rec-6')->start(); + + expect($result)->toBeFalse(); + }); + + it('returns false and does not re-call the bridge when start() is called twice', function () { + $this->bridge->respondTo('Microphone.Start', []); + + $pending = (new Microphone)->record()->id('rec-7'); + + expect($pending->start())->toBeTrue(); + expect($pending->start())->toBeFalse(); + + $this->bridge->assertCalledTimes('Microphone.Start', 1); + }); + + it('auto-starts via destructor when start() was never called explicitly', function () { + $this->bridge->respondTo('Microphone.Start', []); + + $pending = (new Microphone)->record()->id('rec-auto'); + unset($pending); + + $this->bridge->assertCalled('Microphone.Start', function (array $p) { + expect($p['id'])->toBe('rec-auto'); + + return true; + }); + }); + + it('does not double-call the bridge when start() was already called explicitly before destruction', function () { + $this->bridge->respondTo('Microphone.Start', []); + + $pending = (new Microphone)->record()->id('rec-explicit'); + $pending->start(); + unset($pending); + + $this->bridge->assertCalledTimes('Microphone.Start', 1); + }); +}); + +describe('stop()', function () { + it('fires Microphone.Stop with an empty payload', function () { + (new Microphone)->stop(); + + $this->bridge->assertCalled('Microphone.Stop', function (array $p) { + expect($p)->toBe([]); + + return true; + }); + }); + + it('does not throw and returns void', function () { + expect((new Microphone)->stop())->toBeNull(); + }); +}); + +describe('pause()', function () { + it('fires Microphone.Pause with an empty payload', function () { + (new Microphone)->pause(); + + $this->bridge->assertCalled('Microphone.Pause', function (array $p) { + expect($p)->toBe([]); + + return true; + }); + }); +}); + +describe('resume()', function () { + it('fires Microphone.Resume with an empty payload', function () { + (new Microphone)->resume(); + + $this->bridge->assertCalled('Microphone.Resume', function (array $p) { + expect($p)->toBe([]); + + return true; + }); + }); +}); + +describe('getStatus()', function () { + it('fires Microphone.GetStatus with an empty payload', function () { + (new Microphone)->getStatus(); + + $this->bridge->assertCalled('Microphone.GetStatus', function (array $p) { + expect($p)->toBe([]); + + return true; + }); + }); + + it('returns "recording" status from the bridge', function () { + $this->bridge->respondTo('Microphone.GetStatus', ['status' => 'recording']); + + expect((new Microphone)->getStatus())->toBe('recording'); + }); + + it('returns "paused" status from the bridge', function () { + $this->bridge->respondTo('Microphone.GetStatus', ['status' => 'paused']); + + expect((new Microphone)->getStatus())->toBe('paused'); + }); + + it('returns "idle" status from the bridge explicitly', function () { + $this->bridge->respondTo('Microphone.GetStatus', ['status' => 'idle']); + + expect((new Microphone)->getStatus())->toBe('idle'); + }); + + it('defaults to idle when nothing is scripted', function () { + expect((new Microphone)->getStatus())->toBe('idle'); + }); + + it('defaults to idle when the response has no status key', function () { + $this->bridge->respondTo('Microphone.GetStatus', ['foo' => 'bar']); + + expect((new Microphone)->getStatus())->toBe('idle'); + }); + + it('ignores extra fields such as duration in the response', function () { + $this->bridge->respondTo('Microphone.GetStatus', ['status' => 'recording', 'duration' => 12.5]); + + expect((new Microphone)->getStatus())->toBe('recording'); + }); + + it('falls back to idle when the bridge returns an error response', function () { + $this->bridge->respondTo('Microphone.GetStatus', ['error' => 'not recording']); + + expect((new Microphone)->getStatus())->toBe('idle'); + }); +}); + +describe('getRecording()', function () { + it('fires Microphone.GetRecording with an empty payload', function () { + (new Microphone)->getRecording(); + + $this->bridge->assertCalled('Microphone.GetRecording', function (array $p) { + expect($p)->toBe([]); + + return true; + }); + }); + + it('returns the recording path from the bridge', function () { + $this->bridge->respondTo('Microphone.GetRecording', ['path' => '/storage/emulated/0/audio/recording-1.m4a']); + + expect((new Microphone)->getRecording())->toBe('/storage/emulated/0/audio/recording-1.m4a'); + }); + + it('returns null when the path is an empty string', function () { + $this->bridge->respondTo('Microphone.GetRecording', ['path' => '']); + + expect((new Microphone)->getRecording())->toBeNull(); + }); + + it('returns null when the path key is missing', function () { + $this->bridge->respondTo('Microphone.GetRecording', ['foo' => 'bar']); + + expect((new Microphone)->getRecording())->toBeNull(); + }); + + it('returns null when the path is explicitly null', function () { + $this->bridge->respondTo('Microphone.GetRecording', ['path' => null]); + + expect((new Microphone)->getRecording())->toBeNull(); + }); + + it('returns null when nothing is scripted', function () { + expect((new Microphone)->getRecording())->toBeNull(); + }); + + it('ignores extra fields such as duration in the response', function () { + $this->bridge->respondTo('Microphone.GetRecording', ['path' => '/tmp/rec.m4a', 'duration' => 42]); + + expect((new Microphone)->getRecording())->toBe('/tmp/rec.m4a'); + }); + + it('returns null when the bridge returns an error response', function () { + $this->bridge->respondTo('Microphone.GetRecording', ['error' => 'no recording found']); + + expect((new Microphone)->getRecording())->toBeNull(); + }); +}); + +describe('recording lifecycle sequence', function () { + it('fires Start, Pause, Resume, and Stop in order for a realistic recording session', function () { + $this->bridge->respondTo('Microphone.Start', []); + + $microphone = new Microphone; + $microphone->record()->id('session-1')->start(); + $microphone->pause(); + $microphone->resume(); + $microphone->stop(); + + $this->bridge->assertCallOrder([ + 'Microphone.Start', + 'Microphone.Pause', + 'Microphone.Resume', + 'Microphone.Stop', + ]); + }); +}); diff --git a/tests/MicrophoneMacrosTest.php b/tests/MicrophoneMacrosTest.php new file mode 100644 index 0000000..f2c1d6c --- /dev/null +++ b/tests/MicrophoneMacrosTest.php @@ -0,0 +1,102 @@ +markTestSkipped('This core\'s FakeBridge does not support macros.'); + } + + $this->bridge = Native::fakeBridge(); +}); + +describe('withRecording()', function () { + it('reports an idle status and the given path as the last recording', function () { + $this->bridge->withRecording('/storage/emulated/0/audio/note.m4a'); + + expect((new Microphone)->getStatus())->toBe('idle') + ->and((new Microphone)->getRecording())->toBe('/storage/emulated/0/audio/note.m4a'); + }); + + it('defaults to a generic .m4a path when none is given', function () { + $this->bridge->withRecording(); + + expect((new Microphone)->getRecording())->toBe('recording.m4a'); + }); +}); + +describe('withMicrophoneStatus()', function () { + it('scripts getStatus() to report "recording"', function () { + $this->bridge->withMicrophoneStatus('recording'); + + expect((new Microphone)->getStatus())->toBe('recording'); + }); + + it('scripts getStatus() to report "paused"', function () { + $this->bridge->withMicrophoneStatus('paused'); + + expect((new Microphone)->getStatus())->toBe('paused'); + }); + + it('does not script getRecording()', function () { + $this->bridge->withMicrophoneStatus('recording'); + + expect((new Microphone)->getRecording())->toBeNull(); + }); +}); + +describe('assertRecordingStarted()', function () { + it('passes after record()->start()', function () { + (new Microphone)->record()->id('rec-1')->start(); + + $this->bridge->assertRecordingStarted(); + }); + + it('fails when nothing was started', function () { + expect(fn () => $this->bridge->assertRecordingStarted()) + ->toThrow(AssertionFailedError::class); + }); +}); + +describe('assertRecordingStopped()', function () { + it('passes after stop()', function () { + (new Microphone)->stop(); + + $this->bridge->assertRecordingStopped(); + }); + + it('fails when stop() was never called', function () { + expect(fn () => $this->bridge->assertRecordingStopped()) + ->toThrow(AssertionFailedError::class); + }); +}); + +describe('assertNothingRecorded()', function () { + it('passes when no recording was started', function () { + (new Microphone)->getStatus(); + + $this->bridge->assertNothingRecorded(); + }); + + it('fails once a recording was started', function () { + (new Microphone)->record()->id('rec-2')->start(); + + expect(fn () => $this->bridge->assertNothingRecorded()) + ->toThrow(AssertionFailedError::class); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..88a51b7 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,3 @@ +in('.'); diff --git a/tests/PluginTest.php b/tests/PluginTest.php new file mode 100644 index 0000000..e12ea5b --- /dev/null +++ b/tests/PluginTest.php @@ -0,0 +1,133 @@ +pluginPath = getenv('PLUGIN_PATH') ?: dirname(__DIR__); + $this->manifestPath = $this->pluginPath.'/nativephp.json'; +}); + +describe('Plugin Manifest', function () { + it('has a valid nativephp.json file', function () { + expect(file_exists($this->manifestPath))->toBeTrue(); + + json_decode(file_get_contents($this->manifestPath), true); + + expect(json_last_error())->toBe(JSON_ERROR_NONE); + }); + + it('has required fields', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest)->toHaveKeys(['namespace', 'bridge_functions']); + expect($manifest['namespace'])->toBe('Microphone'); + }); + + it('declares every bridge function for both platforms', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['bridge_functions'])->toBeArray()->not->toBeEmpty(); + + $names = array_column($manifest['bridge_functions'], 'name'); + expect($names)->toContain( + 'Microphone.Start', + 'Microphone.Stop', + 'Microphone.Pause', + 'Microphone.Resume', + 'Microphone.GetStatus', + 'Microphone.GetRecording', + ); + + foreach ($manifest['bridge_functions'] as $function) { + expect($function)->toHaveKeys(['name']); + expect(isset($function['android']) || isset($function['ios']))->toBeTrue(); + } + }); + + it('declares its recording/cancellation events', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['events'])->toBeArray()->not->toBeEmpty(); + + foreach ($manifest['events'] as $event) { + expect($event)->toBeString()->not->toBeEmpty(); + // Valid fully-qualified class-name shape, e.g. + // Native\Mobile\Events\Microphone\MicrophoneRecorded + expect($event)->toMatch('/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*(\\\\[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)*$/'); + } + }); + + it('requests microphone permissions on both platforms', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['android']['permissions'])->toBeArray()->toContain('android.permission.RECORD_AUDIO'); + expect($manifest['ios']['info_plist'])->toHaveKey('NSMicrophoneUsageDescription'); + expect($manifest['ios']['info_plist']['NSMicrophoneUsageDescription'])->not->toBeEmpty(); + }); +}); + +describe('Native Code', function () { + it('has matching bridge function classes in native code', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + $kotlinContent = implode('', array_map('file_get_contents', glob($this->pluginPath.'/resources/android/*.kt'))); + $swiftContent = implode('', array_map('file_get_contents', glob($this->pluginPath.'/resources/ios/*.swift'))); + + foreach ($manifest['bridge_functions'] as $function) { + if (isset($function['android'])) { + $parts = explode('.', $function['android']); + expect($kotlinContent)->toContain('class '.end($parts)); + } + + if (isset($function['ios'])) { + $parts = explode('.', $function['ios']); + expect($swiftContent)->toContain('class '.end($parts)); + } + } + }); +}); + +describe('Composer Configuration', function () { + it('has valid composer.json', function () { + $composer = json_decode(file_get_contents($this->pluginPath.'/composer.json'), true); + + expect(json_last_error())->toBe(JSON_ERROR_NONE); + expect($composer['type'])->toBe('nativephp-plugin'); + expect($composer['require'])->toHaveKey('php'); + expect($composer['require']['php'])->not->toBeEmpty(); + expect($composer['require'])->toHaveKey('nativephp/mobile'); + }); + + it('registers a provider that maps to an existing file', function () { + $composer = json_decode(file_get_contents($this->pluginPath.'/composer.json'), true); + + $providers = $composer['extra']['laravel']['providers'] ?? []; + expect($providers)->not->toBeEmpty(); + + foreach ($providers as $provider) { + $matched = false; + + foreach ($composer['autoload']['psr-4'] as $prefix => $path) { + if (! str_starts_with($provider, $prefix)) { + continue; + } + + $relative = str_replace('\\', '/', substr($provider, strlen($prefix))); + $file = $this->pluginPath.'/'.rtrim($path, '/').'/'.$relative.'.php'; + + if (file_exists($file)) { + $matched = true; + break; + } + } + + expect($matched)->toBeTrue("Provider {$provider} does not map to a file"); + } + }); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..de32610 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,32 @@ +set('nativephp.app_id', 'com.test.app'); + $app['config']->set('nativephp.version', '1.0.0'); + $app['config']->set('nativephp.version_code', 1); + $app['config']->set('app.name', 'Test App'); + } +}