fix(sidebar+types): mobile slide animation, close-on-navigate, PHPStan clean
- sidebar: transition-[width,transform] so mobile open/close animates - sidebar: pure :class binding replaces mixed class+:style for translate - layout: livewire:navigated closes mobile sidebar via Alpine store - tests: RouteSmokeTest covers all 17 workspace/profile/AI routes - scripts: crawl-all-routes.mjs + console-check.mjs (Playwright) - phpstan: add @property-read, @var, @return generics across 15 modal/component files → 0 errors - pint: auto-fix style violations in 9 files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
6283e10072
commit
248c5100d1
|
|
@ -2,8 +2,12 @@
|
|||
|
||||
namespace App\Livewire\Components;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
use Illuminate\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
/** @property-read Collection<int, DatabaseNotification> $notifications */
|
||||
class NotificationsBell extends Component
|
||||
{
|
||||
public bool $open = false;
|
||||
|
|
@ -13,7 +17,8 @@ class NotificationsBell extends Component
|
|||
return auth()->user()?->unreadNotifications()->count() ?? 0;
|
||||
}
|
||||
|
||||
public function getNotificationsProperty()
|
||||
/** @return Collection<int, DatabaseNotification> */
|
||||
public function getNotificationsProperty(): Collection
|
||||
{
|
||||
return auth()->user()?->notifications()->latest()->take(10)->get() ?? collect();
|
||||
}
|
||||
|
|
@ -34,7 +39,7 @@ class NotificationsBell extends Component
|
|||
$this->open = false;
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.components.notifications-bell');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
namespace App\Livewire\Components;
|
||||
|
||||
use Illuminate\View\View;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class Toaster extends Component
|
||||
{
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $toasts = [];
|
||||
|
||||
#[On('toast')]
|
||||
|
|
@ -27,7 +29,7 @@ class Toaster extends Component
|
|||
);
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.components.toaster');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class AddDomain extends ModalComponent
|
|||
|
||||
public string $hostname = '';
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class ConfirmAction extends ModalComponent
|
|||
|
||||
public string $confirmEvent = '';
|
||||
|
||||
/** @var array<int|string, mixed> */
|
||||
public array $confirmEventParams = [];
|
||||
|
||||
public bool $danger = false;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class CreateApiToken extends ModalComponent
|
|||
|
||||
public ?string $plainTextToken = null;
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
|
|||
|
||||
use App\Domains\Bio\Models\BioPage;
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use App\Livewire\Pages\Bio\Index;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
|
@ -18,6 +19,7 @@ class CreateBioPage extends ModalComponent
|
|||
|
||||
public string $slug = '';
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -58,7 +60,7 @@ class CreateBioPage extends ModalComponent
|
|||
'is_published' => false,
|
||||
]);
|
||||
|
||||
$this->dispatch('bio-created')->to(\App\Livewire\Pages\Bio\Index::class);
|
||||
$this->dispatch('bio-created')->to(Index::class);
|
||||
$this->dispatch('toast', message: 'Bio Page erstellt', type: 'success');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ class CreateWebhook extends ModalComponent
|
|||
|
||||
public string $secret = '';
|
||||
|
||||
/** @var array<int, string> */
|
||||
public array $selectedEvents = [];
|
||||
|
||||
/** @var array<string, string> */
|
||||
public array $availableEvents = [
|
||||
'link.clicked' => 'Link clicked',
|
||||
'link.created' => 'Link created',
|
||||
|
|
@ -27,6 +29,7 @@ class CreateWebhook extends ModalComponent
|
|||
'link.deleted' => 'Link deleted',
|
||||
];
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ class CreateWorkspace extends ModalComponent
|
|||
{
|
||||
public string $name = '';
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class DeleteWorkspace extends ModalComponent
|
|||
$this->workspaceName = $workspace->name;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -31,6 +32,7 @@ class DeleteWorkspace extends ModalComponent
|
|||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
|
|||
|
||||
use App\Domains\Bio\Models\BioPage;
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use App\Livewire\Pages\Bio\Index;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ class EditBioPage extends ModalComponent
|
|||
$this->isPublished = $bio->is_published;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -55,7 +57,7 @@ class EditBioPage extends ModalComponent
|
|||
'is_published' => $this->isPublished,
|
||||
]);
|
||||
|
||||
$this->dispatch('bio-updated')->to(\App\Livewire\Pages\Bio\Index::class);
|
||||
$this->dispatch('bio-updated')->to(Index::class);
|
||||
$this->dispatch('toast', message: 'Bio Page aktualisiert', type: 'success');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
|
@ -72,10 +74,10 @@ class EditBioPage extends ModalComponent
|
|||
|
||||
private function authorizeWorkspace(int $workspaceId): void
|
||||
{
|
||||
Workspace::where("id", $workspaceId)
|
||||
Workspace::where('id', $workspaceId)
|
||||
->where(fn ($q) => $q
|
||||
->where("owner_id", auth()->id())
|
||||
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id()))
|
||||
->where('owner_id', auth()->id())
|
||||
->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
|
||||
)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
|
|||
|
||||
use App\Domains\Link\Models\Link;
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use App\Livewire\Pages\Links\Index;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ class EditLink extends ModalComponent
|
|||
$this->status = $link->status;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -63,7 +65,7 @@ class EditLink extends ModalComponent
|
|||
'status' => $this->status,
|
||||
]);
|
||||
|
||||
$this->dispatch('link-updated', linkUlid: $this->linkUlid)->to(\App\Livewire\Pages\Links\Index::class);
|
||||
$this->dispatch('link-updated', linkUlid: $this->linkUlid)->to(Index::class);
|
||||
$this->dispatch('toast', message: 'Link aktualisiert', type: 'success');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
|
@ -75,10 +77,10 @@ class EditLink extends ModalComponent
|
|||
|
||||
private function authorizeWorkspace(int $workspaceId): void
|
||||
{
|
||||
Workspace::where("id", $workspaceId)
|
||||
Workspace::where('id', $workspaceId)
|
||||
->where(fn ($q) => $q
|
||||
->where("owner_id", auth()->id())
|
||||
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id()))
|
||||
->where('owner_id', auth()->id())
|
||||
->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
|
||||
)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class EditMemberRole extends ModalComponent
|
|||
|
||||
public string $role = 'editor';
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -62,10 +63,10 @@ class EditMemberRole extends ModalComponent
|
|||
|
||||
private function authorizeWorkspace(int $workspaceId): void
|
||||
{
|
||||
Workspace::where("id", $workspaceId)
|
||||
Workspace::where('id', $workspaceId)
|
||||
->where(fn ($q) => $q
|
||||
->where("owner_id", auth()->id())
|
||||
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id()))
|
||||
->where('owner_id', auth()->id())
|
||||
->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
|
||||
)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
|
|||
|
||||
use App\Domains\QrCode\Models\QrCode;
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use App\Livewire\Pages\QrCodes\Index;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ class EditQrCode extends ModalComponent
|
|||
$this->type = $qr->type;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -54,7 +56,7 @@ class EditQrCode extends ModalComponent
|
|||
'payload' => ['url' => $this->url],
|
||||
]);
|
||||
|
||||
$this->dispatch('qr-updated')->to(\App\Livewire\Pages\QrCodes\Index::class);
|
||||
$this->dispatch('qr-updated')->to(Index::class);
|
||||
$this->dispatch('toast', message: 'QR Code aktualisiert', type: 'success');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
|
@ -71,10 +73,10 @@ class EditQrCode extends ModalComponent
|
|||
|
||||
private function authorizeWorkspace(int $workspaceId): void
|
||||
{
|
||||
Workspace::where("id", $workspaceId)
|
||||
Workspace::where('id', $workspaceId)
|
||||
->where(fn ($q) => $q
|
||||
->where("owner_id", auth()->id())
|
||||
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id()))
|
||||
->where('owner_id', auth()->id())
|
||||
->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
|
||||
)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class InviteMember extends ModalComponent
|
|||
|
||||
public string $role = 'editor';
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -5,13 +5,19 @@ namespace App\Livewire\Modals;
|
|||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/** @property-read string $fullUrl */
|
||||
class UtmBuilder extends ModalComponent
|
||||
{
|
||||
public string $baseUrl = '';
|
||||
|
||||
public string $utmSource = '';
|
||||
|
||||
public string $utmMedium = '';
|
||||
|
||||
public string $utmCampaign = '';
|
||||
|
||||
public string $utmContent = '';
|
||||
|
||||
public string $utmTerm = '';
|
||||
|
||||
public bool $copied = false;
|
||||
|
|
|
|||
|
|
@ -98,10 +98,13 @@
|
|||
|
||||
<aside
|
||||
x-data="{ wsOpen: false }"
|
||||
:class="$store.sidebar.collapsed ? 'w-16' : 'w-60'"
|
||||
class="fixed inset-y-0 left-0 h-screen flex flex-col bg-s1 border-r border-white/[.06] z-40 transition-[width] duration-200 ease-in-out
|
||||
-translate-x-full md:translate-x-0"
|
||||
:style="$store.sidebar?.open ? 'transform: translateX(0)' : ''"
|
||||
:class="{
|
||||
'w-16': $store.sidebar.collapsed,
|
||||
'w-60': !$store.sidebar.collapsed,
|
||||
'translate-x-0': $store.sidebar?.open,
|
||||
'-translate-x-full': !$store.sidebar?.open,
|
||||
}"
|
||||
class="fixed inset-y-0 left-0 h-screen flex flex-col bg-s1 border-r border-white/[.06] z-40 md:translate-x-0 transition-[width,transform] duration-200 ease-in-out"
|
||||
>
|
||||
<!-- Header: Logo + Workspace Switcher -->
|
||||
<div class="flex-shrink-0 relative" @click.outside="wsOpen = false">
|
||||
|
|
|
|||
|
|
@ -142,6 +142,13 @@
|
|||
}
|
||||
document.addEventListener('livewire:navigated', markReady);
|
||||
|
||||
// Close mobile sidebar after SPA navigation
|
||||
document.addEventListener('livewire:navigated', function () {
|
||||
if (window.Alpine && Alpine.store('sidebar')) {
|
||||
Alpine.store('sidebar').open = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Highlight active nav link after SPA navigation
|
||||
function updateActiveNav() {
|
||||
var path = window.location.pathname;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Console-error crawler using Playwright.
|
||||
* Logs in, visits all sidebar nav links, reports JS errors/exceptions.
|
||||
*
|
||||
* Usage:
|
||||
* npm install -D @playwright/test && npx playwright install chromium
|
||||
* node scripts/console-check.mjs
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const BASE = process.env.APP_URL ?? 'https://app.nimuli.com';
|
||||
const EMAIL = 'nexxo@nimuli.com';
|
||||
const PASS = 'NimuliDev2026!';
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
console.log(`\n${'═'.repeat(45)}`);
|
||||
console.log(' Nimuli — Console Error Check');
|
||||
console.log(` ${new Date().toISOString()}`);
|
||||
console.log(` Base: ${BASE}`);
|
||||
console.log(`${'═'.repeat(45)}\n`);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
viewport: { width: 1280, height: 800 },
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push({ url: page.url(), text: msg.text() });
|
||||
} else if (msg.type() === 'warning') {
|
||||
warnings.push({ url: page.url(), text: msg.text() });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', err => {
|
||||
errors.push({ url: page.url(), text: `PageError: ${err.message}` });
|
||||
});
|
||||
|
||||
// ── Login ────────────────────────────────────
|
||||
console.log('[ LOGIN ]');
|
||||
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' });
|
||||
await page.fill('input[name=email]', EMAIL);
|
||||
await page.fill('input[name=password]', PASS);
|
||||
await page.click('button[type=submit]');
|
||||
await page.waitForLoadState('networkidle');
|
||||
console.log(` ✓ Logged in: ${page.url()}`);
|
||||
|
||||
// ── Collect nav links ─────────────────────────
|
||||
const navLocators = page.locator('[data-nav-link]');
|
||||
const navCount = await navLocators.count();
|
||||
const navLinks = [];
|
||||
for (let i = 0; i < navCount; i++) {
|
||||
const href = await navLocators.nth(i).getAttribute('href');
|
||||
if (href && !href.endsWith('#') && !navLinks.includes(href)) {
|
||||
navLinks.push(href);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n[ CRAWLING ${navLinks.length} NAV LINKS ]`);
|
||||
|
||||
for (const link of navLinks) {
|
||||
const pre = errors.length;
|
||||
await page.goto(link, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(400);
|
||||
const newErrs = errors.length - pre;
|
||||
const status = newErrs > 0 ? `✗ ${newErrs} new error(s)` : '✓ clean';
|
||||
let path = link;
|
||||
try { path = new URL(link).pathname; } catch { /* keep full link */ }
|
||||
console.log(` ${status}: ${path}`);
|
||||
}
|
||||
|
||||
// ── Profile pages ─────────────────────────────
|
||||
console.log('\n[ PROFILE PAGES ]');
|
||||
for (const path of ['/profile', '/profile/personal', '/profile/preferences', '/profile/security']) {
|
||||
const pre = errors.length;
|
||||
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(300);
|
||||
const newErrs = errors.length - pre;
|
||||
console.log(` ${newErrs > 0 ? '✗' : '✓'} ${path}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// ── Report ────────────────────────────────────
|
||||
console.log(`\n${'═'.repeat(45)}`);
|
||||
if (errors.length > 0) {
|
||||
console.error(` ❌ ${errors.length} console error(s):`);
|
||||
for (const e of errors) {
|
||||
let path = e.url;
|
||||
try { path = new URL(e.url).pathname; } catch { /* keep full */ }
|
||||
console.error(` ${path}: ${e.text.slice(0, 140)}`);
|
||||
}
|
||||
console.error(`${'═'.repeat(45)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
console.warn(` ⚠ ${warnings.length} console warning(s) (non-blocking)`);
|
||||
}
|
||||
console.log(' ✅ No console errors');
|
||||
console.log(`${'═'.repeat(45)}\n`);
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* Route crawler: logs in via Playwright, visits all app routes,
|
||||
* reports HTTP errors and JS/Blade errors in the page body.
|
||||
*
|
||||
* Usage: node scripts/crawl-all-routes.mjs
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const BASE = process.env.APP_URL ?? 'https://app.nimuli.com';
|
||||
const EMAIL = 'nexxo@nimuli.com';
|
||||
const PASS = 'NimuliDev2026!';
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const failures = [];
|
||||
|
||||
console.log(`\n${'═'.repeat(47)}`);
|
||||
console.log(' Nimuli — Route Crawler');
|
||||
console.log(` ${new Date().toISOString()}`);
|
||||
console.log(` Base: ${BASE}`);
|
||||
console.log(`${'═'.repeat(47)}\n`);
|
||||
|
||||
// ── DB helper (safe: no shell, all args explicit) ─
|
||||
function dbQuery(sql) {
|
||||
const result = spawnSync(
|
||||
'docker',
|
||||
['compose', 'exec', '-T', 'mysql', 'mysql', '-unimuli', '-pnimuli', 'nimuli', '-se', sql],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
if (result.status !== 0) return '';
|
||||
const lines = result.stdout.trim().split('\n').filter(Boolean);
|
||||
return lines[lines.length - 1]?.trim() ?? '';
|
||||
}
|
||||
|
||||
// ── Get workspace ULID ────────────────────────
|
||||
const wsUlid = dbQuery(
|
||||
"SELECT w.ulid FROM workspaces w JOIN workspace_members wm ON wm.workspace_id=w.id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' ORDER BY w.created_at LIMIT 1"
|
||||
);
|
||||
if (!wsUlid) { console.error(' ✗ No workspace for nexxo@nimuli.com'); process.exit(1); }
|
||||
console.log(` Workspace: ${wsUlid}\n`);
|
||||
|
||||
// ── Get sample resource IDs ───────────────────
|
||||
const linkUlid = dbQuery(
|
||||
"SELECT l.ulid FROM links l JOIN workspace_members wm ON wm.workspace_id=l.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' LIMIT 1"
|
||||
);
|
||||
const qrUlid = dbQuery(
|
||||
"SELECT q.ulid FROM qr_codes q JOIN workspace_members wm ON wm.workspace_id=q.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' LIMIT 1"
|
||||
);
|
||||
const bioSlug = dbQuery(
|
||||
"SELECT bp.slug FROM bio_pages bp JOIN workspace_members wm ON wm.workspace_id=bp.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' LIMIT 1"
|
||||
);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// Collect JS errors per-page
|
||||
const pageErrors = [];
|
||||
page.on('console', msg => { if (msg.type() === 'error') pageErrors.push(msg.text()); });
|
||||
page.on('pageerror', err => { pageErrors.push(`PageError: ${err.message}`); });
|
||||
|
||||
// ── Login ─────────────────────────────────────
|
||||
console.log('[ LOGIN ]');
|
||||
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' });
|
||||
await page.fill('input[name=email]', EMAIL);
|
||||
await page.fill('input[name=password]', PASS);
|
||||
await page.click('button[type=submit]');
|
||||
await page.waitForLoadState('networkidle');
|
||||
if (page.url().includes('/login')) {
|
||||
console.error(' ✗ Still on login page — wrong credentials or app error');
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` ✓ Authenticated: ${page.url()}\n`);
|
||||
|
||||
// ── Route check helper ────────────────────────
|
||||
const BLADE_ERR_RE = /Undefined variable|ErrorException|Whoops|Class .* not found|Call to .* on null|syntax error/i;
|
||||
|
||||
async function check(path, label) {
|
||||
pageErrors.length = 0;
|
||||
const resp = await page.goto(`${BASE}/${path}`, { waitUntil: 'networkidle' });
|
||||
const status = resp?.status() ?? 0;
|
||||
const body = await page.content();
|
||||
const bladeErr = BLADE_ERR_RE.test(body)
|
||||
? (body.match(/(?:Undefined variable \w+|ErrorException.{0,60}|Class .{0,30} not found)/)?.[0] ?? 'Blade error')
|
||||
: null;
|
||||
const consoleErrs = [...pageErrors];
|
||||
|
||||
if (status >= 400 || bladeErr || consoleErrs.length > 0) {
|
||||
let reason = status >= 400 ? `HTTP ${status}` : '';
|
||||
if (bladeErr) reason += (reason ? ', ' : '') + bladeErr;
|
||||
if (consoleErrs.length) reason += (reason ? ', ' : '') + `${consoleErrs.length} JS error(s): ${consoleErrs[0].slice(0, 80)}`;
|
||||
console.error(` ✗ ${label} → ${reason}`);
|
||||
failures.push({ path, label, reason });
|
||||
fail++;
|
||||
} else {
|
||||
console.log(` ✓ ${label} → ${status}`);
|
||||
pass++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Static routes ─────────────────────────────
|
||||
console.log('[ STATIC ROUTES ]');
|
||||
await check('', 'root /');
|
||||
await check('forgot-password', 'forgot-password');
|
||||
|
||||
// ── Profile routes ────────────────────────────
|
||||
console.log('\n[ PROFILE ROUTES ]');
|
||||
await check('profile', 'profile');
|
||||
await check('profile/personal', 'profile/personal');
|
||||
await check('profile/preferences', 'profile/preferences');
|
||||
await check('profile/security', 'profile/security');
|
||||
|
||||
// ── Workspace routes ──────────────────────────
|
||||
console.log(`\n[ WORKSPACE ROUTES: ${wsUlid} ]`);
|
||||
await check(`w/${wsUlid}`, 'dashboard');
|
||||
await check(`w/${wsUlid}/links`, 'links');
|
||||
await check(`w/${wsUlid}/qr`, 'qr-codes');
|
||||
await check(`w/${wsUlid}/bio`, 'bio-pages');
|
||||
await check(`w/${wsUlid}/analytics`, 'analytics');
|
||||
await check(`w/${wsUlid}/domains`, 'domains');
|
||||
await check(`w/${wsUlid}/team`, 'team');
|
||||
await check(`w/${wsUlid}/billing`, 'billing');
|
||||
await check(`w/${wsUlid}/settings`, 'settings');
|
||||
await check(`w/${wsUlid}/settings/api-tokens`, 'settings/api-tokens');
|
||||
await check(`w/${wsUlid}/settings/webhooks`, 'settings/webhooks');
|
||||
|
||||
// ── AI routes ─────────────────────────────────
|
||||
console.log('\n[ AI ROUTES ]');
|
||||
await check(`w/${wsUlid}/ai/insights`, 'ai/insights');
|
||||
await check(`w/${wsUlid}/ai/anomalies`, 'ai/anomalies');
|
||||
await check(`w/${wsUlid}/ai/ab-generator`, 'ai/ab-generator');
|
||||
|
||||
// ── Resource routes ───────────────────────────
|
||||
console.log('\n[ RESOURCE ROUTES ]');
|
||||
if (linkUlid) await check(`w/${wsUlid}/links/${linkUlid}`, 'link show');
|
||||
else console.log(' — no links in DB, skipping');
|
||||
if (qrUlid) await check(`w/${wsUlid}/qr/${qrUlid}`, 'qr show');
|
||||
else console.log(' — no QR codes in DB, skipping');
|
||||
if (bioSlug) await check(`bio/${bioSlug}`, 'bio public');
|
||||
else console.log(' — no bio pages in DB, skipping');
|
||||
|
||||
await browser.close();
|
||||
|
||||
// ── Summary ───────────────────────────────────
|
||||
const total = pass + fail;
|
||||
console.log(`\n${'═'.repeat(47)}`);
|
||||
console.log(` Passed: ${pass} / ${total}`);
|
||||
if (fail > 0) {
|
||||
console.error(` Failed: ${fail}`);
|
||||
console.error('\n Failures:');
|
||||
for (const f of failures) console.error(` - ${f.label}: ${f.reason}`);
|
||||
console.error(`${'═'.repeat(47)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' ✅ All routes OK');
|
||||
console.log(`${'═'.repeat(47)}\n`);
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
# Thin wrapper — delegates to crawl-all-routes.mjs (Playwright-based)
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec node "$DIR/crawl-all-routes.mjs" "$@"
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\Workspace\Actions\CreateWorkspace;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = (new CreateWorkspace)->handle($this->user, ['name' => 'Smoke Test WS']);
|
||||
});
|
||||
|
||||
// ── Profile routes ─────────────────────────────────
|
||||
test('profile routes return 200', function (string $route) {
|
||||
$this->actingAs($this->user)
|
||||
->get(route($route))
|
||||
->assertOk();
|
||||
})->with([
|
||||
'profile.personal',
|
||||
'profile.preferences',
|
||||
'profile.security',
|
||||
]);
|
||||
|
||||
// ── Workspace routes ────────────────────────────────
|
||||
test('workspace routes return 200', function (string $routeName) {
|
||||
$ws = $this->workspace;
|
||||
$this->actingAs($this->user)
|
||||
->get(route($routeName, $ws->ulid))
|
||||
->assertOk();
|
||||
})->with([
|
||||
'w.dashboard',
|
||||
'w.links.index',
|
||||
'w.qr.index',
|
||||
'w.bio.index',
|
||||
'w.analytics.index',
|
||||
'w.domains.index',
|
||||
'w.team.index',
|
||||
'w.billing.index',
|
||||
'w.settings.index',
|
||||
'w.settings.api-tokens',
|
||||
'w.settings.webhooks',
|
||||
]);
|
||||
|
||||
// ── AI routes ──────────────────────────────────────
|
||||
test('AI routes return 200', function (string $routeName) {
|
||||
$ws = $this->workspace;
|
||||
$this->actingAs($this->user)
|
||||
->get(route($routeName, $ws->ulid))
|
||||
->assertOk();
|
||||
})->with([
|
||||
'w.ai.insights',
|
||||
'w.ai.anomalies',
|
||||
'w.ai.ab-generator',
|
||||
]);
|
||||
Loading…
Reference in New Issue