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 index ce81a6a..0d1e3c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,30 +1,58 @@ -# Contributing to the NativeWatch SDKs +# Contributing -Use PHP 8.4 or newer and Composer 2. Open an issue in the affected SDK repository -before a substantial feature or a change to the event format. Include the SDK, -Laravel and NativePHP versions, a minimal reproduction and redacted logs. +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`. Desktop and -mobile integrations belong in their platform packages. Keep changes focused, -classes final, types explicit and files within 300 lines. Preserve the original -application error when reporting fails. Never include credentials or customer -crash reports in an issue, test fixture or pull request. +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. -Validate a package checkout with: +## Run the checks ```sh -composer validate --strict -composer install +COMPOSER_ROOT_VERSION=0.2.x-dev composer install +composer test ``` -The SDK regression tests run with the server's integration suite in the -maintainers' checkout. A standalone `composer install` does not run those tests. -Include a regression test or reproduction with a fix; maintainers run the full -suite before merging. Changes to native reporting also need a compiled Release -app, a real crash and relaunch, server-event assertions and screenshots. Identify -the platform and distinguish simulator evidence from a physical device. - -In a pull request, explain the failing behavior, the smallest complete fix and -the commands you actually ran. Include compatibility changes and any remaining -limits. Report suspected vulnerabilities privately to a repository maintainer -instead of opening an issue containing exploit details or private data. +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 c11a79e..1c61c05 100644 --- a/composer.json +++ b/composer.json @@ -21,5 +21,37 @@ "docs": "https://native.watch/docs", "issues": "https://github.com/zinxan/native.core/issues", "source": "https://github.com/zinxan/native.core" + }, + "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", + "zinxan/native-desktop": "^0.2" + }, + "autoload-dev": { + "psr-4": { + "ZXN\\NativeWatch\\Core\\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..84fbb76 --- /dev/null +++ b/docs/review/sdk-ci.md @@ -0,0 +1,37 @@ +# 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, + 110 Pest tests and 542 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 exposed 15 failures caused by private-server fixture, +model, schema and path dependencies. Those are replaced by a package-local +compiled Swift fixture, a notifiable Laravel model and package-relative paths. +The long-message test asserts the 8,192-character limit and valid UTF-8; +validation against the server's complete FormRequest stays in its integration +suite. Existing capture, consent, retry, watcher, release and symbol-upload +regressions run without a production endpoint. + +PHPStan also found redundant type guards around scalar console inputs whose +Laravel command signatures already enforce their type. Only those guards were +removed; optional source-root and CI credential validation remain in place. + +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..c236e86 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + level: 7 + paths: + - src + tmpDir: .phpstan-cache + dynamicConstantNames: + - FILTER_FLAG_GLOBAL_RANGE 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/src/Laravel/Commands/ReleaseCommand.php b/src/Laravel/Commands/ReleaseCommand.php index 7a15be2..5258fd5 100644 --- a/src/Laravel/Commands/ReleaseCommand.php +++ b/src/Laravel/Commands/ReleaseCommand.php @@ -23,9 +23,6 @@ public function handle(BuildReleaseManifest $builder, CiConnection $connection): { $path = $this->option('path') ?? base_path(); $version = $this->argument('version'); - if (! is_string($path) || ! is_string($version)) { - return self::FAILURE; - } try { if ($this->option('id-only')) { $this->line($builder->buildId($path, $version)); diff --git a/src/Laravel/Commands/SymbolsCommand.php b/src/Laravel/Commands/SymbolsCommand.php index d7aa2ff..7e58544 100644 --- a/src/Laravel/Commands/SymbolsCommand.php +++ b/src/Laravel/Commands/SymbolsCommand.php @@ -23,7 +23,7 @@ public function handle(FindDebugSymbols $find, CiConnection $connection): int try { $path = $this->argument('path'); $root = $this->option('source-root'); - if (! is_string($path) || ! is_string($root) || $root === '') { + if (! is_string($root) || $root === '') { throw new RuntimeException('Pass --source-root with the absolute checkout root used to compile this build.'); } $request = $connection->request(); diff --git a/tests/ClientTest.php b/tests/ClientTest.php index ab47937..0b71086 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -175,7 +175,7 @@ && $request['breadcrumbs'] === []); }); -it('keeps long exception messages within the ingest contract', function (bool $native): void { +it('truncates long exception messages without splitting UTF-8 characters', function (bool $native): void { Http::fake(['zxguard.test/*' => Http::response(status: 202)]); $client = client(); $message = str_repeat('é', 8_193); @@ -189,5 +189,5 @@ $client->flush(); Http::assertSent(fn (Request $request): bool => mb_strlen($request['exception']['value']) === 8_192 - && validator($request->data(), (new App\Http\Requests\Api\V1\StoreOccurrenceRequest)->rules())->passes()); + && mb_check_encoding($request['exception']['value'], 'UTF-8')); })->with([false, true]); diff --git a/tests/Fixtures/Symbols/CrashFixture.dwarf b/tests/Fixtures/Symbols/CrashFixture.dwarf new file mode 100644 index 0000000..56990d0 Binary files /dev/null and b/tests/Fixtures/Symbols/CrashFixture.dwarf differ diff --git a/tests/Fixtures/Symbols/CrashFixture.swift b/tests/Fixtures/Symbols/CrashFixture.swift new file mode 100644 index 0000000..a8667d1 --- /dev/null +++ b/tests/Fixtures/Symbols/CrashFixture.swift @@ -0,0 +1,7 @@ +@inline(never) +func nativeWatchFixtureFailure(_ value: Int) -> Int { + precondition(value != 0, "NativeWatch symbolication fixture") + return 84 / value +} + +print(nativeWatchFixtureFailure(CommandLine.arguments.count - 1)) diff --git a/tests/Fixtures/Symbols/README.md b/tests/Fixtures/Symbols/README.md new file mode 100644 index 0000000..7bae467 --- /dev/null +++ b/tests/Fixtures/Symbols/README.md @@ -0,0 +1,28 @@ +# Compiled Swift symbol fixture + +`CrashFixture.dwarf` is real optimized Mach-O debug information built from +`CrashFixture.swift`, using Xcode 27.1. It is not a fabricated symbol response. +The tests run the installed LLVM symbolizer and official Swift demangler against +its bytes on macOS and Linux CI. + +Recorded identity: `bf2dbb4a-b545-3baa-a8a7-8b40b5ea12c5`, arm64, +preferred image base `0x100000000`. Offset `0x714` resolves to +`nativeWatchFixtureFailure`, source line 4. A return address of `0x718` resolves +to the same instruction after the architecture adjustment. + +To rebuild from the repository root on an Apple Silicon Mac: + +```sh +xcrun swiftc -c -g -O -module-name NativeWatchSymbolFixture \ + -debug-prefix-map "$PWD=/nativewatch-fixtures" \ + tests/Fixtures/Symbols/CrashFixture.swift -o /tmp/nativewatch-symbol-fixture.o +xcrun swiftc -g /tmp/nativewatch-symbol-fixture.o -o /tmp/nativewatch-symbol-fixture +xcrun dsymutil /tmp/nativewatch-symbol-fixture -o /tmp/nativewatch-symbol-fixture.dSYM +cp /tmp/nativewatch-symbol-fixture.dSYM/Contents/Resources/DWARF/nativewatch-symbol-fixture \ + tests/Fixtures/Symbols/CrashFixture.dwarf +xcrun dwarfdump --uuid tests/Fixtures/Symbols/CrashFixture.dwarf +``` + +A rebuild can change UUIDs and offsets; update the corresponding assertions +from actual tool output. Prefix remapping keeps developer home paths out of +this fixture. This fixture proves symbol resolution, not crash capture. diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..dfac152 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,75 @@ +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; +} + +/** + * Register the desktop SDK with a fake NativeWatch server, for watcher tests. + * + * @param array $config + */ +function fakeNativeWatch(array $config = []): void +{ + Illuminate\Support\Facades\Http::fake(['zxguard.test/*' => Illuminate\Support\Facades\Http::response(status: 202)]); + + config([ + 'nativewatch.key' => 'nw_test', + 'nativewatch.url' => 'https://zxguard.test', + 'nativewatch.outbox.path' => temporaryOutbox(), + 'nativewatch.consent.path' => temporaryOutbox().'/consent.json', + ...$config, + ]); + + app()->register(ZXN\NativeWatch\Desktop\DesktopServiceProvider::class); +} diff --git a/tests/StackFramesTest.php b/tests/StackFramesTest.php index 4fb381e..bb9df16 100644 --- a/tests/StackFramesTest.php +++ b/tests/StackFramesTest.php @@ -6,12 +6,12 @@ it('starts with the line that threw and marks frames of the app', function (): void { $exception = new RuntimeException('Boom'); - $basePath = dirname(__DIR__, 3); + $basePath = dirname(__DIR__); $frames = new StackFrames($basePath)->fromThrowable($exception); expect($frames[0]) - ->file->toBe('packages/native-core/tests/StackFramesTest.php') + ->file->toBe('tests/StackFramesTest.php') ->line->toBe(__LINE__ - 7) ->in_app->toBeTrue(); @@ -32,7 +32,7 @@ $line = __LINE__ + 1; $exception = new RuntimeException('Boom'); - $frames = new StackFrames(dirname(__DIR__, 3), codeLines: 2)->fromThrowable($exception); + $frames = new StackFrames(dirname(__DIR__), codeLines: 2)->fromThrowable($exception); $code = $frames[0]['code']; expect($code)->not->toBeNull() @@ -46,7 +46,7 @@ }); it('sends no source code when it is turned off', function (): void { - $frames = new StackFrames(dirname(__DIR__, 3), codeLines: 0)->fromThrowable(new RuntimeException('Boom')); + $frames = new StackFrames(dirname(__DIR__), codeLines: 0)->fromThrowable(new RuntimeException('Boom')); expect(array_column($frames, 'code'))->each->toBeNull(); }); diff --git a/tests/SymbolsCommandTest.php b/tests/SymbolsCommandTest.php index d0c3a9d..38747a9 100644 --- a/tests/SymbolsCommandTest.php +++ b/tests/SymbolsCommandTest.php @@ -12,7 +12,7 @@ $this->app->register(DesktopServiceProvider::class); config(['nativewatch.url' => 'http://zxguard.test']); putenv('NATIVEWATCH_RELEASE_TOKEN=nwr_ci-symbol-secret'); - $this->dwarf = __DIR__.'/../../../tests/Fixtures/Symbols/CrashFixture.dwarf'; + $this->dwarf = __DIR__.'/Fixtures/Symbols/CrashFixture.dwarf'; $this->receipt = ['symbols' => [['imageUuid' => 'bf2dbb4a-b545-3baa-a8a7-8b40b5ea12c5', 'architecture' => ['value' => 'arm64'], 'sha256' => hash_file('sha256', $this->dwarf)]]]; }); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..2e42f83 --- /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, + ]); + } +} diff --git a/tests/Watchers/NotificationWatcherTest.php b/tests/Watchers/NotificationWatcherTest.php index 3c0d7ab..6e529ac 100644 --- a/tests/Watchers/NotificationWatcherTest.php +++ b/tests/Watchers/NotificationWatcherTest.php @@ -27,7 +27,10 @@ public function toArray(object $notifiable): array it('records sent and failed notifications with their channel', function (): void { fakeNativeWatch(); - $user = App\Models\User::factory()->make(); + $user = new class extends Illuminate\Foundation\Auth\User + { + use Illuminate\Notifications\Notifiable; + }; Illuminate\Support\Facades\Schema::create('notifications', function (Illuminate\Database\Schema\Blueprint $table): void { $table->uuid('id')->primary();