diff --git a/.gitattributes b/.gitattributes index 9dec809..bdab6cb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,6 @@ /AGENTS.md export-ignore /CLAUDE.md export-ignore /.github export-ignore +/phpunit.xml export-ignore +/phpstan.neon export-ignore +/pint.json export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..029e094 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ff27d52 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## What changes and why + +Describe the problem and the smallest complete solution. + +## Evidence + +List the commands run and their results. For native changes, include the real +compiled app, crash/relaunch results and screenshots, identifying the platform +and whether it was a simulator or physical device. CI does not prove native +crash capture. + +## Compatibility + +Describe public API, event format or minimum-version changes, if any. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9ef56bc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + COMPOSER_ROOT_VERSION: 0.2.x-dev + +jobs: + quality: + name: Quality + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2 + with: + php-version: '8.4' + extensions: mbstring, pdo_sqlite + tools: composer:v2 + coverage: none + - run: composer validate --strict + - run: composer update --no-interaction --prefer-dist --no-progress + - run: composer audit + - run: composer test:lint + - run: composer test:types + - run: composer test:type-coverage + + tests: + name: Tests (PHP ${{ matrix.php }}, Laravel ${{ matrix.laravel }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + laravel: ['12', '13'] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, pdo_sqlite + tools: composer:v2 + coverage: none + - name: Select Laravel version + env: + LARAVEL_VERSION: ${{ matrix.laravel }} + run: composer require --dev "orchestra/testbench:^$((LARAVEL_VERSION - 2)).0" --no-update --no-interaction + - run: composer update --no-interaction --prefer-dist --no-progress + - run: composer test:unit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bcc21a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +/composer.lock +/.phpunit.cache/ +/.phpunit.result.cache +/.phpstan-cache/ +/.php-cs-fixer.cache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0d1e3c2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,58 @@ +# Contributing + +Use PHP 8.4 or newer and Composer 2. Open an issue before a substantial feature +or event-format change. Include SDK, Laravel and NativePHP versions, a minimal +reproduction and redacted logs. Never publish credentials or customer reports. +Report vulnerabilities privately to a repository maintainer. + +Shared capture, redaction and delivery belong in `native-core`. Platform hooks +belong in `native-desktop` or `native-mobile`. Keep changes focused, classes +final, types explicit and files within 300 lines. Reporting must never replace +the original application error. + +## Run the checks + +```sh +COMPOSER_ROOT_VERSION=0.2.x-dev composer install +composer test +``` + +The root version identifies the development checkout as part of the 0.2 line. +Composer will resolve a lock file locally; libraries do not commit it. Tests use +Orchestra Testbench and fake HTTP, without the private server, production keys +or a running NativePHP app. `composer test` runs Pint, PHPStan, 100% PHP type coverage and Pest. GitHub +also validates Composer metadata, audits dependencies and runs Pest on PHP 8.4 +and 8.5 with Laravel 12 and 13. + +For one Laravel version, select its matching Testbench before installing: + +```sh +composer require --dev 'orchestra/testbench:^10.0' --no-update +COMPOSER_ROOT_VERSION=0.2.x-dev composer update +composer test +``` + +Testbench 10 tests Laravel 12; Testbench 11 tests Laravel 13. Do not commit a local matrix selection to `composer.json`. + +The runtime dependency constraint still accepts Laravel 11, but CI does not +verify it: Composer blocks its current releases due to security advisories. +No advisory ignores or disabled security gates are used. + +Native reporting changes also require a compiled Release app, a real crash and +relaunch, receiver assertions and screenshots. Identify the platform and +whether evidence comes from a simulator or physical device. PHP CI does not +compile Swift or Kotlin or establish native crash capture. + +## Submit a pull request + +Explain the failing behavior, the smallest complete fix and the commands you +actually ran. Include a regression test, compatibility changes and remaining +limits. Use a branch and open a pull request against `main`. Merging requires +one approving review from another maintainer, resolved conversations and all +required CI checks passing against the latest `main`. New commits dismiss old +approvals. Force pushes and deletion of `main` are blocked. + +Workflow actions are pinned to full commit hashes and updated by Dependabot. +Keep workflow permissions read-only and never expose secrets to forked code or +use `pull_request_target` to execute a contributor's checkout. Published `v*` +tags cannot be rewritten or deleted. Fix a release with a new version. diff --git a/composer.json b/composer.json index 5969e82..dfe3156 100644 --- a/composer.json +++ b/composer.json @@ -43,5 +43,36 @@ "docs": "https://native.watch/docs/install-mobile", "issues": "https://github.com/zinxan/native.mobile/issues", "source": "https://github.com/zinxan/native.mobile" + }, + "require-dev": { + "larastan/larastan": "^3.9", + "laravel/pint": "^1.27", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.8|^4.0|^5.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.0|^5.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0|^5.0" + }, + "autoload-dev": { + "psr-4": { + "ZXN\\NativeWatch\\Mobile\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + }, + "sort-packages": true + }, + "scripts": { + "test": [ + "@test:lint", + "@test:types", + "@test:type-coverage", + "@test:unit" + ], + "test:lint": "pint --test", + "test:types": "phpstan analyse --memory-limit=1G", + "test:unit": "pest --compact", + "test:type-coverage": "pest --type-coverage --min=100" } } diff --git a/docs/review/sdk-ci.md b/docs/review/sdk-ci.md new file mode 100644 index 0000000..05eccee --- /dev/null +++ b/docs/review/sdk-ci.md @@ -0,0 +1,30 @@ +# Standalone SDK CI evidence + +Verified on 3 November 2026 with PHP 8.4.27 and Laravel 13. + +- `composer validate --strict`: passed. +- `composer audit`: no advisories or abandoned dependencies. +- `composer test`: Pint and PHPStan level 7 passed, 100% PHP type coverage, + 40 Pest tests and 115 assertions passed. +- `git diff --check`: passed. + +The workflow repeats Pest across PHP 8.4/8.5 and Laravel 12/13. Its Quality +job validates Composer, audits dependencies and runs lint, static analysis and +type coverage. Every job runs for every pull request, without path filters. +Both Actions are pinned to full commit hashes; checkout credentials are not +persisted, the token is read-only and the workflow uses no repository secrets. + +The public suite runs against Orchestra Testbench rather than the private +NativeWatch server. Server acceptance and end-to-end native crash behavior +still require their respective integration checks. This CI does not compile +or execute Swift, Kotlin, an iOS simulator or an Android device. + +The first standalone run passed 39 tests and failed the subprocess compatibility +check because it loaded the private server bootstrap. It now starts a fresh +Testbench application in the subprocess and still checks that no NativePHP +runtime is installed. All 40 existing tests execute, without skips. + +The initial GitHub Laravel 11 matrix jobs were blocked by Composer security +advisories, including PKSA-b41t-wbzk-9q51 and PKSA-d5tc-s1qs-h781. CI covers +Laravel 12 and 13 without disabling advisory blocking. Runtime constraints are +unchanged, but this pipeline does not establish Laravel 11 compatibility. diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..d621d98 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,14 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + level: 7 + paths: + - src + - tests/Fixtures/NativeUiTypeCheck.php + tmpDir: .phpstan-cache + dynamicConstantNames: + - FILTER_FLAG_GLOBAL_RANGE + scanFiles: + - stubs/nativephp.php + - stubs/native-ui.stub diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..7efc43c --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + src + + + + + + + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..d4c8c61 --- /dev/null +++ b/pint.json @@ -0,0 +1,58 @@ +{ + "preset": "laravel", + "rules": { + "array_push": true, + "backtick_to_shell_exec": true, + "date_time_immutable": true, + "declare_strict_types": true, + "lowercase_keywords": true, + "lowercase_static_reference": true, + "final_class": true, + "final_internal_class": true, + "final_public_method_for_abstract_class": true, + "fully_qualified_strict_types": true, + "global_namespace_import": { + "import_classes": true, + "import_constants": true, + "import_functions": true + }, + "mb_str_functions": true, + "modernize_types_casting": true, + "new_with_parentheses": false, + "no_superfluous_elseif": true, + "no_useless_else": true, + "no_multiple_statements_per_line": true, + "ordered_class_elements": { + "order": [ + "use_trait", + "case", + "constant", + "constant_public", + "constant_protected", + "constant_private", + "property_public", + "property_protected", + "property_private", + "construct", + "destruct", + "magic", + "phpunit", + "method_abstract", + "method_public_static", + "method_public", + "method_protected_static", + "method_protected", + "method_private_static", + "method_private" + ], + "sort_algorithm": "none" + }, + "ordered_interfaces": true, + "ordered_traits": true, + "protected_to_private": true, + "self_accessor": true, + "self_static_accessor": true, + "strict_comparison": true, + "visibility_required": true + } +} diff --git a/tests/NativeUiCompatibilityTest.php b/tests/NativeUiCompatibilityTest.php index 225e4f3..57f97a7 100644 --- a/tests/NativeUiCompatibilityTest.php +++ b/tests/NativeUiCompatibilityTest.php @@ -19,15 +19,15 @@ } }, true, true); if (class_exists('Native\\Mobile\\Runtime') || class_exists('Native\\Mobile\\Edge\\NativeComponent')) exit(1); -$app = require $argv[3]; +$app = Orchestra\Testbench\Foundation\Application::create(); $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); $app->make('config')->set([ 'nativewatch.enabled' => true, 'nativewatch.key' => 'nw_compatibility_test', 'nativewatch.url' => 'https://zxguard.test', 'nativewatch.require_consent' => true, - 'nativewatch.consent.path' => $argv[4].'/missing-consent.json', - 'nativewatch.outbox.path' => $argv[4].'/outbox', + 'nativewatch.consent.path' => $argv[3].'/missing-consent.json', + 'nativewatch.outbox.path' => $argv[3].'/outbox', ]); $app->register(ZXN\NativeWatch\Mobile\MobileServiceProvider::class); if (! trait_exists(ZXN\NativeWatch\Mobile\Concerns\ReportsNativeUiErrors::class)) exit(2); @@ -36,9 +36,8 @@ $process = new Process([ PHP_BINARY, '-r', $script, - base_path('vendor/autoload.php'), + dirname(__DIR__).'/vendor/autoload.php', dirname(__DIR__).'/src', - base_path('bootstrap/app.php'), temporaryOutbox(), ], base_path(), ['JUMP_BRIDGE_PORT' => false]); $process->mustRun(); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..a2b50f7 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,55 @@ +extend(TestCase::class)->in(__DIR__); + +function temporaryOutbox(): string +{ + $path = sys_get_temp_dir().'/nativewatch-tests/'.bin2hex(random_bytes(6)); + mkdir($path, 0755, true); + + return $path; +} + +/** + * Get the activity entries that the SDK sent, after the app finished its work. + * + * @return list> + */ +function sentActivities(?string $signal = null): array +{ + app()->terminate(); + + $entries = []; + + foreach (Illuminate\Support\Facades\Http::recorded() as [$request]) { + if (str_ends_with($request->url(), '/api/v1/activities')) { + array_push($entries, ...$request['entries']); + } + } + + return array_values(array_filter($entries, fn (array $entry): bool => $signal === null || $entry['signal'] === $signal)); +} + +/** + * Get the events that the SDK sent, after the app finished its work. + * + * @return list> + */ +function sentOccurrences(): array +{ + app()->terminate(); + + $events = []; + + foreach (Illuminate\Support\Facades\Http::recorded() as [$request]) { + if (str_ends_with($request->url(), '/api/v1/occurrences')) { + $events[] = $request->data(); + } + } + + return $events; +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..b266f0f --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,26 @@ +set([ + 'app.key' => 'base64:'.base64_encode(str_repeat('a', 32)), + 'cache.default' => 'array', + 'database.default' => 'testing', + 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:'], + 'mail.default' => 'array', + 'queue.default' => 'sync', + 'session.driver' => 'array', + 'nativewatch.key' => null, + ]); + } +}