set('ai.providers.gemini.key', ''); config()->set('ai.providers.openai.key', ''); config()->set('ai.providers.openrouter.key', ''); config()->set('ai.providers.anthropic.key', ''); $this->autofill = fn (string $url) => app(AutofillBrand::class)($url); }); test('extracts name, description, language, and logo from meta tags', function () { Http::fake([ 'example.com' => Http::response(<<<'HTML' Acme Coffee | The best beans online Welcome HTML, 200), 'example.com/icon-512.png' => Http::response(file_get_contents(__DIR__.'/../../fixtures/1x1.png'), 200, ['Content-Type' => 'image/png']), ]); $result = ($this->autofill)('https://example.com'); expect($result->name)->toBe('Acme Coffee'); expect($result->description)->toBe('Premium artisan coffee beans shipped worldwide.'); expect($result->language)->toBe('pt-BR'); // The 512x512 PNG favicon wins over the unsized apple-touch-icon; og:image is ignored. expect($result->logoUrl)->toBe('https://example.com/icon-512.png'); }); test('falls back to /favicon.ico when no icon link is declared', function () { Http::fake([ 'example.com' => Http::response('Foo', 200), ]); $result = ($this->autofill)('https://example.com'); expect($result->logoUrl)->toBe('https://example.com/favicon.ico'); }); test('ignores og:image when no icon links are present', function () { Http::fake([ 'example.com/page' => Http::response(<<<'HTML' Marketing HTML, 200), ]); $result = ($this->autofill)('https://example.com/page'); // og:image is NOT used — falls back to /favicon.ico at the origin instead. expect($result->logoUrl)->toBe('https://example.com/favicon.ico'); }); test('falls back to title without og:site_name', function () { Http::fake([ 'example.com' => Http::response(<<<'HTML' Super SaaS | Landing page HTML, 200), ]); $result = ($this->autofill)('https://example.com'); expect($result->name)->toBe('Super SaaS'); expect($result->language)->toBe('en'); }); test('normalizes various language codes to supported locales', function (string $lang, ?string $expected) { Http::fake([ 'example.com' => Http::response("X", 200), ]); $result = ($this->autofill)('https://example.com'); expect($result->language)->toBe($expected); })->with([ // Every supported language, detected from its primary subtag. ['en', 'en'], ['uk', 'uk'], ['pt', 'pt-BR'], ['es', 'es'], ['fr', 'fr'], ['de', 'de'], ['it', 'it'], ['nl', 'nl'], ['pl', 'pl'], ['el', 'el'], ['ja', 'ja'], ['ko', 'ko'], ['zh', 'zh'], ['ru', 'ru'], ['tr', 'tr'], ['ar', 'ar'], // Region/script subtags still resolve to the supported language. ['pt-PT', 'pt-BR'], ['en-US', 'en'], ['uk-UA', 'uk'], ['es-MX', 'es'], ['ja-JP', 'ja'], ['zh-Hans', 'zh'], // Unsupported languages stay null. ['sv', null], ]); test('rejects non-http schemes', function () { expect(fn () => ($this->autofill)('ftp://example.com')) ->toThrow(RuntimeException::class); }); test('rejects private network addresses', function () { expect(fn () => ($this->autofill)('http://127.0.0.1')) ->toThrow(RuntimeException::class); expect(fn () => ($this->autofill)('http://192.168.1.1')) ->toThrow(RuntimeException::class); }); test('adds https:// when scheme is missing', function () { Http::fake([ 'example.com' => Http::response('ok', 200), ]); ($this->autofill)('example.com'); Http::assertSent(fn (HttpRequest $req) => str_starts_with($req->url(), 'https://example.com')); }); test('falls back to domain-derived name when site has no meta tags', function () { Http::fake([ 'example.com' => Http::response('', 200), ]); $result = ($this->autofill)('https://example.com'); expect($result->name)->toBe('Example'); expect($result->description)->toBeNull(); expect($result->language)->toBeNull(); // logoUrl always falls back to /favicon.ico since that URL exists on most sites. expect($result->logoUrl)->toBe('https://example.com/favicon.ico'); }); test('falls back to domain-derived name when title is a tagline with no separator', function () { Http::fake([ 'sendkit.dev' => Http::response(<<<'HTML' Email API, SMTP & Marketing Platform for Developers & AI Agents HTML, 200), ]); $result = ($this->autofill)('https://sendkit.dev'); expect($result->name)->toBe('Sendkit'); }); test('extracts brand color from theme-color meta', function () { Http::fake([ 'example.com' => Http::response(<<<'HTML' Acme HTML, 200), ]); $result = ($this->autofill)('https://example.com'); expect($result->brandColor)->toBe('#0ea5e9'); }); test('extracts colors from CSS custom properties', function () { Http::fake([ 'example.com' => Http::response(<<<'HTML' Acme HTML, 200), ]); $result = ($this->autofill)('https://example.com'); expect($result->brandColor)->toBe('#ff5722'); expect($result->backgroundColor)->toBe('#0b0f19'); expect($result->textColor)->toBe('#e2e8f0'); }); test('falls back to body { background } and body { color } rules', function () { Http::fake([ 'example.com' => Http::response(<<<'HTML' Acme HTML, 200), ]); $result = ($this->autofill)('https://example.com'); expect($result->backgroundColor)->toBe('#ffffff'); expect($result->textColor)->toBe('#1f2937'); }); test('extracts colors from external stylesheets', function () { Http::fake([ 'example.com' => Http::response(<<<'HTML' Acme HTML, 200), 'example.com/app.css' => Http::response(':root { --primary: #1d4ed8; --background: #f8fafc; }', 200), ]); $result = ($this->autofill)('https://example.com'); expect($result->brandColor)->toBe('#1d4ed8'); expect($result->backgroundColor)->toBe('#f8fafc'); }); test('falls back to dominant logo color when CSS has no signal', function () { // Logo fixture is a solid blue PNG. CSS in the page has no semantic hooks, // so AutofillBrand should reach into the logo and grab #1e6fff via // LogoColorExtractor. Http::fake([ 'example.com' => Http::response(<<<'HTML' Acme Hello HTML, 200), 'example.com/logo.png' => Http::response( file_get_contents(__DIR__.'/../../fixtures/blue-logo.png'), 200, ['Content-Type' => 'image/png'], ), ]); $result = ($this->autofill)('https://example.com'); expect($result->brandColor)->toBe('#1e6fff'); }); test('returns null when CSS contains no extractable colour values', function () { Http::fake([ 'example.com' => Http::response(<<<'HTML' Acme HTML, 200), ]); $result = ($this->autofill)('https://example.com'); // theme-color tier-1 and CSS-var tier-2 reject the rgb()/named values; // the frequency tier-3 finds nothing extractable in this CSS. expect($result->brandColor)->toBeNull(); expect($result->backgroundColor)->toBeNull(); }); test('falls back to CSS colour frequency when no semantic var is exposed', function () { // Tailwind/utility-style CSS with no --primary var: the brand colour just // appears many times across utility classes. Frequency tier picks it up. $css = str_repeat('.btn-primary { background-color: #2563eb; } ', 30); Http::fake([ 'example.com' => Http::response(<< Acme HTML, 200), ]); $result = ($this->autofill)('https://example.com'); expect($result->brandColor)->toBe('#2563eb'); }); test('throws when upstream site returns an error', function () { Http::fake([ 'example.com' => Http::response('', 500), ]); expect(fn () => ($this->autofill)('https://example.com')) ->toThrow(RuntimeException::class); }); test('when llm is configured, polishes description/tone/language/voice_notes via BrandAnalyzer', function () { config()->set('ai.providers.gemini.key', 'fake-key'); config()->set('ai.default', 'gemini'); Http::fake([ 'example.com' => Http::response(<<<'HTML' Widget Co

Build widgets faster

Widget Co helps small teams ship production widgets 10x faster without writing boilerplate.

HTML, 200), ]); BrandAnalyzer::fake([ [ 'description' => 'Widget Co helps small teams ship production widgets faster.', 'language' => 'en', // A bogus value (dropped) and a second POV (dropped — single-select). 'voice_traits' => ['third_person', 'first_person', 'direct', 'no_hype', 'not_a_trait'], ], ]); $result = ($this->autofill)('https://example.com'); expect($result->description)->toBe('Widget Co helps small teams ship production widgets faster.'); expect($result->language)->toBe('en'); expect($result->toArray()['brand_voice_traits'])->toBe(['third_person', 'direct', 'no_hype']); }); test('openrouter as default text provider enables BrandAnalyzer', function () { config()->set('ai.default', 'openrouter'); config()->set('ai.providers.openrouter.key', 'sk-or-v1-test'); Http::fake([ 'example.com' => Http::response(<<<'HTML' OpenRouter Co

OpenRouter Co ships AI through one key.

HTML, 200), ]); BrandAnalyzer::fake([ [ 'description' => 'OpenRouter Co ships AI through one key.', 'language' => 'en', 'voice_traits' => ['third_person', 'direct'], ], ]); $result = ($this->autofill)('https://example.com'); expect($result->description)->toBe('OpenRouter Co ships AI through one key.'); expect($result->toArray()['brand_voice_traits'])->toBe(['third_person', 'direct']); }); test('LLM language detection wins and carries any supported language, not just en/es/pt-BR', function () { config()->set('ai.providers.gemini.key', 'fake-key'); config()->set('ai.default', 'gemini'); // The declares "en", so the deterministic extractor yields 'en'. // The LLM reads the actual German body and returns 'de'. Since the fixture's // deterministic value differs from the LLM's, this isolates the mergeLlm // precedence: the LLM value must win, and it must be a language beyond the // original en/es/pt-BR set. Http::fake([ 'example.com' => Http::response(<<<'HTML' Beispiel GmbH

Wir bauen Widgets für kleine Teams.

HTML, 200), ]); BrandAnalyzer::fake([ [ 'description' => 'Beispiel GmbH baut Widgets für kleine Teams.', 'language' => 'de', 'voice_traits' => ['third_person'], ], ]); $result = ($this->autofill)('https://example.com'); expect($result->language)->toBe('de'); }); test('when llm is not configured, falls back to meta tags only', function () { // beforeEach already cleared api keys. Http::fake([ 'example.com' => Http::response(<<<'HTML' Marca

hello

HTML, 200), ]); // Fail loud if BrandAnalyzer is called. BrandAnalyzer::fake()->preventStrayPrompts(); $result = ($this->autofill)('https://example.com'); expect($result->description)->toBe('Uma descrição curta.'); expect($result->language)->toBe('pt-BR'); }); test('falls back to meta tags when BrandAnalyzer throws', function () { config()->set('ai.providers.gemini.key', 'fake-key'); config()->set('ai.default', 'gemini'); Http::fake([ 'example.com' => Http::response(<<<'HTML' Acme

hi

HTML, 200), ]); BrandAnalyzer::fake([ fn () => throw new RuntimeException('LLM went down'), ]); $result = ($this->autofill)('https://example.com'); expect($result->description)->toBe('Fallback desc.'); expect($result->language)->toBe('en'); }); test('toArray swaps site background and text colours for the image palette fields', function () { $metadata = new BrandMetadata( brandColor: '#eab308', backgroundColor: '#ffffff', textColor: '#1f2937', ); $array = $metadata->toArray(); expect($array['background_color'])->toBe('#1f2937') ->and($array['text_color'])->toBe('#ffffff') ->and($array['brand_color'])->toBe('#eab308') ->and($metadata->backgroundColor)->toBe('#ffffff') ->and($metadata->textColor)->toBe('#1f2937'); }); test('BrandMetadata toArray exposes the shape the controller expects', function () { Http::fake([ 'example.com' => Http::response('Foo', 200), ]); $result = ($this->autofill)('https://example.com'); expect($result->toArray())->toHaveKeys([ 'name', 'brand_description', 'content_language', 'brand_color', 'background_color', 'text_color', 'logo_url', 'brand_voice_traits', ]); });