From 91cccd73ca7f9a942db6a7b3178758164c81abe6 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 24 Jul 2026 23:58:41 +0100 Subject: [PATCH 1/2] Add Jump compatibility indicators for plugins and EDGE docs Surfaces Bifrost's Jump preview app in two places: - Plugins: a manual `works_in_jump` flag (admin-controlled via Filament) rendered as a pill on the plugin detail page and marketplace cards. - Docs: a collapsible QR code in the right sidebar of Mobile v4 EDGE component pages, deep-linking into Jump. Visitors who scan without the app installed get an overlay with platform-detected store buttons. Also extracts Jump's public identity into App\Support\JumpApp so the deep-link association files and the docs UI share one source of truth, and fixes a Cache::flush() race in the docs controller that could throw FilesystemIterator errors on concurrent local requests. Co-Authored-By: Claude Opus 5 (1M context) --- app/Filament/Resources/PluginResource.php | 10 ++ app/Http/Controllers/ApplinksController.php | 23 +-- .../ShowDocumentationController.php | 22 ++- app/Models/Plugin.php | 6 + app/Support/JumpApp.php | 54 +++++++ app/Support/QrCode.php | 41 +++++ database/factories/PluginFactory.php | 7 + ...852_add_works_in_jump_to_plugins_table.php | 28 ++++ .../docs/jump-app-overlay.blade.php | 101 ++++++++++++ .../components/docs/jump-preview.blade.php | 54 +++++++ .../docs/toc-and-sponsors.blade.php | 4 + .../views/components/plugin-card.blade.php | 38 +++-- resources/views/docs/index.blade.php | 20 +++ resources/views/plugin-show.blade.php | 11 ++ tests/Feature/ApplinksTest.php | 31 ++++ tests/Feature/Docs/DocsCachingTest.php | 49 ++++++ tests/Feature/Docs/JumpPreviewTest.php | 149 ++++++++++++++++++ tests/Feature/PluginJumpIndicatorTest.php | 73 +++++++++ tests/Unit/Support/JumpAppTest.php | 39 +++++ tests/Unit/Support/QrCodeTest.php | 31 ++++ 20 files changed, 755 insertions(+), 36 deletions(-) create mode 100644 app/Support/JumpApp.php create mode 100644 app/Support/QrCode.php create mode 100644 database/migrations/2026_07_24_112852_add_works_in_jump_to_plugins_table.php create mode 100644 resources/views/components/docs/jump-app-overlay.blade.php create mode 100644 resources/views/components/docs/jump-preview.blade.php create mode 100644 tests/Feature/ApplinksTest.php create mode 100644 tests/Feature/Docs/DocsCachingTest.php create mode 100644 tests/Feature/Docs/JumpPreviewTest.php create mode 100644 tests/Feature/PluginJumpIndicatorTest.php create mode 100644 tests/Unit/Support/JumpAppTest.php create mode 100644 tests/Unit/Support/QrCodeTest.php diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php index d34c124d..7a9875e1 100644 --- a/app/Filament/Resources/PluginResource.php +++ b/app/Filament/Resources/PluginResource.php @@ -121,6 +121,10 @@ public static function form(Schema $schema): Schema Forms\Components\Toggle::make('is_active') ->label('Active'), + + Forms\Components\Toggle::make('works_in_jump') + ->label('Works in Jump') + ->helperText('Show a badge indicating this plugin runs in the Jump preview app (i.e. it ships no custom native code)'), ]), Schemas\Components\Section::make('Review Checks') @@ -312,6 +316,10 @@ public static function table(Table $table): Table ->label('Active') ->sortable(), + Tables\Columns\ToggleColumn::make('works_in_jump') + ->label('Jump') + ->sortable(), + Tables\Columns\IconColumn::make('reviewed_at') ->label('Reviewed') ->boolean() @@ -346,6 +354,8 @@ public static function table(Table $table): Table Tables\Filters\TernaryFilter::make('featured'), Tables\Filters\TernaryFilter::make('is_active') ->label('Active'), + Tables\Filters\TernaryFilter::make('works_in_jump') + ->label('Works in Jump'), ]) ->actions([ Actions\ActionGroup::make([ diff --git a/app/Http/Controllers/ApplinksController.php b/app/Http/Controllers/ApplinksController.php index 860569f4..4b8a7854 100644 --- a/app/Http/Controllers/ApplinksController.php +++ b/app/Http/Controllers/ApplinksController.php @@ -2,21 +2,10 @@ namespace App\Http\Controllers; +use App\Support\JumpApp; + class ApplinksController extends Controller { - /** - * Jump's iOS app identity: .. - */ - private const IOS_APP_ID = 'J68WFCX458.com.bifrosttech.jump'; - - /** - * Jump's Android package + the SHA-256 of its Play app-signing cert. - * These are public identifiers, not secrets. - */ - private const ANDROID_PACKAGE = 'com.bifrosttech.jump'; - - private const ANDROID_SHA256 = 'D8:31:4E:55:E5:FF:06:17:D8:49:EA:3B:1F:BF:6C:58:B3:8D:AD:2C:30:CA:13:D2:CA:42:B0:85:B4:7D:CB:38'; - /** * URL path prefixes that open in Jump. * @@ -46,9 +35,9 @@ public function assetLinks() ], 'target' => [ 'namespace' => 'android_app', - 'package_name' => self::ANDROID_PACKAGE, + 'package_name' => JumpApp::ANDROID_PACKAGE, 'sha256_cert_fingerprints' => [ - self::ANDROID_SHA256, + JumpApp::ANDROID_SHA256, ], ], ], @@ -76,13 +65,13 @@ public function appSiteAssociation() 'applinks' => [ 'details' => [ [ - 'appIDs' => [self::IOS_APP_ID], + 'appIDs' => [JumpApp::IOS_APP_ID], 'components' => $components, ], ], ], 'webcredentials' => [ - 'apps' => [self::IOS_APP_ID], + 'apps' => [JumpApp::IOS_APP_ID], ], ]); } diff --git a/app/Http/Controllers/ShowDocumentationController.php b/app/Http/Controllers/ShowDocumentationController.php index cea666fb..42d4f4b8 100644 --- a/app/Http/Controllers/ShowDocumentationController.php +++ b/app/Http/Controllers/ShowDocumentationController.php @@ -6,6 +6,7 @@ use App\Support\CommonMark\CommonMark; use Artesaos\SEOTools\Facades\SEOMeta; use Artesaos\SEOTools\Facades\SEOTools; +use Closure; use Illuminate\Contracts\View\Factory as ViewFactory; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -24,16 +25,12 @@ class ShowDocumentationController extends Controller { public function __invoke(Request $request, string $platform, string $version, ?string $page = null) { - if (config('app.env') === 'local') { - Cache::flush(); - } - abort_unless(is_dir(resource_path('views/docs/'.$platform.'/'.$version)), 404); session(['viewing_docs_version' => $version]); session(['viewing_docs_platform' => $platform]); - $navigation = Cache::remember("docs_nav_{$platform}_{$version}", now()->addDay(), + $navigation = $this->cacheOrCompute("docs_nav_{$platform}_{$version}", fn () => $this->getNavigation($platform, $version) ); @@ -42,7 +39,7 @@ public function __invoke(Request $request, string $platform, string $version, ?s } try { - $pageProperties = Cache::remember("docs_{$platform}_{$version}_{$page}", now()->addDay(), + $pageProperties = $this->cacheOrCompute("docs_{$platform}_{$version}_{$page}", fn () => $this->getPageProperties($platform, $version, $page) ); } catch (InvalidArgumentException $e) { @@ -83,6 +80,19 @@ public function __invoke(Request $request, string $platform, string $version, ?s return view('docs.index')->with($pageProperties); } + /** + * Cache the callback's result for a day, or compute it fresh in local so + * docs edits show up immediately without clearing (or racing on) the cache. + */ + private function cacheOrCompute(string $key, Closure $callback): mixed + { + if (config('app.env') === 'local') { + return $callback(); + } + + return Cache::remember($key, now()->addDay(), $callback); + } + public function serveRawMarkdown(Request $request, string $platform, string $version, string $page) { abort_unless(is_dir(resource_path('views/docs/'.$platform.'/'.$version)), 404); diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index c9ae7631..f0026d32 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -295,6 +295,11 @@ public function isOfficial(): bool return $this->is_official ?? false; } + public function worksInJump(): bool + { + return $this->works_in_jump ?? false; + } + public function isSatisSynced(): bool { return $this->satis_synced_at !== null; @@ -729,6 +734,7 @@ protected function casts(): array 'featured' => 'boolean', 'is_active' => 'boolean', 'is_official' => 'boolean', + 'works_in_jump' => 'boolean', 'composer_data' => 'array', 'nativephp_data' => 'array', 'last_synced_at' => 'datetime', diff --git a/app/Support/JumpApp.php b/app/Support/JumpApp.php new file mode 100644 index 00000000..5dd3a535 --- /dev/null +++ b/app/Support/JumpApp.php @@ -0,0 +1,54 @@ +.. + */ + public const IOS_APP_ID = 'J68WFCX458.com.bifrosttech.jump'; + + public const IOS_APP_STORE_URL = 'https://apps.apple.com/us/app/bifrost-jump/id6757173334'; + + public const ANDROID_PACKAGE = 'com.bifrosttech.jump'; + + /** + * SHA-256 of Jump's Play app-signing cert (public, not a secret). + */ + public const ANDROID_SHA256 = 'D8:31:4E:55:E5:FF:06:17:D8:49:EA:3B:1F:BF:6C:58:B3:8D:AD:2C:30:CA:13:D2:CA:42:B0:85:B4:7D:CB:38'; + + public const ANDROID_PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.bifrosttech.jump'; + + /** + * The only domain whose deep links open in Jump. + */ + public const CANONICAL_DOMAIN = 'https://nativephp.com'; + + /** + * Marks a visit as originating from a scanned QR code. + */ + public const QR_PARAM = 'jump=qr'; + + /** + * Build the scannable deep link for a docs page. + * + * Deliberately pinned to the canonical domain rather than the current host: + * Jump only claims links for nativephp.com (see {@see ApplinksController}), + * and a phone can't resolve a local or preview hostname anyway — so a QR + * built from url() would be unscannable everywhere except production. + */ + public static function docsDeepLink(string $path): string + { + return self::CANONICAL_DOMAIN.'/'.ltrim($path, '/').'?'.self::QR_PARAM; + } +} diff --git a/app/Support/QrCode.php b/app/Support/QrCode.php new file mode 100644 index 00000000..122cc91d --- /dev/null +++ b/app/Support/QrCode.php @@ -0,0 +1,41 @@ + QROutputInterface::MARKUP_SVG, + 'outputBase64' => false, + 'eccLevel' => EccLevel::M, + 'addQuietzone' => true, + 'quietzoneSize' => 2, + 'drawLightModules' => false, + 'svgAddXmlHeader' => false, + 'cssClass' => 'jump-qr', + ]); + + $svg = (new QRCodeGenerator($options))->render($data); + + // chillerlan emits a viewBox-only (no width/height) so it can scale; + // pin it to fill its container as a crisp square. + return str_replace('state(fn (array $attributes) => [ + 'works_in_jump' => true, + ]); + } + public function free(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_07_24_112852_add_works_in_jump_to_plugins_table.php b/database/migrations/2026_07_24_112852_add_works_in_jump_to_plugins_table.php new file mode 100644 index 00000000..e04af3ba --- /dev/null +++ b/database/migrations/2026_07_24_112852_add_works_in_jump_to_plugins_table.php @@ -0,0 +1,28 @@ +boolean('works_in_jump')->default(false)->after('mobile_min_version'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('works_in_jump'); + }); + } +}; diff --git a/resources/views/components/docs/jump-app-overlay.blade.php b/resources/views/components/docs/jump-app-overlay.blade.php new file mode 100644 index 00000000..11cd64e2 --- /dev/null +++ b/resources/views/components/docs/jump-app-overlay.blade.php @@ -0,0 +1,101 @@ +{{-- + Shown when a QR visitor lands here without the Jump app installed. + + Must stay outside the desktop-only right sidebar: it is the phone — not the + desktop — that renders this, after the deep link falls back to the browser. +--}} + diff --git a/resources/views/components/docs/jump-preview.blade.php b/resources/views/components/docs/jump-preview.blade.php new file mode 100644 index 00000000..f8e3780f --- /dev/null +++ b/resources/views/components/docs/jump-preview.blade.php @@ -0,0 +1,54 @@ +@props(['path']) + +@php + // Points at this page on nativephp.com with a marker query param. Jump claims + // /docs/* deep links there, so a device with the app opens it natively; a + // device without it falls back to the browser, where the marker triggers the + // "get the app" prompt. + $deepLink = \App\Support\JumpApp::docsDeepLink($path); +@endphp + +{{-- Lives in the right sidebar, which is itself desktop-only (hidden xl:flex) --}} +
+ + +
+ {{-- Dark QR modules need a light background in both colour schemes --}} + + +

+ Scan with your phone to preview this component live. +

+
+
diff --git a/resources/views/components/docs/toc-and-sponsors.blade.php b/resources/views/components/docs/toc-and-sponsors.blade.php index ea95251f..676627c1 100644 --- a/resources/views/components/docs/toc-and-sponsors.blade.php +++ b/resources/views/components/docs/toc-and-sponsors.blade.php @@ -1,6 +1,10 @@ {{-- Copy as Markdown Button --}} +@isset($beforeAds) + {{ $beforeAds }} +@endisset +
diff --git a/resources/views/components/plugin-card.blade.php b/resources/views/components/plugin-card.blade.php index d68401e4..dd3663df 100644 --- a/resources/views/components/plugin-card.blade.php +++ b/resources/views/components/plugin-card.blade.php @@ -20,19 +20,31 @@ class="size-12 shrink-0 rounded-xl object-cover"
@endif - @if ($plugin->isPaid() && $plugin->isOfficial() && auth()->user()?->hasUltraAccess()) - - Included with Ultra - - @elseif ($plugin->isPaid()) - - Premium - - @else - - Free - - @endif +
+ @if ($plugin->isPaid() && $plugin->isOfficial() && auth()->user()?->hasUltraAccess()) + + Included with Ultra + + @elseif ($plugin->isPaid()) + + Premium + + @else + + Free + + @endif + + @if ($plugin->worksInJump()) + + + @endif +
diff --git a/resources/views/docs/index.blade.php b/resources/views/docs/index.blade.php index ab2b5459..2e8aeb82 100644 --- a/resources/views/docs/index.blade.php +++ b/resources/views/docs/index.blade.php @@ -3,6 +3,13 @@ @endpush +@php + // Jump previews EDGE components, which only exist in the Mobile v4 docs. + $showJumpPreview = $platform === 'mobile' + && (string) $version === '4' + && str_starts_with((string) request()->route('page'), 'edge-components/'); +@endphp + {!! $navigation !!} @@ -25,6 +32,13 @@ @endif + {{-- EDGE component pages can be previewed live in the Jump app --}} + @if ($showJumpPreview) + + + + @endif + {{-- Ad rotation --}} + {{-- Prompts QR visitors who don't have Jump installed. Kept out of the + desktop-only sidebar, since it renders on the phone. --}} + @if ($showJumpPreview) + + @endif + {{-- Table of contents --}}
diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index be18bc15..38045a0a 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -95,6 +95,17 @@ class="text-2xl font-bold sm:text-3xl" {{ $plugin->description }}

@endif + @if ($plugin->worksInJump()) +
+ + +
+ @endif
diff --git a/tests/Feature/ApplinksTest.php b/tests/Feature/ApplinksTest.php new file mode 100644 index 00000000..4618dc59 --- /dev/null +++ b/tests/Feature/ApplinksTest.php @@ -0,0 +1,31 @@ +get('/.well-known/assetlinks.json') + ->assertStatus(200) + ->assertJsonFragment(['package_name' => JumpApp::ANDROID_PACKAGE]) + ->assertJsonFragment(['sha256_cert_fingerprints' => [JumpApp::ANDROID_SHA256]]); + } + + public function test_apple_app_site_association_exposes_the_jump_ios_identity(): void + { + $this->get('/.well-known/apple-app-site-association') + ->assertStatus(200) + ->assertJsonFragment(['appIDs' => [JumpApp::IOS_APP_ID]]); + } + + public function test_apple_app_site_association_scopes_to_docs_deep_links(): void + { + $this->get('/.well-known/apple-app-site-association') + ->assertStatus(200) + ->assertJsonFragment(['/' => '/docs/*']); + } +} diff --git a/tests/Feature/Docs/DocsCachingTest.php b/tests/Feature/Docs/DocsCachingTest.php new file mode 100644 index 00000000..95af7e78 --- /dev/null +++ b/tests/Feature/Docs/DocsCachingTest.php @@ -0,0 +1,49 @@ + 'local']); + Cache::put('unrelated-key', 'keep-me', now()->addHour()); + + $this->get('/docs/mobile/4/edge-components/stack')->assertStatus(200); + + $this->assertSame('keep-me', Cache::get('unrelated-key')); + } + + public function test_local_docs_request_does_not_cache_page_properties(): void + { + config(['app.env' => 'local']); + + $this->get('/docs/mobile/4/edge-components/stack')->assertStatus(200); + + $this->assertFalse(Cache::has('docs_mobile_4_edge-components/stack')); + } + + public function test_non_local_docs_request_caches_page_properties(): void + { + config(['app.env' => 'production']); + + $this->get('/docs/mobile/4/edge-components/stack')->assertStatus(200); + + $this->assertTrue(Cache::has('docs_mobile_4_edge-components/stack')); + } +} diff --git a/tests/Feature/Docs/JumpPreviewTest.php b/tests/Feature/Docs/JumpPreviewTest.php new file mode 100644 index 00000000..6f5b14b7 --- /dev/null +++ b/tests/Feature/Docs/JumpPreviewTest.php @@ -0,0 +1,149 @@ +get('/docs/mobile/4/edge-components/stack'); + + $response->assertStatus(200) + ->assertSee('Preview in Jump') + ->assertSee('Get the Jump app') + ->assertSee(JumpApp::IOS_APP_STORE_URL, false) + ->assertSee(JumpApp::ANDROID_PLAY_STORE_URL, false); + } + + public function test_qr_deep_link_targets_this_page_with_the_marker_param(): void + { + $this->get('/docs/mobile/4/edge-components/badge') + ->assertStatus(200) + ->assertSee('edge-components/badge?jump=qr', false); + } + + public function test_qr_deep_link_is_pinned_to_the_canonical_domain(): void + { + // Jump only claims deep links for nativephp.com, so the QR must encode + // that domain regardless of the host the docs are being served from. + $this->get('/docs/mobile/4/edge-components/badge') + ->assertStatus(200) + ->assertSee('https://nativephp.com/docs/mobile/4/edge-components/badge?jump=qr', false); + } + + public function test_qr_card_renders_in_the_desktop_only_right_sidebar(): void + { + $html = $this->get('/docs/mobile/4/edge-components/stack') + ->assertStatus(200) + ->getContent(); + + // The right sidebar is the desktop-only region and follows . + $this->assertGreaterThan( + strrpos($html, ''), + strpos($html, 'Preview in Jump'), + 'The QR card should render inside the right sidebar.' + ); + } + + public function test_qr_card_sits_between_the_copy_button_and_the_ads(): void + { + $html = $this->get('/docs/mobile/4/edge-components/stack') + ->assertStatus(200) + ->getContent(); + + $copyButton = strrpos($html, 'Copy as Markdown'); + $qrCard = strpos($html, 'Preview in Jump'); + $ads = strpos($html, 'Become a Partner', $qrCard); + + $this->assertGreaterThan($copyButton, $qrCard, 'The QR card should follow the Copy as Markdown button.'); + $this->assertGreaterThan($qrCard, $ads, 'The QR card should come before the ads.'); + } + + public function test_qr_card_is_collapsed_to_its_title_by_default(): void + { + $html = $this->get('/docs/mobile/4/edge-components/stack') + ->assertStatus(200) + ->getContent(); + + // The card occupies the sidebar slot between the copy button and the ads. + $start = strrpos($html, 'Copy as Markdown'); + $end = strpos($html, 'Become a Partner', $start); + $cardMarkup = substr($html, $start, $end - $start); + + $this->assertStringContainsString('Preview in Jump', $cardMarkup); + $this->assertStringContainsString('x-data="{ open: false }"', $cardMarkup); + $this->assertStringContainsString('open = !open', $cardMarkup); + $this->assertStringContainsString('x-collapse', $cardMarkup); + } + + public function test_store_overlay_stays_outside_the_desktop_only_sidebar(): void + { + $html = $this->get('/docs/mobile/4/edge-components/stack') + ->assertStatus(200) + ->getContent(); + + // Phones render the overlay, so it must not be hidden with the sidebar. + $this->assertLessThan( + strrpos($html, ''), + strpos($html, 'Get the Jump app'), + 'The overlay should render in the always-visible article body.' + ); + } + + public function test_non_edge_docs_page_has_no_jump_preview(): void + { + $this->get('/docs/mobile/4/getting-started/introduction') + ->assertStatus(200) + ->assertDontSee('Preview in Jump') + ->assertDontSee('Get the Jump app') + ->assertDontSee('jump=qr', false); + } + + /** + * EDGE sections also exist in Mobile v2 and v3, but Jump only previews v4. + * + * @return array + */ + public static function nonV4EdgePagesProvider(): array + { + return [ + 'mobile v2 edge components' => ['/docs/mobile/2/edge-components/bottom-nav'], + 'mobile v3 edge components' => ['/docs/mobile/3/edge-components/bottom-nav'], + ]; + } + + #[DataProvider('nonV4EdgePagesProvider')] + public function test_older_mobile_edge_pages_have_no_jump_preview(string $url): void + { + $this->get($url) + ->assertStatus(200) + ->assertDontSee('Preview in Jump') + ->assertDontSee('Get the Jump app') + ->assertDontSee('jump=qr', false); + } + + public function test_desktop_docs_have_no_jump_preview(): void + { + $this->get('/docs/desktop/2/the-basics/windows') + ->assertStatus(200) + ->assertDontSee('Preview in Jump') + ->assertDontSee('Get the Jump app') + ->assertDontSee('jump=qr', false); + } +} diff --git a/tests/Feature/PluginJumpIndicatorTest.php b/tests/Feature/PluginJumpIndicatorTest.php new file mode 100644 index 00000000..d7b863f3 --- /dev/null +++ b/tests/Feature/PluginJumpIndicatorTest.php @@ -0,0 +1,73 @@ +create()->refresh(); + + $this->assertFalse($plugin->works_in_jump); + $this->assertFalse($plugin->worksInJump()); + } + + public function test_works_in_jump_helper_reflects_the_column(): void + { + $plugin = Plugin::factory()->worksInJump()->create(); + + $this->assertTrue($plugin->works_in_jump); + $this->assertTrue($plugin->worksInJump()); + } + + public function test_detail_page_shows_pill_when_plugin_works_in_jump(): void + { + $plugin = Plugin::factory()->approved()->worksInJump()->create(); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertSee('Works in Jump'); + } + + public function test_detail_page_hides_pill_when_plugin_does_not_work_in_jump(): void + { + $plugin = Plugin::factory()->approved()->create(); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertDontSee('Works in Jump'); + } + + public function test_plugin_card_shows_badge_when_plugin_works_in_jump(): void + { + Plugin::factory()->approved()->worksInJump()->create(); + + Livewire::test(PluginDirectory::class) + ->assertSee('Works in Jump'); + } + + public function test_plugin_card_hides_badge_when_plugin_does_not_work_in_jump(): void + { + Plugin::factory()->approved()->create(); + + Livewire::test(PluginDirectory::class) + ->assertDontSee('Works in Jump'); + } +} diff --git a/tests/Unit/Support/JumpAppTest.php b/tests/Unit/Support/JumpAppTest.php new file mode 100644 index 00000000..ef53be87 --- /dev/null +++ b/tests/Unit/Support/JumpAppTest.php @@ -0,0 +1,39 @@ +assertSame( + 'https://nativephp.com/docs/mobile/4/edge-components/stack?jump=qr', + $link + ); + } + + public function test_docs_deep_link_tolerates_a_leading_slash(): void + { + $link = JumpApp::docsDeepLink('/docs/mobile/4/edge-components/badge'); + + $this->assertSame( + 'https://nativephp.com/docs/mobile/4/edge-components/badge?jump=qr', + $link + ); + } + + public function test_docs_deep_link_ignores_the_configured_app_url(): void + { + config(['app.url' => 'https://keen-pony.test']); + + $this->assertStringStartsWith( + 'https://nativephp.com/', + JumpApp::docsDeepLink('docs/mobile/4/edge-components/stack') + ); + } +} diff --git a/tests/Unit/Support/QrCodeTest.php b/tests/Unit/Support/QrCodeTest.php new file mode 100644 index 00000000..a088f35c --- /dev/null +++ b/tests/Unit/Support/QrCodeTest.php @@ -0,0 +1,31 @@ +assertStringContainsString('assertStringContainsString('', $svg); + } + + public function test_it_returns_raw_markup_not_a_base64_data_uri(): void + { + $svg = QrCode::svg('https://example.test'); + + $this->assertStringNotContainsString('data:image', $svg); + } + + public function test_it_pins_the_svg_to_fill_its_container(): void + { + $svg = QrCode::svg('https://example.test'); + + $this->assertStringContainsString('width:100%', $svg); + } +} From 9798ad1fe2dc032b7e137baf7bb7fd58a9754684 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 25 Jul 2026 00:51:02 +0100 Subject: [PATCH 2/2] Fix docs tests failing in CI without a Torchlight token Docs pages contain fenced code blocks, so rendering them calls Torchlight, which throws outside production when no token is configured. The new tests passed locally only because .env carries a real token, and returned 500 in CI where none exists. Adopts the setUp convention already used by DocumentationRenderingTest and DocsPrereleaseVersionTest: set a dummy token and fake the API so Torchlight falls back to un-highlighted output deterministically. Co-Authored-By: Claude Opus 5 (1M context) --- tests/Feature/Docs/DocsCachingTest.php | 10 ++++++++++ tests/Feature/Docs/JumpPreviewTest.php | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/tests/Feature/Docs/DocsCachingTest.php b/tests/Feature/Docs/DocsCachingTest.php index 95af7e78..e284fd32 100644 --- a/tests/Feature/Docs/DocsCachingTest.php +++ b/tests/Feature/Docs/DocsCachingTest.php @@ -5,6 +5,7 @@ use App\Features\ShowPlugins; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use Laravel\Pennant\Feature; use Tests\TestCase; @@ -17,6 +18,15 @@ protected function setUp(): void parent::setUp(); Feature::define(ShowPlugins::class, true); + + // These tests render full docs pages that contain fenced code blocks. + // Torchlight throws outside production when no token is configured (as + // in CI), so give it a token and fake the API to force its offline + // fallback — the pages render deterministically without a real token. + config(['torchlight.token' => 'test-token']); + Http::fake([ + '*' => Http::response(['blocks' => []], 200), + ]); } public function test_local_docs_request_does_not_flush_the_entire_cache(): void diff --git a/tests/Feature/Docs/JumpPreviewTest.php b/tests/Feature/Docs/JumpPreviewTest.php index 6f5b14b7..3379f46f 100644 --- a/tests/Feature/Docs/JumpPreviewTest.php +++ b/tests/Feature/Docs/JumpPreviewTest.php @@ -5,6 +5,7 @@ use App\Features\ShowPlugins; use App\Support\JumpApp; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Laravel\Pennant\Feature; use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; @@ -18,6 +19,15 @@ protected function setUp(): void parent::setUp(); Feature::define(ShowPlugins::class, true); + + // These tests render full docs pages that contain fenced code blocks. + // Torchlight throws outside production when no token is configured (as + // in CI), so give it a token and fake the API to force its offline + // fallback — the pages render deterministically without a real token. + config(['torchlight.token' => 'test-token']); + Http::fake([ + '*' => Http::response(['blocks' => []], 200), + ]); } public function test_edge_component_page_shows_the_jump_qr_and_store_overlay(): void