test: Add E2E test framework with Playwright
feat: Basic Homepage elements
This commit is contained in:
123
app/components/WeatherWidget.vue
Normal file
123
app/components/WeatherWidget.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<IonCard>
|
||||||
|
<IonCardHeader>
|
||||||
|
<IonCardTitle>Weather</IonCardTitle>
|
||||||
|
<IonCardSubtitle>Marina conditions</IonCardSubtitle>
|
||||||
|
</IonCardHeader>
|
||||||
|
<IonCardContent>
|
||||||
|
<div v-if="loading" class="ion-text-center ion-padding">
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
</div>
|
||||||
|
<p v-else-if="error" class="empty-text">{{ error }}</p>
|
||||||
|
<div v-else class="weather-grid">
|
||||||
|
<div class="weather-item">
|
||||||
|
<IonIcon :icon="thermometerOutline" />
|
||||||
|
<span class="value">{{ weather.temp }}°C</span>
|
||||||
|
<span class="label">Temperature</span>
|
||||||
|
</div>
|
||||||
|
<div class="weather-item">
|
||||||
|
<IonIcon :icon="partlySunnyOutline" />
|
||||||
|
<span class="value">{{ weather.description }}</span>
|
||||||
|
<span class="label">Conditions</span>
|
||||||
|
</div>
|
||||||
|
<div class="weather-item">
|
||||||
|
<IonIcon
|
||||||
|
:icon="arrowUpOutline"
|
||||||
|
:style="{ transform: `rotate(${weather.windDir}deg)` }"
|
||||||
|
/>
|
||||||
|
<span class="value">{{ weather.windSpeed }} kn</span>
|
||||||
|
<span class="label">Wind</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle,
|
||||||
|
IonCardContent, IonSpinner, IonIcon,
|
||||||
|
} from '@ionic/vue'
|
||||||
|
import { thermometerOutline, partlySunnyOutline, arrowUpOutline } from 'ionicons/icons'
|
||||||
|
|
||||||
|
const LAT = 43.4412629
|
||||||
|
const LON = -79.6696725
|
||||||
|
|
||||||
|
const WMO: Record<number, string> = {
|
||||||
|
0: 'Clear', 1: 'Mostly clear', 2: 'Partly cloudy', 3: 'Overcast',
|
||||||
|
45: 'Fog', 48: 'Icy fog',
|
||||||
|
51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle',
|
||||||
|
61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
|
||||||
|
71: 'Light snow', 73: 'Snow', 75: 'Heavy snow',
|
||||||
|
80: 'Showers', 81: 'Showers', 82: 'Heavy showers',
|
||||||
|
95: 'Thunderstorm', 96: 'T-storm + hail', 99: 'Severe t-storm',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_KEY = 'weather_cache'
|
||||||
|
const CACHE_TTL_MS = 10 * 60 * 1000 // 10 minutes
|
||||||
|
|
||||||
|
interface WeatherData { temp: number; windSpeed: number; windDir: number; description: string }
|
||||||
|
interface WeatherCache { data: WeatherData; fetchedAt: number }
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const weather = ref<WeatherData>({ temp: 0, windSpeed: 0, windDir: 0, description: '' })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const cached = localStorage.getItem(CACHE_KEY)
|
||||||
|
if (cached) {
|
||||||
|
const { data, fetchedAt }: WeatherCache = JSON.parse(cached)
|
||||||
|
if (Date.now() - fetchedAt < CACHE_TTL_MS) {
|
||||||
|
weather.value = data
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
`https://api.open-meteo.com/v1/forecast` +
|
||||||
|
`?latitude=${LAT}&longitude=${LON}` +
|
||||||
|
`¤t=temperature_2m,wind_speed_10m,wind_direction_10m,weather_code` +
|
||||||
|
`&wind_speed_unit=kn`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
const json = await res.json()
|
||||||
|
const c = json.current
|
||||||
|
const data: WeatherData = {
|
||||||
|
temp: Math.round(c.temperature_2m),
|
||||||
|
windSpeed: Math.round(c.wind_speed_10m),
|
||||||
|
windDir: c.wind_direction_10m,
|
||||||
|
description: WMO[c.weather_code as number] ?? 'Unknown',
|
||||||
|
}
|
||||||
|
weather.value = data
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify({ data, fetchedAt: Date.now() }))
|
||||||
|
} catch {
|
||||||
|
error.value = 'Weather data unavailable'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.weather-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.weather-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.weather-item ion-icon {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
}
|
||||||
|
.value { font-size: 1rem; font-weight: 600; }
|
||||||
|
.label { font-size: 0.75rem; color: var(--ion-color-medium); }
|
||||||
|
.empty-text { color: var(--ion-color-medium); margin: 0; }
|
||||||
|
</style>
|
||||||
@@ -21,7 +21,46 @@
|
|||||||
</IonToolbar>
|
</IonToolbar>
|
||||||
</IonHeader>
|
</IonHeader>
|
||||||
<IonContent class="ion-padding">
|
<IonContent class="ion-padding">
|
||||||
<h2>Welcome to OYS Borrow a Boat</h2>
|
<h2 class="welcome">Welcome, {{ auth.displayName }}</h2>
|
||||||
|
|
||||||
|
<IonCard>
|
||||||
|
<IonCardHeader>
|
||||||
|
<IonCardTitle>Upcoming Reservations</IonCardTitle>
|
||||||
|
</IonCardHeader>
|
||||||
|
<IonCardContent>
|
||||||
|
<div v-if="loadingReservations" class="ion-text-center ion-padding">
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
</div>
|
||||||
|
<p v-else-if="upcomingReservations.length === 0" class="empty-text">
|
||||||
|
No upcoming reservations.
|
||||||
|
</p>
|
||||||
|
<IonList v-else lines="full">
|
||||||
|
<IonItem v-for="r in upcomingReservations" :key="r.id">
|
||||||
|
<IonLabel>
|
||||||
|
<h3>{{ boatName(r) }}</h3>
|
||||||
|
<p>{{ formatDateRange(r.start_time, r.end_time) }}</p>
|
||||||
|
</IonLabel>
|
||||||
|
<IonBadge slot="end" :color="statusColor(r.status)">{{ r.status }}</IonBadge>
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
|
||||||
|
<IonButton expand="block" router-link="/reservations/create" class="ion-margin-bottom">
|
||||||
|
<IonIcon slot="start" :icon="addCircleOutline" />
|
||||||
|
Create Reservation
|
||||||
|
</IonButton>
|
||||||
|
|
||||||
|
<IonCard>
|
||||||
|
<IonCardHeader>
|
||||||
|
<IonCardTitle>Calendar</IonCardTitle>
|
||||||
|
</IonCardHeader>
|
||||||
|
<IonCardContent class="calendar-content">
|
||||||
|
<DatePicker inline :min-date="today" />
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
|
||||||
|
<WeatherWidget />
|
||||||
</IonContent>
|
</IonContent>
|
||||||
</template>
|
</template>
|
||||||
</IonPage>
|
</IonPage>
|
||||||
@@ -30,19 +69,69 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
|
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
|
||||||
IonButtons, IonMenuButton, IonButton,
|
IonButtons, IonMenuButton, IonButton, IonCard, IonCardHeader,
|
||||||
|
IonCardTitle, IonCardContent, IonList, IonItem, IonLabel,
|
||||||
|
IonBadge, IonSpinner, IonIcon,
|
||||||
} from '@ionic/vue'
|
} from '@ionic/vue'
|
||||||
|
import { addCircleOutline } from 'ionicons/icons'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
import type { Database, ReservationStatus } from '~/types/supabase'
|
||||||
|
|
||||||
const user = useSupabaseUser()
|
type ReservationWithBoat = Database['public']['Tables']['reservations']['Row'] & {
|
||||||
|
boats: { name: string; display_name: string | null } | null
|
||||||
|
}
|
||||||
|
|
||||||
definePageMeta({ layout: false })
|
definePageMeta({ layout: false })
|
||||||
|
|
||||||
|
const user = useSupabaseUser()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const supabase = useSupabaseClient<Database>()
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const loadingReservations = ref(true)
|
||||||
|
const upcomingReservations = ref<ReservationWithBoat[]>([])
|
||||||
|
|
||||||
|
async function fetchReservations() {
|
||||||
|
if (!user.value) return
|
||||||
|
loadingReservations.value = true
|
||||||
|
const { data } = await (supabase as ReturnType<typeof useSupabaseClient>)
|
||||||
|
.from('reservations')
|
||||||
|
.select('*, boats(name, display_name)')
|
||||||
|
.eq('user_id', user.value.id)
|
||||||
|
.gte('start_time', new Date().toISOString())
|
||||||
|
.order('start_time', { ascending: true })
|
||||||
|
.limit(3)
|
||||||
|
upcomingReservations.value = (data as ReservationWithBoat[]) ?? []
|
||||||
|
loadingReservations.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(user, (val) => { if (val) fetchReservations() }, { immediate: true })
|
||||||
|
|
||||||
|
function boatName(r: ReservationWithBoat) {
|
||||||
|
return r.boats?.display_name || r.boats?.name || 'Unknown boat'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateRange(start: string, end: string) {
|
||||||
|
const s = new Date(start)
|
||||||
|
const e = new Date(end)
|
||||||
|
const date = s.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||||
|
const startTime = s.toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
const endTime = e.toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `${date} · ${startTime}–${endTime}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: ReservationStatus): string {
|
||||||
|
const colors: Record<ReservationStatus, string> = {
|
||||||
|
pending: 'warning',
|
||||||
|
tentative: 'medium',
|
||||||
|
confirmed: 'success',
|
||||||
|
}
|
||||||
|
return colors[status]
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.splash-content {
|
.splash-content { --background: var(--ion-color-light); }
|
||||||
--background: var(--ion-color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.splash-center {
|
.splash-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -52,14 +141,10 @@ definePageMeta({ layout: false })
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
.splash-logo { max-width: 280px; width: 100%; }
|
||||||
|
.splash-btn { width: 100%; max-width: 280px; }
|
||||||
|
|
||||||
.splash-logo {
|
.welcome { font-size: 1.25rem; font-weight: 600; margin: 0 0 1rem; }
|
||||||
max-width: 280px;
|
.empty-text { color: var(--ion-color-medium); margin: 0; }
|
||||||
width: 100%;
|
.calendar-content { display: flex; justify-content: center; padding: 0.5rem 0; }
|
||||||
}
|
|
||||||
|
|
||||||
.splash-btn {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 280px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Session Handoff: Auth Tests + Backend Ansible Plan
|
||||||
|
**Date:** 2026-04-12
|
||||||
|
**Session Focus:** Fix broken auth unit tests; plan bab-backend-ansible rewrite; update EE dependencies
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
1. **Deleted `tests/unit/auth-callback.test.ts`** — low-value component test per agreed test strategy; E2E covers this
|
||||||
|
2. **Extracted pure auth logic** → `app/utils/auth.ts` (`checkAuthRedirect(userValue, path): string | null`)
|
||||||
|
3. **Simplified `app/middleware/auth.ts`** — delegates to `checkAuthRedirect`; Nuxt-specific code is now minimal
|
||||||
|
4. **Rewrote `tests/unit/auth-middleware.test.ts`** — tests `checkAuthRedirect` directly, no mocking, node env; **7/7 passing**
|
||||||
|
5. **Documented Nuxt testing lessons** → memory `feedback_nuxt_testing.md`
|
||||||
|
6. **Wrote backend rewrite plan** → `docs/summaries/plan-bab-backend-ansible-rewrite.md`
|
||||||
|
7. **Updated `ee-demo` EE** with: `amazon.aws` collection, `boto3`/`botocore`, `postgresql` RPM, `supabase` CLI install via `SUPABASE_VERSION` build arg; updated `build.sh` (user also added `--redhat` flag)
|
||||||
|
8. **Resolved all plan open questions** except one (see below)
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
|
||||||
|
- `tests/integration/auth-session.test.ts` — written last session, not yet run (requires local Supabase + `SUPABASE_SERVICE_ROLE_KEY`)
|
||||||
|
- Playwright E2E — not yet set up; `tests/e2e/` directory does not exist
|
||||||
|
- `.gitea/workflows/build.yaml` — not yet created
|
||||||
|
|
||||||
|
## Key Technical Decisions This Session
|
||||||
|
|
||||||
|
- **Extract-don't-mock pattern**: Nuxt auto-imports compile to concrete dist paths; `vi.mock('#imports')` doesn't intercept them. Pattern: extract logic to `app/utils/` with no Nuxt deps, test directly. CONFIRMED.
|
||||||
|
- **Skip unit tests for simple page components**: Ionic component registration + Supabase init failures make `mountSuspended` too brittle. Cover with Playwright E2E instead. CONFIRMED.
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `app/utils/auth.ts` | Created | `checkAuthRedirect` pure function; `PUBLIC_ROUTES` constant |
|
||||||
|
| `app/middleware/auth.ts` | Modified | Now delegates to `checkAuthRedirect` |
|
||||||
|
| `tests/unit/auth-middleware.test.ts` | Rewritten | Tests pure function; 7/7 passing |
|
||||||
|
| `tests/unit/auth-callback.test.ts` | Deleted | Low-value component test |
|
||||||
|
| `docs/summaries/plan-bab-backend-ansible-rewrite.md` | Created | Full rewrite plan with implementation sequence |
|
||||||
|
| `docs/context/sdlc-architecture.md` | Updated | Dev URL, nginx webroot, Gitea URL, artifact token path |
|
||||||
|
| `/home/ptoal/Dev/ExecutionEnvironments/ee-demo/execution-environment.yml` | Modified | Added `postgresql` RPM, `supabase` CLI build step |
|
||||||
|
| `/home/ptoal/Dev/ExecutionEnvironments/ee-demo/requirements.yml` | Modified | Added `amazon.aws >= 9.0.0` |
|
||||||
|
| `/home/ptoal/Dev/ExecutionEnvironments/ee-demo/requirements.txt` | Modified | Added `boto3`, `botocore` |
|
||||||
|
| `/home/ptoal/Dev/ExecutionEnvironments/ee-demo/build.sh` | Modified | Added `SUPABASE_VERSION` guard; user added `--redhat` flag |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
|
||||||
|
**If continuing oysqn.app frontend:**
|
||||||
|
1. Set up Playwright — `yarn add -D @playwright/test`, create `tests/e2e/`, write login flow E2E test
|
||||||
|
2. Create `.gitea/workflows/build.yaml` following bab-app pattern (semantic-release + artifact)
|
||||||
|
|
||||||
|
**If starting bab-backend-ansible rewrite:**
|
||||||
|
1. Read `docs/summaries/plan-bab-backend-ansible-rewrite.md` — full scope and implementation sequence
|
||||||
|
2. Start with step 3: `sync_gitea_secrets.yml` (lowest risk, standalone)
|
||||||
|
3. Note: work in `/home/ptoal/Dev/Projects/bab-backend-ansible`, not oysqn.app
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
|
||||||
|
- [ ] **`kv/oys/dev/supabase/postgres_url`** — not in Vault; needed before `migrate_supabase.yml` can run rollback SQL via `psql` against dev. Add to Vault before first dev migration run.
|
||||||
|
|
||||||
|
## Confirmed Infrastructure Values
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|-------|
|
||||||
|
| Dev URL | `https://bab.toal.ca` |
|
||||||
|
| nginx webroot (bab1) | `/usr/share/nginx/html/` |
|
||||||
|
| Gitea URL | `https://gitea.toal.ca/` |
|
||||||
|
| Gitea artifact token | `kv/oys/bab_gitea` |
|
||||||
|
| Backup path (bab1) | `/var/backups/oysqn/` (assumed — confirm before first prod backup) |
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
|
||||||
|
- **Frontend session:** `docs/summaries/handoff-2026-04-12-splash-and-login.md` (prior UI work context)
|
||||||
|
- **Backend session:** `docs/summaries/plan-bab-backend-ansible-rewrite.md`; `docs/context/sdlc-architecture.md`
|
||||||
66
docs/summaries/handoff-2026-04-19-playwright-e2e-setup.md
Normal file
66
docs/summaries/handoff-2026-04-19-playwright-e2e-setup.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Session Handoff: Playwright E2E Setup
|
||||||
|
**Date:** 2026-04-19
|
||||||
|
**Session Focus:** Set up Playwright E2E testing; write and pass auth flow tests
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
1. **Installed `@playwright/test` 1.59.1** + Chromium headless shell binary
|
||||||
|
2. **Created `playwright.config.ts`** — Pixel 5 viewport, `reuseExistingServer` for local dev, `github` reporter in CI, 30s timeout, 1 worker (serial)
|
||||||
|
3. **Created `tests/e2e/helpers/mailpit.ts`** — `deleteAllMail()` and `getMagicLink(email)` against Mailpit at `http://127.0.0.1:54324`
|
||||||
|
4. **Created `tests/e2e/auth.spec.ts`** — 2 tests: full magic link flow + protected route redirect
|
||||||
|
5. **Fixed `supabase/config.toml`** — `site_url` changed from `http://127.0.0.1:3000` to `http://localhost:3000`; `additional_redirect_urls` updated to include `http://localhost:3000/auth/callback` and `http://127.0.0.1:3000/auth/callback`
|
||||||
|
6. **Added scripts to `package.json`**: `test:e2e`, `test:e2e:ui`, `test:e2e:headed`
|
||||||
|
7. **Both tests pass: 2/2**
|
||||||
|
|
||||||
|
## Key Debugging Discoveries
|
||||||
|
|
||||||
|
| Problem | Root Cause | Fix |
|
||||||
|
|---------|-----------|-----|
|
||||||
|
| `getByRole('button', { name: 'Log In' })` not found | `IonButton` with `router-link` renders as `<a>` (role=`link`) | Changed to `getByRole('link', { name: 'Log In' })` |
|
||||||
|
| `getByLabel('Email address')` not found | `IonLabel` is not associated via `for`/`id` with `IonInput` | Changed to `getByPlaceholder('you@example.com')` |
|
||||||
|
| `page.goto(magicLink)` → `ERR_CONNECTION_REFUSED` to `127.0.0.1:54321` | Playwright's Chromium headless shell cannot reach Supabase local auth server directly | Use Node.js `fetch(magicLink, { redirect: 'manual' })` to follow redirect server-side; navigate browser to the resulting app callback URL |
|
||||||
|
| Callback URL was `/?code=` instead of `/auth/callback?code=` | `emailRedirectTo` not whitelisted in `supabase/config.toml`; Supabase fell back to `site_url` | Fixed `config.toml` redirect URLs; required `supabase stop && supabase start` |
|
||||||
|
|
||||||
|
## Ionic-Specific Playwright Patterns (confirmed working)
|
||||||
|
|
||||||
|
- `IonButton` with `router-link` → use `getByRole('link', { name: '...' })`
|
||||||
|
- `IonButton` without `router-link` → use `getByRole('button', { name: '...' })`
|
||||||
|
- `IonInput` → use `getByPlaceholder(...)` (label association not standard HTML)
|
||||||
|
- Never navigate browser directly to Supabase local auth URL — follow redirect server-side first
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `playwright.config.ts` | Created |
|
||||||
|
| `tests/e2e/helpers/mailpit.ts` | Created |
|
||||||
|
| `tests/e2e/auth.spec.ts` | Created |
|
||||||
|
| `supabase/config.toml` | Modified — site_url and redirect URLs |
|
||||||
|
| `package.json` | Modified — added test:e2e scripts |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
|
||||||
|
**Option A — CI pipeline:**
|
||||||
|
1. Create `.gitea/workflows/build.yaml` — unit tests + build + E2E (read bab-app pipeline as reference)
|
||||||
|
2. Note: E2E in CI requires local Supabase running in the pipeline — confirm if feasible or skip E2E in CI for now
|
||||||
|
|
||||||
|
**Option B — Home page content:**
|
||||||
|
1. Implement authenticated home content in `app/pages/index.vue` (currently "Welcome to OYS Borrow a Boat" placeholder)
|
||||||
|
2. Likely a boat list or dashboard — check `docs/planning/` for persona requirements
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- [ ] Should E2E tests run in CI (requires Supabase in pipeline) or local-only? — OPEN
|
||||||
|
- [ ] What is the authenticated home content? Boat list, dashboard, or something else? — check planning docs
|
||||||
|
|
||||||
|
## Dev Environment Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required before running E2E:
|
||||||
|
DOCKER_HOST=unix:///run/user/1000/podman/podman.sock npx supabase start
|
||||||
|
|
||||||
|
# Run E2E:
|
||||||
|
yarn test:e2e # headless
|
||||||
|
yarn test:e2e:headed # visible browser
|
||||||
|
yarn test:e2e:ui # Playwright UI mode
|
||||||
|
```
|
||||||
@@ -11,7 +11,10 @@
|
|||||||
"typecheck": "nuxt typecheck",
|
"typecheck": "nuxt typecheck",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts"
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:headed": "playwright test --headed"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
@@ -33,6 +36,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/test-utils": "^4.0.2",
|
"@nuxt/test-utils": "^4.0.2",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@vite-pwa/nuxt": "^1.1.1",
|
"@vite-pwa/nuxt": "^1.1.1",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"happy-dom": "^20.8.9",
|
"happy-dom": "^20.8.9",
|
||||||
|
|||||||
90
playwright-report/index.html
Normal file
90
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
25
playwright.config.ts
Normal file
25
playwright.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: process.env.CI ? 'github' : 'html',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'mobile-chrome',
|
||||||
|
use: { ...devices['Pixel 5'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'yarn dev',
|
||||||
|
url: 'http://localhost:3000',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -151,9 +151,9 @@ max_indexes = 5
|
|||||||
enabled = true
|
enabled = true
|
||||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||||
# in emails.
|
# in emails.
|
||||||
site_url = "http://127.0.0.1:3000"
|
site_url = "http://localhost:3000"
|
||||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
additional_redirect_urls = ["http://localhost:3000/auth/callback", "http://127.0.0.1:3000/auth/callback"]
|
||||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||||
jwt_expiry = 3600
|
jwt_expiry = 3600
|
||||||
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/auth/v1).
|
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/auth/v1).
|
||||||
|
|||||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
46
tests/e2e/auth.spec.ts
Normal file
46
tests/e2e/auth.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { deleteAllMail, getMagicLink } from './helpers/mailpit'
|
||||||
|
|
||||||
|
const TEST_EMAIL = 'e2e-test@example.com'
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await deleteAllMail()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Auth flow', () => {
|
||||||
|
test('splash → login → magic link → home', async ({ page }) => {
|
||||||
|
// Splash page shows logo and login button
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByRole('img', { name: 'OYS Borrow a Boat' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: 'Log In' })).toBeVisible()
|
||||||
|
|
||||||
|
// Navigate to login page
|
||||||
|
await page.getByRole('link', { name: 'Log In' }).click()
|
||||||
|
await expect(page).toHaveURL('/login')
|
||||||
|
await expect(page.getByText('Sign In')).toBeVisible()
|
||||||
|
|
||||||
|
// Submit email for magic link
|
||||||
|
await page.getByPlaceholder('you@example.com').fill(TEST_EMAIL)
|
||||||
|
await page.getByRole('button', { name: 'Send Sign-In Link' }).click()
|
||||||
|
await expect(page.getByText('Check your email')).toBeVisible()
|
||||||
|
|
||||||
|
// Fetch magic link from Mailpit, then follow the Supabase verify redirect
|
||||||
|
// server-side (Node.js fetch) to get our app's callback URL.
|
||||||
|
// Playwright's headless shell can't reach 127.0.0.1:54321 directly.
|
||||||
|
const magicLink = await getMagicLink(TEST_EMAIL)
|
||||||
|
const redirectRes = await fetch(magicLink, { redirect: 'manual' })
|
||||||
|
const callbackUrl = redirectRes.headers.get('location')
|
||||||
|
if (!callbackUrl) throw new Error('Supabase did not redirect to app callback')
|
||||||
|
await page.goto(callbackUrl)
|
||||||
|
|
||||||
|
// Auth callback redirects to home (authenticated state)
|
||||||
|
await expect(page).toHaveURL('/')
|
||||||
|
await expect(page.getByText('Welcome to OYS Borrow a Boat')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unauthenticated access to protected route redirects to splash', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard')
|
||||||
|
await expect(page).toHaveURL('/')
|
||||||
|
await expect(page.getByRole('link', { name: 'Log In' })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
54
tests/e2e/helpers/mailpit.ts
Normal file
54
tests/e2e/helpers/mailpit.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
const MAILPIT_URL = 'http://127.0.0.1:54324'
|
||||||
|
|
||||||
|
interface MailpitAddress {
|
||||||
|
Address: string
|
||||||
|
Name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailpitMessage {
|
||||||
|
ID: string
|
||||||
|
To: MailpitAddress[]
|
||||||
|
Subject: string
|
||||||
|
Date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailpitListResponse {
|
||||||
|
messages: MailpitMessage[] | null
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailpitMessageDetail {
|
||||||
|
Text: string
|
||||||
|
HTML: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllMail(): Promise<void> {
|
||||||
|
await fetch(`${MAILPIT_URL}/api/v1/messages`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMagicLink(toEmail: string, timeoutMs = 15_000): Promise<string> {
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const res = await fetch(`${MAILPIT_URL}/api/v1/messages`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data: MailpitListResponse = await res.json()
|
||||||
|
const msg = (data.messages ?? []).find(m =>
|
||||||
|
m.To.some(t => t.Address === toEmail),
|
||||||
|
)
|
||||||
|
if (msg) {
|
||||||
|
const detail: MailpitMessageDetail = await fetch(
|
||||||
|
`${MAILPIT_URL}/api/v1/message/${msg.ID}`,
|
||||||
|
).then(r => r.json())
|
||||||
|
|
||||||
|
const body = detail.Text || detail.HTML
|
||||||
|
// Supabase local auth confirmation URL
|
||||||
|
const match = body.match(/https?:\/\/127\.0\.0\.1:54321\/auth\/v1\/verify\?[^\s"<]+/)
|
||||||
|
if (match) return match[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 500))
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`No magic link email for ${toEmail} within ${timeoutMs}ms`)
|
||||||
|
}
|
||||||
52
yarn.lock
52
yarn.lock
@@ -2134,6 +2134,13 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
|
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
|
||||||
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
|
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
|
||||||
|
|
||||||
|
"@playwright/test@^1.59.1":
|
||||||
|
version "1.59.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6"
|
||||||
|
integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==
|
||||||
|
dependencies:
|
||||||
|
playwright "1.59.1"
|
||||||
|
|
||||||
"@polka/url@^1.0.0-next.24":
|
"@polka/url@^1.0.0-next.24":
|
||||||
version "1.0.0-next.29"
|
version "1.0.0-next.29"
|
||||||
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
|
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
|
||||||
@@ -3330,10 +3337,10 @@ acorn@^8.15.0, acorn@^8.16.0, acorn@^8.6.0:
|
|||||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
|
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
|
||||||
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
||||||
|
|
||||||
agent-base@8.0.0:
|
agent-base@9.0.0:
|
||||||
version "8.0.0"
|
version "9.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-8.0.0.tgz#3eb7c2c198ccb93368c77969530594e725b804b5"
|
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-9.0.0.tgz#ec9efb08314e1e75b0852d74aabf9a387f99834e"
|
||||||
integrity sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==
|
integrity sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==
|
||||||
|
|
||||||
agent-base@^7.1.0, agent-base@^7.1.2:
|
agent-base@^7.1.0, agent-base@^7.1.2:
|
||||||
version "7.1.4"
|
version "7.1.4"
|
||||||
@@ -5033,6 +5040,11 @@ fs.realpath@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||||
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
version "2.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
|
||||||
|
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
||||||
|
|
||||||
fsevents@~2.3.2, fsevents@~2.3.3:
|
fsevents@~2.3.2, fsevents@~2.3.3:
|
||||||
version "2.3.3"
|
version "2.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||||
@@ -5412,12 +5424,12 @@ https-proxy-agent@^7.0.5, https-proxy-agent@^7.0.6:
|
|||||||
agent-base "^7.1.2"
|
agent-base "^7.1.2"
|
||||||
debug "4"
|
debug "4"
|
||||||
|
|
||||||
https-proxy-agent@^8.0.0:
|
https-proxy-agent@^9.0.0:
|
||||||
version "8.0.0"
|
version "9.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-8.0.0.tgz#2ab138d2706d473ede6d1f5e7158d7586d5ca738"
|
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz#89256adf9dc20926fe43ea1d3ca0feb9e23cc4bd"
|
||||||
integrity sha512-YYeW+iCnAS3xhvj2dvVoWgsbca3RfQy/IlaNHHOtDmU0jMqPI9euIq3Y9BJETdxk16h9NHHCKqp/KB9nIMStCQ==
|
integrity sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base "8.0.0"
|
agent-base "9.0.0"
|
||||||
debug "^4.3.4"
|
debug "^4.3.4"
|
||||||
|
|
||||||
httpxy@^0.3.1:
|
httpxy@^0.3.1:
|
||||||
@@ -7167,6 +7179,20 @@ pkg-types@^2.3.0:
|
|||||||
exsolve "^1.0.7"
|
exsolve "^1.0.7"
|
||||||
pathe "^2.0.3"
|
pathe "^2.0.3"
|
||||||
|
|
||||||
|
playwright-core@1.59.1:
|
||||||
|
version "1.59.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2"
|
||||||
|
integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==
|
||||||
|
|
||||||
|
playwright@1.59.1:
|
||||||
|
version "1.59.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a"
|
||||||
|
integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==
|
||||||
|
dependencies:
|
||||||
|
playwright-core "1.59.1"
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents "2.3.2"
|
||||||
|
|
||||||
plist@^3.1.0:
|
plist@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/plist/-/plist-3.1.0.tgz#797a516a93e62f5bde55e0b9cc9c967f860893c9"
|
resolved "https://registry.yarnpkg.com/plist/-/plist-3.1.0.tgz#797a516a93e62f5bde55e0b9cc9c967f860893c9"
|
||||||
@@ -8572,12 +8598,12 @@ stylehacks@^7.0.5:
|
|||||||
postcss-selector-parser "^7.1.1"
|
postcss-selector-parser "^7.1.1"
|
||||||
|
|
||||||
supabase@^2.84.4:
|
supabase@^2.84.4:
|
||||||
version "2.84.4"
|
version "2.92.1"
|
||||||
resolved "https://registry.yarnpkg.com/supabase/-/supabase-2.84.4.tgz#939a14c492fa90b9695352cf47e1cccb9b057cb1"
|
resolved "https://registry.yarnpkg.com/supabase/-/supabase-2.92.1.tgz#cd874c10cc9070215a075a060c7d66fa0a8f499a"
|
||||||
integrity sha512-+WSe/7FFMuEOa1LJr1tZh12WDwW6lpKSmBjiEmf7m9j/ialf2oxeUMlsJCdYpST5kQ7PN0XDyvqnjE0tv/AB2w==
|
integrity sha512-BB3olR2glhrE0YGDhq0vknJdrwjROaIHgiC/OZc94eLbBHnsJ3szKeRZkcF9dxRgxuq6QWdxCrn5m14lfu9tug==
|
||||||
dependencies:
|
dependencies:
|
||||||
bin-links "^6.0.0"
|
bin-links "^6.0.0"
|
||||||
https-proxy-agent "^8.0.0"
|
https-proxy-agent "^9.0.0"
|
||||||
node-fetch "^3.3.2"
|
node-fetch "^3.3.2"
|
||||||
tar "7.5.13"
|
tar "7.5.13"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user