diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0e92130 --- /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@v4 + + - 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@v4 + + # 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 44c783c..4ca34f0 100644 --- a/README.md +++ b/README.md @@ -103,3 +103,37 @@ The share sheet automatically detects MIME types for common file formats: - Uses `UIActivityViewController` - Supports iPad popover presentation - Files are shared directly via file URLs + +## Testing + +The plugin extends the NativePHP testing suite with share-specific helpers, so your app tests can assert share sheet activity without knowing any bridge internals: + +```php +use Native\Mobile\Testing\Native; + +it('shares the invite link', function () { + Native::test(ShareSheet::class) + ->tap('Share link') + ->assertSharedUrl('https://example.com/invite/abc'); +}); + +it('shares the exported report', function () { + Native::test(ReportScreen::class) + ->tap('Share report') + ->assertSharedFile('/storage/app/report.pdf'); +}); + +it('does not share anything before the button is tapped', function () { + Native::test(ReportScreen::class) + ->assertNothingShared(); +}); +``` + +### Helpers + +- `assertShared()` — assert something was shared, a URL or a file. +- `assertSharedUrl(?string $url = null)` — assert a URL was shared, or exactly `$url` when given. +- `assertSharedFile(?string $filePath = null)` — assert a file was shared, or exactly `$filePath` when given. +- `assertNothingShared()` — assert neither `Share::url()` nor `Share::file()` was called. + +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). 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/ShareServiceProvider.php b/src/ShareServiceProvider.php index 7fcb7e4..f5ec2a6 100644 --- a/src/ShareServiceProvider.php +++ b/src/ShareServiceProvider.php @@ -3,7 +3,9 @@ namespace Native\Mobile\Providers; use Illuminate\Support\ServiceProvider; +use Native\Mobile\Providers\Testing\ShareMacros; use Native\Mobile\Share; +use Native\Mobile\Testing\FakeBridge; class ShareServiceProvider extends ServiceProvider { @@ -12,5 +14,14 @@ public function register(): void $this->app->singleton(Share::class, function () { return new Share; }); + + // Test sugar (assertSharedUrl() 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')) { + ShareMacros::register(); + } } } diff --git a/src/Testing/ShareMacros.php b/src/Testing/ShareMacros.php new file mode 100644 index 0000000..09188f1 --- /dev/null +++ b/src/Testing/ShareMacros.php @@ -0,0 +1,87 @@ +tap('Share link') + * ->assertSharedUrl('https://example.com/invite/abc'); + * + * Registered by ShareServiceProvider when the app is running unit tests on + * a core whose FakeBridge supports macros. + */ +class ShareMacros +{ + public static function register(): void + { + /** Assert something was shared — a URL, a file, or either. */ + FakeBridge::macro('assertShared', function () { + $shared = [...$this->callsTo('Share.Url'), ...$this->callsTo('Share.File')]; + + Assert::assertNotEmpty($shared, 'Expected the share sheet to be opened, but it was not.'); + + return $this; + }); + + /** Assert a URL was shared — any URL, or exactly $url when given. */ + FakeBridge::macro('assertSharedUrl', function (?string $url = null) { + if ($url === null) { + return $this->assertCalled('Share.Url'); + } + + $shared = array_map( + fn (array $call) => $call['params']['url'] ?? '', + $this->callsTo('Share.Url') + ); + + Assert::assertContains( + $url, + $shared, + "Expected [{$url}] to be shared. Shared: " + .($shared === [] ? '(nothing)' : '['.implode('], [', $shared).']') + ); + + return $this; + }); + + /** Assert a file was shared — any file, or exactly $filePath when given. */ + FakeBridge::macro('assertSharedFile', function (?string $filePath = null) { + if ($filePath === null) { + return $this->assertCalled('Share.File'); + } + + $shared = array_map( + fn (array $call) => $call['params']['filePath'] ?? '', + $this->callsTo('Share.File') + ); + + Assert::assertContains( + $filePath, + $shared, + "Expected [{$filePath}] to be shared. Shared: " + .($shared === [] ? '(nothing)' : '['.implode('], [', $shared).']') + ); + + return $this; + }); + + /** Assert nothing was shared — no Share.Url or Share.File call. */ + FakeBridge::macro('assertNothingShared', function () { + $shared = [...$this->callsTo('Share.Url'), ...$this->callsTo('Share.File')]; + + Assert::assertEmpty( + $shared, + 'Expected nothing to be shared, but the share sheet was opened.' + ); + + return $this; + }); + } +} 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..d5a8e03 --- /dev/null +++ b/tests/PluginTest.php @@ -0,0 +1,121 @@ +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('Share'); + }); + + 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( + 'Share.Url', + 'Share.File', + ); + + foreach ($manifest['bridge_functions'] as $function) { + expect($function)->toHaveKeys(['name']); + expect(isset($function['android']) || isset($function['ios']))->toBeTrue(); + } + }); + + it('declares no events', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['events'])->toBeArray()->toBeEmpty(); + }); + + it('requests no permissions', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['android']['permissions'])->toBeArray()->toBeEmpty(); + expect($manifest['ios']['info_plist'])->toBeArray()->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/ShareBridgeTest.php b/tests/ShareBridgeTest.php new file mode 100644 index 0000000..4833e3d --- /dev/null +++ b/tests/ShareBridgeTest.php @@ -0,0 +1,311 @@ +bridge = Native::fakeBridge(); +}); + +describe('url()', function () { + it('fires Share.Url with title, text and url keys', function () { + (new Share)->url('Check this out', 'You have to see this', 'https://nativephp.com'); + + $this->bridge->assertCalled('Share.Url', function (array $p) { + expect($p)->toHaveKeys(['title', 'text', 'url']); + expect($p['title'])->toBe('Check this out'); + expect($p['text'])->toBe('You have to see this'); + expect($p['url'])->toBe('https://nativephp.com'); + + return true; + }); + }); + + it('does not send a message or filePath key', function () { + (new Share)->url('Title', 'Text', 'https://nativephp.com'); + + $this->bridge->assertCalled('Share.Url', function (array $p) { + expect($p)->not->toHaveKey('message'); + expect($p)->not->toHaveKey('filePath'); + + return true; + }); + }); + + it('passes each argument through positionally without transposing them', function () { + (new Share)->url('the-title', 'the-text', 'the-url'); + + $this->bridge->assertCalled('Share.Url', function (array $p) { + expect($p['title'])->toBe('the-title'); + expect($p['text'])->toBe('the-text'); + expect($p['url'])->toBe('the-url'); + + return true; + }); + }); + + it('preserves multiline text with quotes verbatim', function () { + $snippet = "\n Hello 'world'\n"; + (new Share)->url('Title', $snippet, 'https://nativephp.com'); + + $this->bridge->assertCalled('Share.Url', function (array $p) use ($snippet) { + expect($p['text'])->toBe($snippet); + + return true; + }); + }); + + it('preserves special characters and symbols verbatim in the title', function () { + $title = 'Save 50% off — "limited time" & more!'; + (new Share)->url($title, 'Text', 'https://nativephp.com'); + + $this->bridge->assertCalled('Share.Url', function (array $p) use ($title) { + expect($p['title'])->toBe($title); + + return true; + }); + }); + + it('preserves unicode characters and emoji verbatim', function () { + $text = 'こんにちは世界 🌍 — café résumé naïve'; + (new Share)->url('Title', $text, 'https://nativephp.com'); + + $this->bridge->assertCalled('Share.Url', function (array $p) use ($text) { + expect($p['text'])->toBe($text); + + return true; + }); + }); + + it('preserves query strings and fragments in the url verbatim', function () { + $url = 'https://nativephp.com/docs?ref=share&utm_source=app#install'; + (new Share)->url('Title', 'Text', $url); + + $this->bridge->assertCalled('Share.Url', function (array $p) use ($url) { + expect($p['url'])->toBe($url); + + return true; + }); + }); + + it('passes through empty strings for title and text', function () { + (new Share)->url('', '', 'https://nativephp.com'); + + $this->bridge->assertCalled('Share.Url', function (array $p) { + expect($p['title'])->toBe(''); + expect($p['text'])->toBe(''); + expect($p['url'])->toBe('https://nativephp.com'); + + return true; + }); + }); + + it('returns void', function () { + $result = (new Share)->url('Title', 'Text', 'https://nativephp.com'); + + expect($result)->toBeNull(); + }); + + it('fires Share.Url exactly once per call', function () { + (new Share)->url('Title', 'Text', 'https://nativephp.com'); + + $this->bridge->assertCalledTimes('Share.Url', 1); + }); + + it('does not fire Share.File', function () { + (new Share)->url('Title', 'Text', 'https://nativephp.com'); + + $this->bridge->assertNotCalled('Share.File'); + }); + + it('records each call independently across multiple invocations', function () { + (new Share)->url('First', 'First text', 'https://nativephp.com/first'); + (new Share)->url('Second', 'Second text', 'https://nativephp.com/second'); + + $calls = $this->bridge->callsTo('Share.Url'); + expect($calls)->toHaveCount(2); + expect($calls[0]['params']['title'])->toBe('First'); + expect($calls[0]['params']['url'])->toBe('https://nativephp.com/first'); + expect($calls[1]['params']['title'])->toBe('Second'); + expect($calls[1]['params']['url'])->toBe('https://nativephp.com/second'); + }); +}); + +describe('file()', function () { + it('fires Share.File with title, message and filePath keys', function () { + (new Share)->file('Vacation photo', 'Look at this!', '/storage/app/photo.jpg'); + + $this->bridge->assertCalled('Share.File', function (array $p) { + expect($p)->toHaveKeys(['title', 'message', 'filePath']); + expect($p['title'])->toBe('Vacation photo'); + expect($p['message'])->toBe('Look at this!'); + expect($p['filePath'])->toBe('/storage/app/photo.jpg'); + + return true; + }); + }); + + it('sends the text argument under the message key, not text', function () { + (new Share)->file('Title', 'the shared text', '/storage/app/file.pdf'); + + $this->bridge->assertCalled('Share.File', function (array $p) { + expect($p)->not->toHaveKey('text'); + expect($p['message'])->toBe('the shared text'); + + return true; + }); + }); + + it('does not send a url key', function () { + (new Share)->file('Title', 'Text', '/storage/app/file.pdf'); + + $this->bridge->assertCalled('Share.File', function (array $p) { + expect($p)->not->toHaveKey('url'); + + return true; + }); + }); + + it('passes each argument through positionally without transposing them', function () { + (new Share)->file('the-title', 'the-message', '/the/file/path.txt'); + + $this->bridge->assertCalled('Share.File', function (array $p) { + expect($p['title'])->toBe('the-title'); + expect($p['message'])->toBe('the-message'); + expect($p['filePath'])->toBe('/the/file/path.txt'); + + return true; + }); + }); + + it('preserves multiline messages with quotes verbatim', function () { + $snippet = "\n Hello 'world'\n"; + (new Share)->file('Title', $snippet, '/storage/app/file.txt'); + + $this->bridge->assertCalled('Share.File', function (array $p) use ($snippet) { + expect($p['message'])->toBe($snippet); + + return true; + }); + }); + + it('preserves special characters and symbols verbatim in the title', function () { + $title = 'Save 50% off — "limited time" & more!'; + (new Share)->file($title, 'Message', '/storage/app/file.txt'); + + $this->bridge->assertCalled('Share.File', function (array $p) use ($title) { + expect($p['title'])->toBe($title); + + return true; + }); + }); + + it('preserves unicode characters and emoji verbatim', function () { + $message = 'こんにちは世界 🌍 — café résumé naïve'; + (new Share)->file('Title', $message, '/storage/app/file.txt'); + + $this->bridge->assertCalled('Share.File', function (array $p) use ($message) { + expect($p['message'])->toBe($message); + + return true; + }); + }); + + it('preserves file paths with spaces and special characters verbatim', function () { + $filePath = '/storage/app/My Documents/report (final) [v2].pdf'; + (new Share)->file('Title', 'Message', $filePath); + + $this->bridge->assertCalled('Share.File', function (array $p) use ($filePath) { + expect($p['filePath'])->toBe($filePath); + + return true; + }); + }); + + it('passes through empty strings for title and message', function () { + (new Share)->file('', '', '/storage/app/file.txt'); + + $this->bridge->assertCalled('Share.File', function (array $p) { + expect($p['title'])->toBe(''); + expect($p['message'])->toBe(''); + expect($p['filePath'])->toBe('/storage/app/file.txt'); + + return true; + }); + }); + + it('returns void', function () { + $result = (new Share)->file('Title', 'Message', '/storage/app/file.txt'); + + expect($result)->toBeNull(); + }); + + it('fires Share.File exactly once per call', function () { + (new Share)->file('Title', 'Message', '/storage/app/file.txt'); + + $this->bridge->assertCalledTimes('Share.File', 1); + }); + + it('does not fire Share.Url', function () { + (new Share)->file('Title', 'Message', '/storage/app/file.txt'); + + $this->bridge->assertNotCalled('Share.Url'); + }); + + it('records each call independently across multiple invocations', function () { + (new Share)->file('First', 'First message', '/storage/app/first.txt'); + (new Share)->file('Second', 'Second message', '/storage/app/second.txt'); + + $calls = $this->bridge->callsTo('Share.File'); + expect($calls)->toHaveCount(2); + expect($calls[0]['params']['title'])->toBe('First'); + expect($calls[0]['params']['filePath'])->toBe('/storage/app/first.txt'); + expect($calls[1]['params']['title'])->toBe('Second'); + expect($calls[1]['params']['filePath'])->toBe('/storage/app/second.txt'); + }); +}); + +describe('url() and file() together', function () { + it('fires nothing before either method is called', function () { + $this->bridge->assertNothingCalled(); + }); + + it('calls Share.Url then Share.File in the order invoked', function () { + (new Share)->url('Title', 'Text', 'https://nativephp.com'); + (new Share)->file('Title', 'Message', '/storage/app/file.txt'); + + $this->bridge->assertCallOrder(['Share.Url', 'Share.File']); + }); + + it('keeps the two bridge functions and their payloads independent', function () { + (new Share)->url('URL title', 'URL text', 'https://nativephp.com'); + (new Share)->file('File title', 'File message', '/storage/app/file.txt'); + + $this->bridge->assertCalled('Share.Url', function (array $p) { + expect($p['title'])->toBe('URL title'); + expect($p)->not->toHaveKey('filePath'); + + return true; + }); + + $this->bridge->assertCalled('Share.File', function (array $p) { + expect($p['title'])->toBe('File title'); + expect($p)->not->toHaveKey('url'); + + return true; + }); + }); +}); diff --git a/tests/ShareMacrosTest.php b/tests/ShareMacrosTest.php new file mode 100644 index 0000000..c73bf2a --- /dev/null +++ b/tests/ShareMacrosTest.php @@ -0,0 +1,132 @@ +markTestSkipped('This core\'s FakeBridge does not support macros.'); + } + + $this->bridge = Native::fakeBridge(); +}); + +describe('assertShared()', function () { + it('passes when a URL was shared', function () { + (new Share)->url('Title', 'Text', 'https://nativephp.com'); + + $this->bridge->assertShared(); + }); + + it('passes when a file was shared', function () { + (new Share)->file('Title', 'Message', '/storage/app/file.txt'); + + $this->bridge->assertShared(); + }); + + it('fails when nothing was shared', function () { + expect(fn () => $this->bridge->assertShared()) + ->toThrow(AssertionFailedError::class); + }); +}); + +describe('assertSharedUrl()', function () { + it('passes when any URL was shared', function () { + (new Share)->url('Title', 'Text', 'https://nativephp.com'); + + $this->bridge->assertSharedUrl(); + }); + + it('matches the exact shared url', function () { + (new Share)->url('First', 'Text', 'https://nativephp.com/first'); + (new Share)->url('Second', 'Text', 'https://nativephp.com/second'); + + $this->bridge->assertSharedUrl('https://nativephp.com/second'); + }); + + it('fails when nothing was shared', function () { + expect(fn () => $this->bridge->assertSharedUrl()) + ->toThrow(AssertionFailedError::class); + }); + + it('fails when a different url was shared, naming what was', function () { + (new Share)->url('Title', 'Text', 'https://nativephp.com/actual'); + + expect(fn () => $this->bridge->assertSharedUrl('https://nativephp.com/expected')) + ->toThrow(AssertionFailedError::class, 'https://nativephp.com/actual'); + }); + + it('fails when only a file was shared', function () { + (new Share)->file('Title', 'Message', '/storage/app/file.txt'); + + expect(fn () => $this->bridge->assertSharedUrl()) + ->toThrow(AssertionFailedError::class); + }); +}); + +describe('assertSharedFile()', function () { + it('passes when any file was shared', function () { + (new Share)->file('Title', 'Message', '/storage/app/file.txt'); + + $this->bridge->assertSharedFile(); + }); + + it('matches the exact shared filePath', function () { + (new Share)->file('First', 'Message', '/storage/app/first.txt'); + (new Share)->file('Second', 'Message', '/storage/app/second.txt'); + + $this->bridge->assertSharedFile('/storage/app/second.txt'); + }); + + it('fails when nothing was shared', function () { + expect(fn () => $this->bridge->assertSharedFile()) + ->toThrow(AssertionFailedError::class); + }); + + it('fails when a different file was shared, naming what was', function () { + (new Share)->file('Title', 'Message', '/storage/app/actual.txt'); + + expect(fn () => $this->bridge->assertSharedFile('/storage/app/expected.txt')) + ->toThrow(AssertionFailedError::class, '/storage/app/actual.txt'); + }); + + it('fails when only a url was shared', function () { + (new Share)->url('Title', 'Text', 'https://nativephp.com'); + + expect(fn () => $this->bridge->assertSharedFile()) + ->toThrow(AssertionFailedError::class); + }); +}); + +describe('assertNothingShared()', function () { + it('passes when nothing was shared', function () { + $this->bridge->assertNothingShared(); + }); + + it('fails after a url share', function () { + (new Share)->url('Title', 'Text', 'https://nativephp.com'); + + expect(fn () => $this->bridge->assertNothingShared()) + ->toThrow(AssertionFailedError::class); + }); + + it('fails after a file share', function () { + (new Share)->file('Title', 'Message', '/storage/app/file.txt'); + + expect(fn () => $this->bridge->assertNothingShared()) + ->toThrow(AssertionFailedError::class); + }); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..a348c14 --- /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'); + } +}