Browser testing
The automated suite authenticates directly and never crosses hosts, so a class of defects only shows up in a browser: cross-origin redirects, cookie scope, generated URLs, hover-revealed controls. This page describes the local tooling for running those journeys quickly, including from a browser-automation agent.
Login links
Section titled “Login links”php artisan dev:login --list # demo accounts, roles, 2FA statephp artisan dev:login viewer@second-shop # role@slug shorthandphp artisan dev:login staff@test-shop --storefrontphp artisan dev:login manager@test-shop --workspace=second-shopThe command prints a URL. Opening it signs the account in and lands on the workspace control panel (or storefront). Nobody types a password, so the link can be handed to an automation tool.
Demo shops
Section titled “Demo shops”php artisan db:seed (or --class=DemoWorkspaceSeeder, idempotent: existing slugs are skipped) creates three workspaces, each in UAH with the Europe/Kyiv default timezone, one account per role (<role>@<slug>.test, password DEMO_PASSWORD, default password), a catalogue, batches through the inventory ledger and one order per lifecycle state:
| Slug | Purpose |
|---|---|
test-shop |
Original fixture; its manager is also a viewer in second-shop (cross-tenant cases) |
second-shop |
Second tenant for isolation checks |
pilot-shop |
Clean shop in the Stage 3 pilot shape for storefront and checkout journeys |
Laravel Boost (MCP)
Section titled “Laravel Boost (MCP)”.mcp.json registers Laravel Boost for Claude Code (php artisan boost:mcp; approve it once with /mcp). It runs against the local application only: tinker, database-query (read-only), database-schema, list-routes, last-error, read-log-entries, browser-logs and version-matched search-docs. Boost is a dev dependency and is not installed in production. Fixtures that move stock still go through InventoryLedger and the order services, never direct column updates, so inventory:reconcile stays green.
Safety properties
Section titled “Safety properties”The link is not a back door. Four independent gates apply:
| Gate | Effect |
|---|---|
DEV_LOGIN_LINKS=true in .env |
Off by default; .env.example ships it false |
APP_ENV is local or testing |
The route is a 404 in every other environment even if the flag is on |
| Signed URL | Tampering with the token or expiry invalidates the signature |
| Single-use cache token, 10 minutes | A second visit, or a visit after expiry, is a 404 |
Every use is logged with the user and workspace. The feature test DevLoginLinkTest covers all four gates.
Typical session
Section titled “Typical session”npm run buildso the browser runs production assets, ornpm run devfor hot reload.php artisan dev:login owner@second-shop, open the link.- Drive the journey; check the console and network panel, not only the screen.
php artisan dev:login viewer@second-shopin the same browser switches account (the new login replaces the session).
What it has already found
Section titled “What it has already found”On 12 September 2026 the first session with these links found that every Wayfinder-generated link and form action in the control panel pointed at http://.flowery.test/...: pages passed an empty workspace argument and nothing set Wayfinder’s URL defaults, so create and update forms failed with a 503. The suite could not see it because tests call routes by name on the server. Fix: workspaceSlug() from @/lib/workspace-url supplies the slug of the current page (kept current on every Inertia navigation), and an ESLint rule rejects an empty workspace argument.
On 4 October 2026 the first real-account onboarding on production (flowery.com.ua) found these defects, which demo accounts (created verified and fully onboarded) could not show:
| Defect | Cause | Fix and test |
|---|---|---|
| No verification email; an unverified account went straight into onboarding | User did not implement MustVerifyEmail (commented out in the starter kit), so Fortify never sent the email and the verified middleware passed everyone |
User implements MustVerifyEmail; OnboardingFlowTest asserts the email is sent and an unverified account is redirected to the verification notice |
| Refreshing an onboarding step returned a 500 | showWorkspaceForm() / showSubdomainForm() were typed to return an Inertia page but return Inertia::location(), a plain redirect on a full page load (TypeError) |
The subdomain step may return a redirect; step 1 no longer redirects (see the next row); OnboardingFlowTest covers both on a full page load |
| Onboarding had no way back or out: dead sidebar links, step 1 skipped once a shop existed, no visible logout | The steps used the control-panel AppLayout, whose /control links only exist on a shop subdomain; step 1 redirected whenever the user had a workspace |
OnboardingLayout (logo, language, signed-in email, log out); step 2 links back to step 1, which is pre-filled and renames the owner’s shop rather than creating a second; OnboardingFlowTest |
Onboarding could be skipped half-way and reopened after completion; reopening it renamed the live shop (ion became ionsd) |
Step 1 already creates a usable workspace with a slug from its name; nothing recorded completion, so / sent the owner to the control panel mid-way and the steps stayed open forever |
workspaces.onboarded_at, set when the subdomain is saved (existing workspaces backfilled); until then / (also the login and verification landing) sends the owner to the subdomain step; afterwards every onboarding step redirects to the control panel; OnboardingFlowTest |
| Any member could change the subdomain of the shop they were viewing | saveSubdomain() updated the user’s current workspace without checking ownership |
Only the owner’s own workspace is changed; OnboardingFlowTest |
| A shop name containing HTML would run in the completion page | The name was interpolated into a translation rendered with dangerouslySetInnerHTML (i18next escaping is off) |
Rendered with Trans, which inserts the name as text |
| The completion page offered the control-panel URL as the shop address and promised the subdomain could be changed in settings, which has no such field | Copy written before the storefront and settings existed | Shows the storefront URL; the false hint is removed (changing a live address needs redirects and is a deliberate later feature) |
The subdomain step previewed http://<slug>.com.ua |
The page derived the root domain by dropping the first label of the browser hostname (locally it showed .test); both steps hard-coded http:// |
The server passes domain (APP_DOMAIN) and the request scheme; the completion step links route('dashboard'); OnboardingUrlTest |
After a correct two-factor code (or a password login) the page stayed put although the user was signed in; the console showed AxiosError: Network Error. Found 4 October 2026 with a real password and 2FA login (S2-F04), which dev:login never exercises |
Fortify answers with redirect()->intended(). When the intended page is on a shop subdomain, that is a cross-origin 302, which an Inertia XHR cannot follow |
HandleInertiaRequests turns any redirect to another host that answers an Inertia request into Inertia::location() (409 + X-Inertia-Location); CrossHostRedirectTest covers password and 2FA login. RedirectIfAuthenticated also built the shop URL with a hard-coded http:// and now uses the named route |
| Signing out of a shop’s control panel showed the shop’s storefront inside a popup over the control panel. Found 6 October 2026 | Fortify redirects to / on the current host after logout. On a shop subdomain that is now the Astro storefront, not an Inertia page, so Inertia displayed it in its error overlay |
App\Http\Responses\LogoutResponse sends sign-out to the root sign-in page with Inertia::location() (a full visit); AuthenticationTest covers the Inertia request from a shop host |
Signing in on a shop’s address (where sign-out leaves people) showed that shop’s storefront in the popup instead of opening the control panel, for the shop’s own owner and for members of other shops alike. Found 10 October 2026 on liliia.flowery.com.ua/login |
Fortify’s sign-in and two-factor responses do redirect()->intended('/'). On the root domain / sends a signed-in person on to their shop; on a shop host / is the storefront (Astro, not Inertia) |
App\Http\Responses\LoginResponse, bound for both the sign-in and the two-factor contracts, honours an intended URL and otherwise goes to the person’s own control panel (Inertia::location() when that is another host); SignInOnShopHostTest |
| After “Remove domain” on Settings → Domain the buttons stayed disabled until a full reload, so a domain added right after could not be connected with Cloudflare. Found 12 October 2026 during the first real connection | The page called router.delete(url, {}, options); Inertia’s delete takes (url, options), so the options with the “finished” callback were ignored and the page kept its busy state (Inertia keeps the component mounted across visits to the same page) |
delete and post are called with their own signatures (settings/domain.tsx) |
| The two-factor error read “The provided two factor authentication code was invalid.” on a Ukrainian page | Fortify’s own messages had no Ukrainian translations | Added to lang/uk.json; TranslationsTest now also scans Fortify’s source |
| The storefront invented content: “Real reviews” from made-up customers, “New” and “Bestseller” badges given by card position, same-day delivery, “order before 2pm”, a 7-day freshness guarantee, money back and “secure payment” (no checkout exists), plus a newsletter, wishlist, share and footer links that did nothing. Found 4 October 2026 | Template copy from the prototype, never tied to shop data | Removed (storefront honesty hotfix). The guard now lives in storefront/tests/honesty.test.ts, which scans every theme file for invented reviews, promises and popularity claims. Real shop details come with the storefront profile (Storefront platform plan) |
Flower “Add to cart” sent a flower id as bouquet_id (a 422, or the wrong bouquet added); quick-view and related-product buttons did nothing |
The cart sells bouquets only; the flower cards reused the bouquet action | Flowers go into the customer’s own bouquet in the builder; quick view and related cards call the real actions |
| The bouquet gallery never showed images; the page total added packaging once while the cart charges it per bouquet | The page read a non-existent path field; the total ignored quantity for packaging |
The server sends each image’s url; the total is (price + packaging) × quantity |
| Customers saw raw stock (including expired and reserved stems) and the builder offered flowers the home page called sold out | Pages read stock_quantity and a literal threshold of 5 |
Every storefront page uses available_quantity (stems promisable today, FEFO); stock_quantity is never sent to the storefront |
| “Admin Panel” was shown to every visitor | The header linked to the control panel unconditionally | Shown only to members of the shop. The Astro storefront, which replaced these pages on 5 October 2026, has no staff links at all |
Lessons: never derive the tenant root domain from window.location; take it from the server. A journey that demo data pre-satisfies (verified email, onboarded shop) needs one run with a fresh real account, including refreshes, a second tab and the back button.
Phone-width screenshots
Section titled “Phone-width screenshots”The Chrome extension cannot resize the window, and a headless Chrome --window-size=390,… screenshot is not a phone: the window is 390 wide, the viewport narrower, and the right edge is clipped (it misled the Stage 3A closeout for a moment). For a true phone viewport use the Playwright CLI, which needs no project dependency:
--wait-for-timeout=2000 http://pilot-shop.flowery.test:4321/checkout checkout-390.pngA page that needs the cart cookie takes --load-storage=storage.json with a Playwright storage state: one cookie named flowery_cart for the shop host, its value from the Set-Cookie header of a POST /storefront-api/v1/cart/items.
Limits
Section titled “Limits”- Two-factor enrolment requires password confirmation, so that journey still needs a person.
- Links are for local and testing environments only. Do not reach for them in staging or production; use a real account.