* Fix OpenRouter support by using laravel/ai default provider config. PR #216 patched Lab::OpenRouter into every agent match, but laravel/ai already resolves config('ai.default') — including openrouter — when agents omit provider(). Those matches also forced unknown providers to Gemini and BrandAnalyzerRunner checked a non-existent services.openrouter key. Remove the duplicated provider() overrides, gate availability on ai.providers.*.key, and document OPENROUTER_API_KEY. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * chore(deps): bump laravel/ai to v0.10.3 for native OpenRouter support v0.5.1's OpenRouter driver only covered text/embeddings via the legacy Prism gateway. v0.10.3 ships a native OpenRouter gateway with image, audio (TTS/STT), and web search support, and drops prism-php/prism as a dependency. ai-sdk-development skill docs refreshed via boost:update to match the installed version. * fix: honor AI_IMAGE_PROVIDER instead of hardcoding OpenAI's gpt-image-2 AiImageClient always passed model: 'gpt-image-2' to Image::of()->generate(), so AI_IMAGE_PROVIDER silently did nothing for any provider other than OpenAI — gemini/xai/openrouter would fail against an OpenAI-only model id and quietly fall back to a stock photo. Usage recording was hardcoded to provider 'openai' too, so credits were billed against the wrong model whenever a different provider actually ran. Drop the hardcoded model so generation falls through to the SDK's own config('ai.default_for_images') + per-provider default model, and read the actual provider/model back off the response's meta for usage recording and source_meta instead of assuming OpenAI. * refactor: extract AiImageClient into single-purpose steps, fix uncaught exception generate() built the prompt, called the SDK, and unpacked the response all in one block, with bytes extraction happening after the try/catch — so a response with an empty images collection threw an uncaught RuntimeException from ImageResponse::firstImage() instead of returning null as documented. Split into cleanKeywords(), buildPrompt(), resolveBrandContext(), and toResult(), and moved response unpacking inside the try block so any malformed response is treated as a failure like everything else. Added a regression test with an empty-images fake response. * fix: drop hardcoded default_text_model, resolve model per provider default_text_model was the only per-modality model override in ai.php — image, audio, transcription, embeddings, and reranking all just pick a provider and let it use its own default model. Text had a config-pinned model on top, forced into every agent's model() and into every usage log's model field regardless of which provider actually ran. Switching AI_TEXT_PROVIDER (e.g. to openrouter) kept sending OpenAI's model id to whichever provider ended up handling the request. Removed model() from all six agents so laravel/ai resolves the model from the active provider's own default (OpenAI's default is already 'gpt-5.4', so no behavior change there). Usage-recording call sites now read the actual provider/model back off the response's meta instead of assuming config('ai.default')/default_text_model. StreamPostContent needed the then() callback since broadcast()'s StreamableAgentResponse doesn't expose meta directly. * refactor: drop AiConfiguration wrapper, use data_get() for array reads AiConfiguration was a one-line static helper used by only two call sites, with no laravel/ai equivalent to lean on (confirmed AiManager and the Provider base class expose no isConfigured()/hasKey() check — the package's model is try-then-catch, not pre-flight checks). Inlined the filled(config(...)) check directly into HandleInertiaRequests and BrandAnalyzerRunner instead of keeping a class around one line of logic. Also swapped direct array-key reads for data_get() per project convention across every file touched by the recent AI provider/model fixes (agents' budget arrays, RunGenerateNode/StreamPostCreation's humanizer merge, RegeneratePostMediaImage's baseContext/copy/rendered access). Write/assignment sites (`$x['key'] = ...`) are left as-is — data_get() only reads. * feat: enhance AI configuration with new providers and options Added strict types declaration and updated Azure OpenAI API version. Introduced new 'bedrock' provider configuration with AWS credentials and role assumptions. Enhanced existing providers with additional options, including image deployment for Azure and OpenAI, and updated URLs for Gemini and Ollama. Added support for an 'openai-compatible' driver to broaden integration capabilities. * fix: remove trailing newline in AI configuration file * feat: add support for OpenRouter and ElevenLabs API keys in configuration Updated the production Docker Compose file and example environment file to include commented-out entries for OPENROUTER_API_KEY and ELEVENLABS_API_KEY. This enhances the configuration options for AI providers, allowing for easier integration of additional services. * feat: allow per-provider model overrides for every AI modality Adds a `models` array to each provider block, wired to env vars, so self-hosted operators can pin a specific text/image/audio/transcription/ embeddings/reranking model instead of relying on the package's built-in default for that provider. Only added for the modalities each provider actually implements (verified against laravel/ai's Provider classes). Azure is left untouched — it resolves models via deployment names (AZURE_OPENAI_DEPLOYMENT etc.), not raw model strings, which was already wired before this change. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
543 lines
17 KiB
PHP
543 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Actions\Ai\AutofillBrand;
|
|
use App\Ai\Agents\BrandAnalyzer;
|
|
use App\Services\Brand\BrandMetadata;
|
|
use Illuminate\Http\Client\Request as HttpRequest;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
beforeEach(function () {
|
|
// Run tests without LLM credentials so the deterministic fallback is exercised.
|
|
config()->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'
|
|
<!DOCTYPE html>
|
|
<html lang="pt-BR">
|
|
<head>
|
|
<title>Acme Coffee | The best beans online</title>
|
|
<meta name="description" content="Premium artisan coffee beans shipped worldwide.">
|
|
<meta property="og:site_name" content="Acme Coffee">
|
|
<meta property="og:image" content="https://example.com/og.png">
|
|
<link rel="apple-touch-icon" href="https://example.com/apple-touch-icon.png">
|
|
<link rel="icon" sizes="512x512" href="/icon-512.png">
|
|
</head>
|
|
<body>Welcome</body>
|
|
</html>
|
|
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('<html><head><title>Foo</title></head></html>', 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'
|
|
<html>
|
|
<head>
|
|
<title>Marketing</title>
|
|
<meta property="og:image" content="https://example.com/social-card.png">
|
|
</head>
|
|
</html>
|
|
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'
|
|
<html lang="en">
|
|
<head>
|
|
<title>Super SaaS | Landing page</title>
|
|
<meta name="description" content="A simple product.">
|
|
</head>
|
|
<body></body>
|
|
</html>
|
|
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("<html lang=\"{$lang}\"><head><title>X</title></head></html>", 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('<html><head><title>ok</title></head></html>', 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('<html><body></body></html>', 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'
|
|
<html>
|
|
<head>
|
|
<title>Email API, SMTP & Marketing Platform for Developers & AI Agents</title>
|
|
</head>
|
|
<body></body>
|
|
</html>
|
|
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'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Acme</title>
|
|
<meta name="theme-color" content="#0ea5e9">
|
|
</head>
|
|
<body></body>
|
|
</html>
|
|
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'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Acme</title>
|
|
<style>
|
|
:root {
|
|
--primary: #ff5722;
|
|
--background: #0b0f19;
|
|
--foreground: #e2e8f0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body></body>
|
|
</html>
|
|
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'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Acme</title>
|
|
<style>
|
|
body {
|
|
background-color: #ffffff;
|
|
color: #1f2937;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body></body>
|
|
</html>
|
|
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'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Acme</title>
|
|
<link rel="stylesheet" href="/app.css">
|
|
</head>
|
|
<body></body>
|
|
</html>
|
|
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'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Acme</title>
|
|
<link rel="icon" sizes="32x32" href="/logo.png">
|
|
</head>
|
|
<body>Hello</body>
|
|
</html>
|
|
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'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Acme</title>
|
|
<meta name="theme-color" content="rgb(255, 0, 0)">
|
|
<style>:root { --primary: notacolor; --background: red; }</style>
|
|
</head>
|
|
<body></body>
|
|
</html>
|
|
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(<<<HTML
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Acme</title>
|
|
<style>{$css}</style>
|
|
</head>
|
|
<body></body>
|
|
</html>
|
|
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'
|
|
<html lang="en">
|
|
<head>
|
|
<title>Widget Co</title>
|
|
<meta name="description" content="A very terse seo blurb.">
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Build widgets faster</h1>
|
|
<p>Widget Co helps small teams ship production widgets 10x faster without writing boilerplate.</p>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
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'
|
|
<html lang="en">
|
|
<head>
|
|
<title>OpenRouter Co</title>
|
|
<meta name="description" content="Terse blurb.">
|
|
</head>
|
|
<body><main><p>OpenRouter Co ships AI through one key.</p></main></body>
|
|
</html>
|
|
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 <html lang> 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'
|
|
<html lang="en">
|
|
<head>
|
|
<title>Beispiel GmbH</title>
|
|
<meta name="description" content="Kurze Beschreibung.">
|
|
</head>
|
|
<body><main><p>Wir bauen Widgets für kleine Teams.</p></main></body>
|
|
</html>
|
|
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'
|
|
<html lang="pt-BR">
|
|
<head>
|
|
<title>Marca</title>
|
|
<meta name="description" content="Uma descrição curta.">
|
|
</head>
|
|
<body><main><p>hello</p></main></body>
|
|
</html>
|
|
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'
|
|
<html lang="en">
|
|
<head>
|
|
<title>Acme</title>
|
|
<meta name="description" content="Fallback desc.">
|
|
</head>
|
|
<body><main><p>hi</p></main></body>
|
|
</html>
|
|
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('<html lang="en"><head><title>Foo</title></head></html>', 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',
|
|
]);
|
|
});
|