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
63 changes: 63 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
13 changes: 13 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="Plugin">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
11 changes: 11 additions & 0 deletions src/ShareServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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();
}
}
}
87 changes: 87 additions & 0 deletions src/Testing/ShareMacros.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

namespace Native\Mobile\Providers\Testing;

use Native\Mobile\Testing\FakeBridge;
use PHPUnit\Framework\Assert;

/**
* Share test vocabulary for the NativePHP testing suite, registered as
* FakeBridge macros so app tests read in share terms instead of raw bridge
* method strings:
*
* Native::test(ShareSheet::class)
* ->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;
});
}
}
3 changes: 3 additions & 0 deletions tests/Pest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php

uses()->in('.');
121 changes: 121 additions & 0 deletions tests/PluginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

/**
* Plugin validation tests for Share.
*
* Run with: ./vendor/bin/pest
*/
beforeEach(function () {
// PLUGIN_PATH lets CI run this suite from an external Pest harness
// (the plugin's own composer deps aren't resolvable on public
// runners); locally the suite sits inside the plugin as usual.
$this->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");
}
});
});
Loading
Loading