Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,10 @@ logs

/prisma/generated
/dev.db

# Local test output
/coverage/
/playwright-report/
/test-results/
/.vitest/
/.nuxtrc
19 changes: 4 additions & 15 deletions app/pages/auth.vue
Original file line number Diff line number Diff line change
@@ -1,23 +1,12 @@
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
import { authClient } from '../utils/auth-client'
import { createAuthFormSchema } from '../utils/auth-form'

const toast = useToast()
const isEmailSent = ref(false)

const schema = computed(() => {
if (!isEmailSent.value) {
return z.object({
email: z.string().email('Invalid email'),
})
} else {
return z.object({
email: z.string().email('Invalid email'),
otp: z.array(z.string()).length(6, 'Must be 6 digits'),
})
}
})
const schema = computed(() => createAuthFormSchema(isEmailSent.value))

const state = reactive({
email: '',
Expand Down Expand Up @@ -60,11 +49,11 @@
</template>

<UForm :schema="schema" :state="state" @submit="handleSubmit" class="space-y-5">
<UFormField name="email" v-if="!isEmailSent">
<UFormField label="Email" name="email" v-if="!isEmailSent">
<UInput v-model="state.email" class="w-full" placeholder="Email" />
</UFormField>

<UFormField name="otp" v-if="isEmailSent">
<UFormField label="One-time password" name="otp" v-if="isEmailSent">
<UPinInput
otp
v-model="state.otp"
Expand Down
14 changes: 14 additions & 0 deletions app/utils/auth-form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { z } from 'zod'

const email = z.string().email('Invalid email')

export function createAuthFormSchema(isEmailSent: boolean) {
if (!isEmailSent) {
return z.object({ email })
}

return z.object({
email,
otp: z.array(z.string()).length(6, 'Must be 6 digits'),
})
}
58 changes: 58 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Testing proof of concept

This proof of concept evaluates Vitest with Nuxt Test Utils for fast auth-form validation
checks and Playwright for browser-level behavior on the public `/auth` screen.

## Setup and commands

Install repository dependencies, then install the Playwright-managed Chromium build:

```bash
corepack pnpm install
corepack pnpm exec playwright install chromium
```

The test commands are independent:

```bash
corepack pnpm test:unit
corepack pnpm test:unit:watch
corepack pnpm test:e2e
corepack pnpm test:e2e:ui
corepack pnpm test:e2e:debug
corepack pnpm test:check
```

`test:check` is the combined local check. The Playwright command starts a minimal local Nuxt
fixture on port 3100 that mounts the repository's real auth page without loading production
server routes or middleware. Browser routing intercepts every `/api/auth/**` request used by
the scenarios, so the tests use no database, email service, customer data, or external auth
provider. Test addresses use the reserved `example.test` domain.

## Coverage split

Vitest covers the stable schema decisions behind the auth screen: valid and malformed email
addresses, the pre-OTP phase, and complete versus incomplete six-position OTP input.
Playwright contains exactly two scenarios: a valid email submission that reaches the OTP
step, and a malformed email that remains local and sends no OTP request. Browser locators
use visible roles, labels, and text.

## Repeatability, artifacts, and limitations

The unit and browser suites are intentionally small and deterministic for repeat local runs.
On the PoC workstation, two consecutive four-test Vitest runs passed in 8.19 and 8.09
seconds. Two consecutive two-test Playwright runs passed in 24.8 and 21.0 seconds including
local server startup; the warmer repeat was faster. Playwright retains a trace and screenshot
only when a test fails; its report and all test artifacts are ignored by Git. Vitest coverage
output is also ignored.

This PoC does not test real OTP delivery, database-backed session behavior, successful OTP
verification, or protected routes. Those integrations need a separate disposable test
environment with explicit provider and database isolation.

## Recommendation

**Adopt with revision.** Keep Vitest for deterministic validation and composable logic, and
Playwright for a small set of critical browser flows. Before expanding coverage, add a
shared typed auth mock and a deliberately provisioned disposable integration environment;
do not point either suite at production services or externally supplied databases.
14 changes: 13 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@
"name": "epics-template",
"type": "module",
"private": true,
"packageManager": "pnpm@10.28.2",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:check": "corepack pnpm test:unit && corepack pnpm test:e2e",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
Expand All @@ -28,13 +35,18 @@
},
"devDependencies": {
"@better-auth/drizzle-adapter": "^1.6.23",
"@nuxt/test-utils": "^4.1.0",
"@playwright/test": "^1.62.0",
"@types/better-sqlite3": "^7.6.13",
"@vue/compiler-sfc": "3.5.39",
"better-sqlite3": "12.11.1",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
"drizzle-zod": "^0.8.3",
"happy-dom": "^20.11.1",
"prettier": "3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"tsx": "^4.21.0"
"tsx": "^4.21.0",
"vitest": "^4.1.10"
}
}
25 changes: 25 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
workers: 1,
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
use: {
baseURL: 'http://127.0.0.1:3100',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'corepack pnpm exec nuxt dev tests/fixtures/e2e-app --host 127.0.0.1 --port 3100',
url: 'http://127.0.0.1:3100/auth',
reuseExistingServer: false,
timeout: 120_000,
},
})
Loading