fix: login match design reference
This commit is contained in:
1 parent
001f9f4774
commit
f1e22f7073
16 files changed
+1183
-57
No files matched your search
@@ -13,6 +13,8 @@
|
||||
"vue-router": "^4.6.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.136",
|
||||
"@iconify/utils": "^3.1.7",
|
||||
"@tsconfig/node24": "^24.0.3",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
@@ -25,6 +27,7 @@
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "25",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"playwright": "^1.63.0",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.2.4",
|
||||
@@ -199,6 +202,8 @@
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@iconify-json/lucide": ["@iconify-json/lucide@1.2.136", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-Wk+OeDVKkw+1LELZqRR3gXWIy5jNx5XjNMvVc9KoygJYmGqclv36FYqseRuJuxBIKDggdoZWNBxNRrOkiRbtwQ=="],
|
||||
|
||||
"@iconify/collections": ["@iconify/collections@1.0.741", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-AUzucHF84kVFMzUQUgBsjmoy53U2ecvDZksFRsEOjfSBvJ5Hy+j8GeJ3dDSvBzNgLZ5BSxR6Zvh38VwrHdFbeg=="],
|
||||
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
@@ -1015,6 +1020,10 @@
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="],
|
||||
|
||||
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.136",
|
||||
"@iconify/utils": "^3.1.7",
|
||||
"@tsconfig/node24": "^24.0.3",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
@@ -36,6 +38,7 @@
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "25",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"playwright": "^1.63.0",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.2.4",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
}>(),
|
||||
{ size: 32 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="RenewCMS"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 16 16"
|
||||
shape-rendering="crispEdges"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
width="16"
|
||||
height="16"
|
||||
fill="#111111"
|
||||
class="dark:stroke-1 dark:stroke-(--color-chalk-600)"
|
||||
/>
|
||||
<path
|
||||
fill="#ffffff"
|
||||
fill-rule="evenodd"
|
||||
d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useSlots, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
@@ -7,6 +8,41 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
const titleEl = ref<HTMLHeadingElement | null>(null)
|
||||
const belowSm = ref(false)
|
||||
let mediaQuery: MediaQueryList | null = null
|
||||
|
||||
function updateBelowSm() {
|
||||
belowSm.value = mediaQuery?.matches ?? false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
mediaQuery = window.matchMedia('(max-width: 39.9975rem)')
|
||||
updateBelowSm()
|
||||
mediaQuery.addEventListener('change', updateBelowSm)
|
||||
}
|
||||
titleEl.value?.focus()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaQuery?.removeEventListener('change', updateBelowSm)
|
||||
})
|
||||
|
||||
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
|
||||
|
||||
const mainClass = computed(() => [
|
||||
'flex min-h-screen flex-col items-center pt-[12vh]',
|
||||
belowSm.value ? 'bg-default px-5' : 'bg-muted px-0',
|
||||
])
|
||||
|
||||
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
|
||||
|
||||
const cardUi = computed(() => ({
|
||||
root: belowSm.value ? 'ring-0' : '',
|
||||
container: belowSm.value ? 'p-5' : '',
|
||||
header: 'mb-6',
|
||||
}))
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
@@ -15,26 +51,42 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
() => {
|
||||
titleEl.value?.focus()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex min-h-screen flex-col items-center bg-default px-5 pt-[12vh] sm:px-0">
|
||||
<UAlert v-if="slots.notice" class="mb-4 w-full sm:max-w-[400px]">
|
||||
<main :class="mainClass">
|
||||
<UAlert v-if="slots.notice" :class="['mb-4', cardClass]">
|
||||
<template #description>
|
||||
<slot name="notice" />
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UPageCard variant="outline" class="w-full sm:max-w-[400px]">
|
||||
<span role="img" aria-label="RenewCMS" class="i-lucide-square inline-block h-8 w-8" />
|
||||
<UPageCard
|
||||
variant="outline"
|
||||
:description="description"
|
||||
:ui="cardUi"
|
||||
data-testid="auth-card"
|
||||
:class="cardClass"
|
||||
>
|
||||
<template #header>
|
||||
<AppLogo :size="32" />
|
||||
</template>
|
||||
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="description">{{ description }}</p>
|
||||
<template #title>
|
||||
<h1 ref="titleEl" tabindex="-1">{{ title }}</h1>
|
||||
</template>
|
||||
|
||||
<slot />
|
||||
<slot :control-size="controlSize" />
|
||||
</UPageCard>
|
||||
|
||||
<footer class="mt-4 flex w-full items-center justify-between sm:max-w-[400px]">
|
||||
<footer :class="['mt-4 flex w-full items-center justify-between', cardClass]">
|
||||
<slot name="footer" />
|
||||
<UColorModeSelect size="sm" />
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AppLogo from '../AppLogo.vue'
|
||||
|
||||
describe('AppLogo renders the brand mark as inline SVG, and theme-switches', () => {
|
||||
it('renders an inline svg at 32px by default, not an img tag', () => {
|
||||
const wrapper = mount(AppLogo)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.exists()).toBe(true)
|
||||
expect(svg.attributes('width')).toBe('32')
|
||||
expect(svg.attributes('height')).toBe('32')
|
||||
})
|
||||
|
||||
it('carries alt text through role=img and aria-label, and is not wrapped in a link', () => {
|
||||
const wrapper = mount(AppLogo)
|
||||
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.attributes('role')).toBe('img')
|
||||
expect(svg.attributes('aria-label')).toBe('RenewCMS')
|
||||
expect(wrapper.find('a').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('sizes the svg through the size prop', () => {
|
||||
const wrapper = mount(AppLogo, { props: { size: 48 } })
|
||||
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.attributes('width')).toBe('48')
|
||||
expect(svg.attributes('height')).toBe('48')
|
||||
})
|
||||
|
||||
it('has no stroke by default, and a dark-mode 1px chalk-600 edge on the black square', () => {
|
||||
const wrapper = mount(AppLogo)
|
||||
|
||||
const square = wrapper.find('rect')
|
||||
expect(square.attributes('fill')).toBe('#111111')
|
||||
expect(square.classes()).toContain('dark:stroke-(--color-chalk-600)')
|
||||
expect(square.classes()).toContain('dark:stroke-1')
|
||||
expect(square.attributes('stroke')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,9 @@ describe('AuthShell renders its title as the page heading and sets the document
|
||||
it('renders an h1 containing the title and sets document.title', () => {
|
||||
const wrapper = mountShell({ title: 'Log in' })
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('Log in')
|
||||
const heading = wrapper.find('h1')
|
||||
expect(heading.text()).toBe('Log in')
|
||||
expect(heading.attributes('tabindex')).toBe('-1')
|
||||
expect(document.title).toBe('Log in · RenewCMS')
|
||||
})
|
||||
})
|
||||
@@ -36,7 +38,7 @@ describe('AuthShell renders its notice and footer slots', () => {
|
||||
expect(alertDescription.text()).toBe('Your session expired.')
|
||||
|
||||
const html = wrapper.html()
|
||||
expect(html.indexOf('Your session expired.')).toBeLessThan(html.indexOf('<h1>'))
|
||||
expect(html.indexOf('Your session expired.')).toBeLessThan(html.indexOf('<h1'))
|
||||
|
||||
const footerLink = wrapper.find('a[href="/register/reset/request"]')
|
||||
expect(footerLink.exists()).toBe(true)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
|
||||
function stubMatchMedia(belowSm: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: belowSm,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia
|
||||
}
|
||||
|
||||
function mockResponse(status: number, body: unknown = null) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
async function mountLogin() {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
|
||||
await router.push('/login')
|
||||
const wrapper = mount(LoginView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('AppLogo renders the brand mark as inline SVG, and theme-switches', () => {
|
||||
it("renders as UPageCard's first element, inside the card, left-aligned, at 32px, not as an img, alt text, not a link", () => {
|
||||
const wrapper = mount(AuthShell, {
|
||||
props: { title: 'Log in', description: 'Welcome back.' },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.exists()).toBe(true)
|
||||
expect(svg.attributes('width')).toBe('32')
|
||||
|
||||
expect(card.element.contains(svg.element)).toBe(true)
|
||||
|
||||
const html = wrapper.html()
|
||||
expect(html.indexOf('<svg')).toBeGreaterThan(html.indexOf('data-testid="auth-card"'))
|
||||
expect(html.indexOf('<svg')).toBeLessThan(html.indexOf('<h1'))
|
||||
|
||||
let centeringAncestor = svg.element.parentElement
|
||||
while (centeringAncestor && centeringAncestor !== card.element) {
|
||||
expect(centeringAncestor.className).not.toMatch(
|
||||
/items-center|justify-center|text-center/,
|
||||
)
|
||||
centeringAncestor = centeringAncestor.parentElement
|
||||
}
|
||||
|
||||
const header = wrapper.find('[data-slot="header"]')
|
||||
expect(header.exists()).toBe(true)
|
||||
expect(header.classes()).toContain('mb-6')
|
||||
expect(header.classes()).not.toContain('mb-4')
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(svg.attributes('role')).toBe('img')
|
||||
expect(svg.attributes('aria-label')).toBe('RenewCMS')
|
||||
|
||||
expect(svg.element.closest('a')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("UPageCard's title is the page h1, and its description uses UPageCard's own styling", () => {
|
||||
it('renders variant outline, the title through the title slot as h1, and the description through the prop', () => {
|
||||
const wrapper = mount(AuthShell, {
|
||||
props: {
|
||||
title: 'Log in',
|
||||
description: 'Welcome back. Log in to manage your articles.',
|
||||
},
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
expect(card.exists()).toBe(true)
|
||||
|
||||
const heading = wrapper.find('h1')
|
||||
expect(heading.text()).toBe('Log in')
|
||||
expect(heading.attributes('tabindex')).toBe('-1')
|
||||
|
||||
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
|
||||
expect(wrapper.findAll('p').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The page sits on the muted background from sm up, and the card keeps its border', () => {
|
||||
it('uses the muted background token, keeps the card ring, capped width and 12vh top offset', () => {
|
||||
stubMatchMedia(false)
|
||||
const wrapper = mount(AuthShell, {
|
||||
props: { title: 'Log in', description: 'Welcome back.' },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
const main = wrapper.find('main')
|
||||
expect(main.classes()).toContain('bg-muted')
|
||||
expect(main.classes()).not.toContain('bg-default')
|
||||
expect(main.classes()).toContain('pt-[12vh]')
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
expect(card.classes()).toContain('max-w-[400px]')
|
||||
expect(card.classes().some((c) => c === 'ring-0')).toBe(false)
|
||||
|
||||
const container = wrapper.find('[data-slot="container"]')
|
||||
expect(container.classes()).not.toContain('p-5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Below sm, AuthShell drops the muted background, the card border and the card padding', () => {
|
||||
it('uses the default background, an unbordered full-width card with 20px padding, and xl controls', async () => {
|
||||
stubMatchMedia(true)
|
||||
const wrapper = mount(AuthShell, {
|
||||
props: { title: 'Log in', description: 'Welcome back.' },
|
||||
slots: {
|
||||
default: `<template #default="{ controlSize }"><button :data-size="controlSize">Submit</button></template>`,
|
||||
},
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const main = wrapper.find('main')
|
||||
expect(main.classes()).toContain('bg-default')
|
||||
expect(main.classes()).not.toContain('bg-muted')
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
expect(card.classes()).not.toContain('max-w-[400px]')
|
||||
expect(card.classes()).toContain('ring-0')
|
||||
|
||||
const button = wrapper.find('button')
|
||||
expect(button.attributes('data-size')).toBe('xl')
|
||||
|
||||
const container = wrapper.find('[data-slot="container"]')
|
||||
expect(container.classes()).toContain('p-5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The login form is a UForm', () => {
|
||||
it('wraps the Username field, the Password field and the submit button in a UForm, not a plain form', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const form = wrapper.find('form')
|
||||
expect(form.exists()).toBe(true)
|
||||
expect(form.attributes('id')).toMatch(/^v-/)
|
||||
expect(form.find('input[placeholder="Your username"]').exists()).toBe(true)
|
||||
expect(form.find('input[autocomplete="current-password"]').exists()).toBe(true)
|
||||
expect(form.find('button[type="submit"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('"Forgot password?" renders in the Password field\'s hint slot', () => {
|
||||
it('renders the link through the hint slot, and the label reads only Password', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
const fieldRoot = passwordInput.element.closest('[data-slot="root"][data-orientation]')
|
||||
expect(fieldRoot).toBeTruthy()
|
||||
|
||||
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
|
||||
expect(hint).toBeTruthy()
|
||||
expect(hint!.textContent?.trim()).toBe('Forgot password?')
|
||||
expect(hint!.querySelector('a[href="/register/reset/request"]')).toBeTruthy()
|
||||
|
||||
const label = fieldRoot!.querySelector('[data-slot="label"]')
|
||||
expect(label!.textContent?.trim()).toBe('Password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Inputs are neutral by default and turn to the error edge only when their field has an error', () => {
|
||||
it('starts neutral, then turns to the error edge with the field messages on invalid submit', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
expect(usernameInput.classes()).toContain('ring-accented')
|
||||
expect(usernameInput.classes()).not.toContain('ring-error')
|
||||
expect(passwordInput.classes()).toContain('ring-accented')
|
||||
expect(passwordInput.classes()).not.toContain('ring-error')
|
||||
|
||||
await usernameInput.setValue('ma')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const usernameAfter = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordAfter = wrapper.find('input[autocomplete="current-password"]')
|
||||
expect(usernameAfter.classes()).toContain('ring-error')
|
||||
expect(usernameAfter.classes()).not.toContain('ring-accented')
|
||||
expect(passwordAfter.classes()).toContain('ring-error')
|
||||
expect(passwordAfter.classes()).not.toContain('ring-accented')
|
||||
|
||||
expect(wrapper.text()).toContain('Use at least 3 characters.')
|
||||
expect(wrapper.text()).toContain('Enter your password.')
|
||||
})
|
||||
})
|
||||
+67
-45
@@ -68,7 +68,6 @@ const usernameError = ref('')
|
||||
const passwordError = ref('')
|
||||
const submitting = ref(false)
|
||||
const passwordShown = ref(false)
|
||||
const formEl = ref<HTMLFormElement | null>(null)
|
||||
|
||||
const initialReason = typeof route.query.reason === 'string' ? route.query.reason : null
|
||||
|
||||
@@ -94,10 +93,10 @@ const submitLabel = computed(() => {
|
||||
|
||||
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
|
||||
|
||||
function focusField(selector: string) {
|
||||
nextTick(() => {
|
||||
formEl.value?.querySelector<HTMLInputElement>(selector)?.focus()
|
||||
})
|
||||
async function focusField(selector: string) {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
document.querySelector<HTMLInputElement>(selector)?.focus()
|
||||
}
|
||||
|
||||
function focusPassword() {
|
||||
@@ -212,52 +211,75 @@ onMounted(async () => {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<form ref="formEl" class="flex flex-col gap-4" @submit.prevent="onSubmit">
|
||||
<UFormField label="Username" name="username" :error="usernameError">
|
||||
<UInput
|
||||
v-model="username"
|
||||
placeholder="Your username"
|
||||
autocomplete="username"
|
||||
:disabled="submitting"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<template #default="{ controlSize }">
|
||||
<UForm :state="{ username, password }" class="flex flex-col gap-4" @submit="onSubmit">
|
||||
<UFormField
|
||||
label="Username"
|
||||
name="username"
|
||||
:error="usernameError || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<UInput
|
||||
v-model="username"
|
||||
placeholder="Your username"
|
||||
autocomplete="username"
|
||||
:disabled="submitting"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
:highlight="!!usernameError"
|
||||
:size="controlSize"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="password" :error="passwordError">
|
||||
<template #label>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<span>Password</span>
|
||||
<UFormField
|
||||
label="Password"
|
||||
name="password"
|
||||
:error="passwordError || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template #hint>
|
||||
<RouterLink to="/register/reset/request" class="text-primary text-sm">
|
||||
Forgot password?
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="password"
|
||||
:type="passwordShown ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
:disabled="submitting"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
<UInput
|
||||
v-model="password"
|
||||
:type="passwordShown ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
:disabled="submitting"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
:highlight="!!passwordError"
|
||||
:size="controlSize"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UButton type="submit" block :loading="submitting" :disabled="submitDisabled">
|
||||
{{ submitLabel }}
|
||||
</UButton>
|
||||
</form>
|
||||
<UButton
|
||||
type="submit"
|
||||
block
|
||||
:size="controlSize"
|
||||
:loading="submitting"
|
||||
:disabled="submitDisabled"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
|
||||
<template v-if="registrationOpen" #footer>
|
||||
<p>
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.visual.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": ["visual/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.visual.tsbuildinfo",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { extname, join, relative, resolve } from 'node:path'
|
||||
import { build, preview } from 'vite'
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
|
||||
import { icons as lucide } from '@iconify-json/lucide'
|
||||
import { getIconData, getIcons, iconToHTML, iconToSVG, replaceIDs } from '@iconify/utils'
|
||||
import { visualStates, type ApiMock, type VisualState } from './states'
|
||||
|
||||
type Viewport = 'desktop' | 'mobile'
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
interface Screen {
|
||||
name: string
|
||||
board: string
|
||||
group: string
|
||||
states: string[]
|
||||
}
|
||||
|
||||
const webRoot = resolve(import.meta.dirname, '..')
|
||||
const repoRoot = resolve(webRoot, '..')
|
||||
const designRoot = join(repoRoot, 'docs', 'design')
|
||||
const sourceRoot = join(designRoot, 'source')
|
||||
const outRoot = join(repoRoot, 'tmp', 'visual')
|
||||
const distRoot = join(outRoot, 'dist')
|
||||
const configFile = join(webRoot, 'vite.config.ts')
|
||||
|
||||
const frames: Record<Viewport, { width: number; height: number }> = {
|
||||
desktop: { width: 1440, height: 900 },
|
||||
mobile: { width: 390, height: 844 },
|
||||
}
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.json': 'application/json',
|
||||
}
|
||||
|
||||
const settleMs = 400
|
||||
const diffThreshold = 48
|
||||
|
||||
function slug(text: string) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const flags = new Map<string, string>()
|
||||
const positional: string[] = []
|
||||
for (const arg of argv) {
|
||||
const flag = /^--(\w+)=(.+)$/.exec(arg)
|
||||
if (flag) {
|
||||
flags.set(flag[1]!, flag[2]!)
|
||||
} else {
|
||||
positional.push(arg)
|
||||
}
|
||||
}
|
||||
const [screen, ...stateWords] = positional
|
||||
if (!screen) {
|
||||
throw new Error(
|
||||
'Usage: task visual:check -- <screen> [state] [--viewport=desktop|mobile] [--theme=light|dark]',
|
||||
)
|
||||
}
|
||||
const viewports = (flags.get('viewport')?.split(',') ?? ['desktop', 'mobile']) as Viewport[]
|
||||
const themes = (flags.get('theme')?.split(',') ?? ['light', 'dark']) as Theme[]
|
||||
return { screen, state: stateWords.join(' ') || undefined, viewports, themes }
|
||||
}
|
||||
|
||||
async function readScreen(name: string): Promise<Screen> {
|
||||
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
|
||||
const reference = /\*\*Visual reference:\*\* `source\/([^`]+)`, group "([^"]+)"/.exec(markdown)
|
||||
if (!reference) {
|
||||
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
|
||||
}
|
||||
const states = [...markdown.matchAll(/^### (.+)$/gm)].map((match) => match[1]!.trim())
|
||||
return { name, board: reference[1]!, group: reference[2]!, states }
|
||||
}
|
||||
|
||||
function pickStates(screen: Screen, wanted?: string) {
|
||||
if (!wanted) {
|
||||
return screen.states
|
||||
}
|
||||
const match = screen.states.find(
|
||||
(state) => state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted),
|
||||
)
|
||||
if (!match) {
|
||||
throw new Error(`Unknown state "${wanted}". Known: ${screen.states.join(', ')}`)
|
||||
}
|
||||
return [match]
|
||||
}
|
||||
|
||||
async function serveDesign() {
|
||||
const server = createServer(async (request, response) => {
|
||||
const pathname = decodeURIComponent(
|
||||
new URL(request.url ?? '/', 'http://localhost').pathname,
|
||||
)
|
||||
const file = join(sourceRoot, pathname)
|
||||
if (relative(sourceRoot, file).startsWith('..')) {
|
||||
response.writeHead(403).end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await readFile(file)
|
||||
response
|
||||
.writeHead(200, {
|
||||
'Content-Type': contentTypes[extname(file)] ?? 'application/octet-stream',
|
||||
})
|
||||
.end(body)
|
||||
} catch {
|
||||
response.writeHead(404).end()
|
||||
}
|
||||
})
|
||||
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done))
|
||||
const { port } = server.address() as AddressInfo
|
||||
return { origin: `http://127.0.0.1:${port}`, close: () => server.close() }
|
||||
}
|
||||
|
||||
async function serveApp() {
|
||||
await build({
|
||||
configFile,
|
||||
root: webRoot,
|
||||
logLevel: 'warn',
|
||||
build: { outDir: distRoot, emptyOutDir: true },
|
||||
})
|
||||
const server = await preview({
|
||||
configFile,
|
||||
root: webRoot,
|
||||
logLevel: 'warn',
|
||||
build: { outDir: distRoot },
|
||||
preview: { port: 4180, strictPort: false, open: false },
|
||||
})
|
||||
const origin = server.resolvedUrls?.local[0]?.replace(/\/$/, '')
|
||||
if (!origin) {
|
||||
throw new Error('Vite preview did not report a local URL')
|
||||
}
|
||||
return { origin, close: () => server.close() }
|
||||
}
|
||||
|
||||
const iconHosts = new Set(['api.iconify.design', 'api.simplesvg.com', 'api.unisvg.com'])
|
||||
|
||||
async function serveIconsLocally(context: BrowserContext) {
|
||||
await context.route(
|
||||
(url) => iconHosts.has(url.hostname),
|
||||
async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const svg = /^\/lucide\/([a-z0-9-]+)\.svg$/.exec(url.pathname)
|
||||
if (svg) {
|
||||
const data = getIconData(lucide, svg[1]!)
|
||||
if (!data) {
|
||||
await route.fulfill({ status: 404, body: '' })
|
||||
return
|
||||
}
|
||||
const { attributes, body } = iconToSVG(data)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/svg+xml',
|
||||
body: iconToHTML(replaceIDs(body), attributes),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/lucide.json') {
|
||||
const names = url.searchParams.get('icons')?.split(',') ?? []
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(
|
||||
getIcons(lucide, names) ?? { prefix: 'lucide', icons: {} },
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.fulfill({ status: 404, body: '' })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function openBoard(browser: Browser, origin: string, screen: Screen, theme: Theme) {
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } })
|
||||
await serveIconsLocally(context)
|
||||
const page = await context.newPage()
|
||||
await page.goto(`${origin}/${encodeURIComponent(screen.board)}`)
|
||||
await page.locator(`section[data-screen-label="${screen.group}"]`).waitFor()
|
||||
await page.getByRole('radio', { name: theme === 'dark' ? 'Dark' : 'Light' }).click()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
return page
|
||||
}
|
||||
|
||||
async function captureDesign(
|
||||
board: Page,
|
||||
screen: Screen,
|
||||
state: string,
|
||||
viewport: Viewport,
|
||||
path: string,
|
||||
) {
|
||||
const found = await board.evaluate(
|
||||
({ group, state, width, height }) => {
|
||||
document
|
||||
.querySelectorAll('[data-visual-check]')
|
||||
.forEach((element) => element.removeAttribute('data-visual-check'))
|
||||
const section = [...document.querySelectorAll('section[data-screen-label]')].find(
|
||||
(element) => element.getAttribute('data-screen-label') === group,
|
||||
)
|
||||
const title = section
|
||||
? [...section.querySelectorAll('span')].find(
|
||||
(span) => span.textContent?.trim() === state,
|
||||
)
|
||||
: undefined
|
||||
const block = title?.closest('div')?.parentElement
|
||||
const target = block
|
||||
? [...block.querySelectorAll<HTMLElement>('div')].find(
|
||||
(div) => div.style.width === `${width}px`,
|
||||
)
|
||||
: undefined
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
const container = target.parentElement!
|
||||
container.style.border = 'none'
|
||||
container.style.boxSizing = 'content-box'
|
||||
container.style.width = `${width}px`
|
||||
container.style.height = `${height}px`
|
||||
target.style.zoom = '1'
|
||||
target.dataset.visualCheck = 'frame'
|
||||
return true
|
||||
},
|
||||
{ group: screen.group, state, ...frames[viewport] },
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`State "${state}" not found in group "${screen.group}" of ${screen.board}`)
|
||||
}
|
||||
const frame = board.locator('[data-visual-check="frame"]')
|
||||
await frame.scrollIntoViewIfNeeded()
|
||||
await frame.evaluate(async (root) => {
|
||||
const urls = new Set<string>()
|
||||
for (const element of [root, ...root.querySelectorAll('*')]) {
|
||||
const mask = getComputedStyle(element).maskImage
|
||||
for (const match of mask.matchAll(/url\("([^"]+)"\)/g)) {
|
||||
urls.add(match[1]!)
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
[...urls].map(
|
||||
(url) =>
|
||||
new Promise<void>((done) => {
|
||||
const image = new Image()
|
||||
image.onload = () => done()
|
||||
image.onerror = () => done()
|
||||
image.src = url
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
await board.waitForTimeout(settleMs)
|
||||
await frame.screenshot({ path, animations: 'disabled' })
|
||||
}
|
||||
|
||||
function fulfilment(mock: ApiMock) {
|
||||
const text = typeof mock.body === 'string'
|
||||
return {
|
||||
status: mock.status,
|
||||
headers: mock.headers,
|
||||
contentType: text ? 'text/plain; charset=utf-8' : 'application/json',
|
||||
body:
|
||||
mock.body === undefined ? '' : text ? (mock.body as string) : JSON.stringify(mock.body),
|
||||
}
|
||||
}
|
||||
|
||||
async function captureApp(
|
||||
browser: Browser,
|
||||
origin: string,
|
||||
setup: VisualState,
|
||||
viewport: Viewport,
|
||||
theme: Theme,
|
||||
path: string,
|
||||
) {
|
||||
const { width, height } = frames[viewport]
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
colorScheme: theme,
|
||||
reducedMotion: 'reduce',
|
||||
})
|
||||
await serveIconsLocally(context)
|
||||
const unmocked = new Set<string>()
|
||||
await context.route('**/v1/**', async (route) => {
|
||||
const request = route.request()
|
||||
const key = `${request.method()} ${new URL(request.url()).pathname}`
|
||||
const mock = setup.api?.[key]
|
||||
if (!mock) {
|
||||
unmocked.add(key)
|
||||
await route.fulfill({ status: 404, body: '' })
|
||||
return
|
||||
}
|
||||
if (mock.hang) {
|
||||
return
|
||||
}
|
||||
await route.fulfill(fulfilment(mock))
|
||||
})
|
||||
const page = await context.newPage()
|
||||
await page.goto(`${origin}${setup.path}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await setup.steps?.(page)
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.waitForTimeout(settleMs)
|
||||
await page.screenshot({ path, animations: 'disabled' })
|
||||
await context.close()
|
||||
return [...unmocked]
|
||||
}
|
||||
|
||||
async function diff(browser: Browser, designPath: string, appPath: string, diffPath: string) {
|
||||
const [design, app] = await Promise.all([readFile(designPath), readFile(appPath)])
|
||||
const page = await browser.newPage()
|
||||
const result = await page.evaluate(
|
||||
async ({ design, app, threshold }) => {
|
||||
const load = (src: string) =>
|
||||
new Promise<HTMLImageElement>((done, fail) => {
|
||||
const image = new Image()
|
||||
image.onload = () => done(image)
|
||||
image.onerror = fail
|
||||
image.src = src
|
||||
})
|
||||
const [a, b] = await Promise.all([load(design), load(app)])
|
||||
const width = Math.max(a.width, b.width)
|
||||
const height = Math.max(a.height, b.height)
|
||||
const pixels = (image: HTMLImageElement) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')!
|
||||
context.drawImage(image, 0, 0)
|
||||
return context.getImageData(0, 0, width, height).data
|
||||
}
|
||||
const left = pixels(a)
|
||||
const right = pixels(b)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')!
|
||||
const output = context.createImageData(width, height)
|
||||
let changed = 0
|
||||
for (let i = 0; i < left.length; i += 4) {
|
||||
const delta =
|
||||
Math.abs(left[i]! - right[i]!) +
|
||||
Math.abs(left[i + 1]! - right[i + 1]!) +
|
||||
Math.abs(left[i + 2]! - right[i + 2]!)
|
||||
if (delta > threshold) {
|
||||
changed++
|
||||
output.data.set([230, 0, 60, 255], i)
|
||||
} else {
|
||||
const gray = (left[i]! + left[i + 1]! + left[i + 2]!) / 3
|
||||
const faded = 255 - (255 - gray) * 0.2
|
||||
output.data.set([faded, faded, faded, 255], i)
|
||||
}
|
||||
}
|
||||
context.putImageData(output, 0, 0)
|
||||
return {
|
||||
png: canvas.toDataURL('image/png'),
|
||||
ratio: changed / (width * height),
|
||||
sizes: `${a.width}x${a.height} vs ${b.width}x${b.height}`,
|
||||
}
|
||||
},
|
||||
{
|
||||
design: `data:image/png;base64,${design.toString('base64')}`,
|
||||
app: `data:image/png;base64,${app.toString('base64')}`,
|
||||
threshold: diffThreshold,
|
||||
},
|
||||
)
|
||||
await page.close()
|
||||
await writeFile(diffPath, Buffer.from(result.png.split(',')[1]!, 'base64'))
|
||||
return result
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const screen = await readScreen(args.screen)
|
||||
const states = pickStates(screen, args.state)
|
||||
const setups = visualStates[screen.name] ?? {}
|
||||
|
||||
const design = await serveDesign()
|
||||
const app = await serveApp()
|
||||
const browser = await chromium.launch()
|
||||
const lines: string[] = []
|
||||
|
||||
try {
|
||||
for (const viewport of args.viewports) {
|
||||
for (const theme of args.themes) {
|
||||
const board = await openBoard(browser, design.origin, screen, theme)
|
||||
for (const state of states) {
|
||||
const directory = join(outRoot, screen.name, slug(state))
|
||||
await mkdir(directory, { recursive: true })
|
||||
const suffix = `${viewport}-${theme}.png`
|
||||
const designPath = join(directory, `design-${suffix}`)
|
||||
await captureDesign(board, screen, state, viewport, designPath)
|
||||
|
||||
const setup = setups[state]
|
||||
if (!setup) {
|
||||
lines.push(
|
||||
`${state} · ${viewport} · ${theme}: design only, no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const appPath = join(directory, `app-${suffix}`)
|
||||
const diffPath = join(directory, `diff-${suffix}`)
|
||||
const unmocked = await captureApp(
|
||||
browser,
|
||||
app.origin,
|
||||
setup,
|
||||
viewport,
|
||||
theme,
|
||||
appPath,
|
||||
)
|
||||
const result = await diff(browser, designPath, appPath, diffPath)
|
||||
const warnings = [
|
||||
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
|
||||
? [`size mismatch ${result.sizes}`]
|
||||
: []),
|
||||
...unmocked.map((key) => `unmocked ${key}`),
|
||||
]
|
||||
lines.push(
|
||||
`${state} · ${viewport} · ${theme}: ${(result.ratio * 100).toFixed(1)}% pixels differ → ${relative(repoRoot, directory)}${warnings.length ? ` (${warnings.join('; ')})` : ''}`,
|
||||
)
|
||||
}
|
||||
await board.context().close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
await app.close()
|
||||
design.close()
|
||||
}
|
||||
|
||||
const report = [
|
||||
`# Visual check: ${screen.name}`,
|
||||
`Board: ${screen.board}, group "${screen.group}"`,
|
||||
'',
|
||||
...lines.map((line) => `- ${line}`),
|
||||
].join('\n')
|
||||
await writeFile(join(outRoot, screen.name, 'report.md'), `${report}\n`)
|
||||
console.log(report)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Page } from 'playwright'
|
||||
|
||||
export interface ApiMock {
|
||||
status: number
|
||||
body?: unknown
|
||||
headers?: Record<string, string>
|
||||
hang?: boolean
|
||||
}
|
||||
|
||||
export interface VisualState {
|
||||
path: string
|
||||
api?: Record<string, ApiMock>
|
||||
steps?: (page: Page) => Promise<void>
|
||||
}
|
||||
|
||||
const registrationClosed: ApiMock = { status: 200, body: { open: false } }
|
||||
const registrationOpen: ApiMock = { status: 200, body: { open: true } }
|
||||
|
||||
async function fillLogin(page: Page, username: string, password: string) {
|
||||
await page.getByPlaceholder('Your username').fill(username)
|
||||
await page.locator('input[autocomplete="current-password"]').fill(password)
|
||||
}
|
||||
|
||||
function submitLogin(username: string, password: string) {
|
||||
return async (page: Page) => {
|
||||
await fillLogin(page, username, password)
|
||||
await page.getByRole('button', { name: 'Log in', exact: true }).click()
|
||||
}
|
||||
}
|
||||
|
||||
function loginWith(response: ApiMock, path = '/login'): Omit<VisualState, 'steps'> {
|
||||
return {
|
||||
path,
|
||||
api: {
|
||||
'GET /v1/auth/registration': registrationClosed,
|
||||
'POST /v1/auth/login': response,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'log-in': {
|
||||
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
|
||||
'Fresh install': { path: '/login', api: { 'GET /v1/auth/registration': registrationOpen } },
|
||||
'Validation errors': {
|
||||
...loginWith({ status: 500 }),
|
||||
steps: submitLogin('ma', ''),
|
||||
},
|
||||
Submitting: {
|
||||
...loginWith({ status: 200, hang: true }),
|
||||
steps: submitLogin('marta', 'limewash2026'),
|
||||
},
|
||||
'Wrong credentials (403)': {
|
||||
...loginWith({ status: 403, body: 'forbidden' }),
|
||||
steps: submitLogin('marta', 'limewash2026'),
|
||||
},
|
||||
'Too many attempts (429)': {
|
||||
...loginWith({ status: 429, headers: { 'Retry-After': '42' } }),
|
||||
steps: submitLogin('marta', 'limewash'),
|
||||
},
|
||||
'Session expired': {
|
||||
...loginWith({ status: 500 }, '/login?redirect=/article/12/edit&reason=expired'),
|
||||
steps: async (page) => {
|
||||
await page.getByPlaceholder('Your username').fill('marta')
|
||||
await page.locator('input[autocomplete="current-password"]').focus()
|
||||
},
|
||||
},
|
||||
'Password changed': {
|
||||
path: '/login?reason=password-changed',
|
||||
api: { 'GET /v1/auth/registration': registrationClosed },
|
||||
},
|
||||
'Server or network error': {
|
||||
...loginWith({ status: 500 }),
|
||||
steps: submitLogin('marta', 'limewash2026'),
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user