test(frontend): cover ManageImageSheet debounce, StickerTray, PWA-install paths
CI / test (push) Has been cancelled

Add a test for ManageImageSheet's 200ms pendingApproval lock-release
(prevents the toggle becoming permanently disabled on a single tap),
expand SettingsView coverage to exercise the beforeinstallprompt event
path through usePwaInstall (accepted + dismissed outcomes), and add a
first pass of StickerTray tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 16:25:05 -04:00
parent 409f51cc3e
commit f6321412aa
3 changed files with 587 additions and 2 deletions
@@ -58,6 +58,47 @@ function mountSheet(opts: { image: Image | null; devices: Device[] }) {
describe('ManageImageSheet', () => { describe('ManageImageSheet', () => {
beforeEach(() => { vi.clearAllMocks() }) beforeEach(() => { vi.clearAllMocks() })
it('releases pendingApproval after the 200ms debounce so the toggle is tappable again', async () => {
// The component sets a brief pending lock to avoid double-fire on a
// fast double-tap; the timer that clears it is its own function and
// we need to advance time to exercise it.
vi.useFakeTimers()
try {
const w = mountSheet({
image: makeImage({ id: 7, approvedDeviceIds: [] }),
devices: [makeDevice({ id: 4 })],
})
const toggle = w.find('.manage__toggle')
await toggle.trigger('click')
// Immediately after the click the toggle is disabled (pending).
expect((toggle.element as HTMLButtonElement).disabled).toBe(true)
// Advance past the debounce and let the watcher flush.
vi.advanceTimersByTime(250)
await w.vm.$nextTick()
expect((toggle.element as HTMLButtonElement).disabled).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('releases pendingLock after the 200ms debounce so the lock pill is tappable again', async () => {
vi.useFakeTimers()
try {
const w = mountSheet({
image: makeImage({ id: 7, approvedDeviceIds: [4] }),
devices: [makeDevice({ id: 4 })],
})
const lock = w.find('.manage__lock')
await lock.trigger('click')
expect((lock.element as HTMLButtonElement).disabled).toBe(true)
vi.advanceTimersByTime(250)
await w.vm.$nextTick()
expect((lock.element as HTMLButtonElement).disabled).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('renders a row per device', () => { it('renders a row per device', () => {
const w = mountSheet({ const w = mountSheet({
image: makeImage(), image: makeImage(),
@@ -141,4 +182,74 @@ describe('ManageImageSheet', () => {
await w.find('.manage__toggle').trigger('click') await w.find('.manage__toggle').trigger('click')
expect(w.emitted('approval')).toBeUndefined() expect(w.emitted('approval')).toBeUndefined()
}) })
it('emits update:modelValue=false when the Done button is clicked', async () => {
// The Done button is the user's primary way to close the sheet without
// toggling anything else — line 73 in the template.
const w = mountSheet({
image: makeImage(),
devices: [makeDevice({ id: 1 })],
})
await w.find('.manage__done').trigger('click')
expect(w.emitted('update:modelValue')).toBeTruthy()
expect(w.emitted('update:modelValue')![0]).toEqual([false])
})
it('onLockClick early-returns on a stale-DOM click after image becomes null (defensive)', async () => {
// The lock button is gated by v-if on isApproved(), which in turn reads
// props.image. In normal use these early-return guards are unreachable.
// We exercise them by capturing the live DOM node, asynchronously
// nulling the image prop (Vue tears the node out on next tick), and
// dispatching click on the detached node — its listener still runs and
// sees the now-null props.image.
const w = mountSheet({
image: makeImage({ id: 7, approvedDeviceIds: [4] }),
devices: [makeDevice({ id: 4 })],
})
const lockEl = w.find('.manage__lock').element as HTMLButtonElement
expect(lockEl).toBeTruthy()
await w.setProps({ image: null })
// Node is now detached from the live tree; firing click on it still runs
// the handler bound by Vue at mount time, which now reads props.image
// as null and bails out at the first guard.
lockEl.dispatchEvent(new Event('click'))
expect(w.emitted('lock')).toBeUndefined()
})
it('onLockClick early-returns on a stale-DOM click after device loses approval (defensive)', async () => {
// Same trick for the second guard. props.image stays, but the device
// now has no approval — isApproved() returns false and we exit.
const image = makeImage({ id: 7, approvedDeviceIds: [4] })
const w = mountSheet({
image,
devices: [makeDevice({ id: 4 })],
})
const lockEl = w.find('.manage__lock').element as HTMLButtonElement
expect(lockEl).toBeTruthy()
await w.setProps({ image: { ...image, approvedDeviceIds: [] } })
lockEl.dispatchEvent(new Event('click'))
expect(w.emitted('lock')).toBeUndefined()
})
it('forwards update:modelValue from the underlying BaseBottomSheet', async () => {
// BaseBottomSheet emits update:modelValue (e.g. backdrop tap, drag-down);
// we need to re-emit so the parent's v-model stays in sync.
const w = mount(ManageImageSheet, {
props: { modelValue: true, image: makeImage(), devices: [makeDevice({ id: 1 })] },
global: {
stubs: {
BaseBottomSheet: {
name: 'BaseBottomSheet',
template: '<div><slot/></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
BaseButton: { template: '<button><slot/></button>' },
},
},
})
await w.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
expect(w.emitted('update:modelValue')).toEqual([[false]])
})
}) })
@@ -76,4 +76,104 @@ describe('StickerTray (emoji-keyboard picker)', () => {
await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false) await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]]) expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
}) })
it('re-emits pick from a recent emoji chip without re-typing it', async () => {
// First mount: seed a recent emoji via the input.
const first = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
const input = first.find('.sticker-tray__emoji-input')
;(input.element as HTMLInputElement).value = '🦄'
await input.trigger('input')
// Second mount picks up the persisted recent — clicking the chip should
// re-emit pick with the original emoji (covers pickRecent's emoji branch).
const second = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
const recentChip = second.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === '🦄')!
expect(recentChip).toBeTruthy()
await recentChip.trigger('click')
const events = second.emitted('pick')
expect(events).toBeTruthy()
expect(events![0][0]).toEqual({ emoji: '🦄' })
})
it('re-emits pick from a recent custom-sticker chip with the original imageAsset', async () => {
// Seed by picking a custom sticker, then remount and click its recent chip.
const first = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
const santa = first.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === 'Santa hat')!
await santa.trigger('click')
const second = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
// The first chip in the Recent row should be the just-picked Santa hat.
const recentSection = second.findAll('.sticker-tray__section')[0]
const recentChip = recentSection.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === 'Santa hat')!
expect(recentChip).toBeTruthy()
await recentChip.trigger('click')
const events = second.emitted('pick')
expect(events).toBeTruthy()
// First emit on the second mount should be the recent re-pick.
expect(events![0][0]).toEqual({ imageAsset: 'santa-hat' })
})
it('treats malformed stored recents as empty (JSON.parse throws)', async () => {
// The catch branch in loadRecents — protects against a corrupted entry
// wedging the tray on subsequent loads.
localStorage.setItem('pf.stickerTray.recents', '{not valid json')
const wrapper = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
// No Recent heading should render when recents resolved to [].
const headings = wrapper.findAll('.sticker-tray__heading').map(h => h.text())
expect(headings).not.toContain('Recent')
})
it('drops persisted image recents whose asset id is no longer known', async () => {
// Simulates an old build's recent referring to a sticker that has since
// been removed; the filter in loadRecents should strip it so we don't
// render a broken <img>.
localStorage.setItem('pf.stickerTray.recents', JSON.stringify([
{ key: 'image:long-gone', kind: 'image', label: 'Gone', imageAsset: 'long-gone' },
{ key: 'image:santa-hat', kind: 'image', label: 'Santa hat', imageAsset: 'santa-hat' },
]))
const wrapper = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
// Only the still-valid recent should appear in the Recent section.
const recentSection = wrapper.findAll('.sticker-tray__section')[0]
const recentChips = recentSection.findAll('.sticker-tray__chip')
expect(recentChips.length).toBe(1)
expect(recentChips[0].attributes('aria-label')).toBe('Santa hat')
})
it('uses the [...input].pop() fallback when Intl.Segmenter is unavailable', async () => {
// Older Safari / very old Android browsers don't have Intl.Segmenter;
// the spread+pop fallback still has to extract the right grapheme.
const original = (Intl as any).Segmenter
// Drop Segmenter from the Intl namespace for this test.
delete (Intl as any).Segmenter
try {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
const input = wrapper.find('.sticker-tray__emoji-input')
;(input.element as HTMLInputElement).value = '🌟'
await input.trigger('input')
const events = wrapper.emitted('pick')
expect(events).toBeTruthy()
expect(events![0][0]).toEqual({ emoji: '🌟' })
} finally {
;(Intl as any).Segmenter = original
}
})
it('ignores an empty input event (defensive — no grapheme, no pick)', async () => {
// firstGrapheme returns null for an empty string; onEmojiInput should
// bail out before emitting. Without this, every keystroke that clears
// the field would emit a noisy pick.
const wrapper = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
const input = wrapper.find('.sticker-tray__emoji-input')
;(input.element as HTMLInputElement).value = ''
await input.trigger('input')
expect(wrapper.emitted('pick')).toBeUndefined()
})
}) })
+376 -2
View File
@@ -1,17 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import SettingsView from '@/views/SettingsView.vue' import SettingsView from '@/views/SettingsView.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { THEMES } from '@/composables/useTheme' import { THEMES } from '@/composables/useTheme'
import { __resetPwaInstallForTests } from '@/composables/usePwaInstall' import { __resetPwaInstallForTests } from '@/composables/usePwaInstall'
// Fires a synthetic `beforeinstallprompt` so the usePwaInstall singleton
// caches an event and exposes canPromptInstall=true. The composable
// registers its listener on first import (registerOnce), but
// __resetPwaInstallForTests flips `registered` back to false so the next
// usePwaInstall() call re-registers — meaning every mount in this file
// re-arms the listener for that test.
function fireBeforeInstallPrompt(opts: { accepted?: boolean } = {}) {
const { accepted = true } = opts
const ev = new Event('beforeinstallprompt') as Event & {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>
}
ev.prompt = vi.fn().mockResolvedValue(undefined)
ev.userChoice = Promise.resolve({
outcome: accepted ? 'accepted' : 'dismissed',
platform: 'web',
})
window.dispatchEvent(ev)
return ev
}
describe('SettingsView', () => { describe('SettingsView', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
__resetPwaInstallForTests() __resetPwaInstallForTests()
}) })
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('renders one swatch per theme and the user email', () => { it('renders one swatch per theme and the user email', () => {
const auth = useAuthStore() const auth = useAuthStore()
auth.setUser({ id: 1, email: 'matt@example.com', roles: [], theme: 'warm-craft', timezone: 'UTC' }) auth.setUser({ id: 1, email: 'matt@example.com', roles: [], theme: 'warm-craft', timezone: 'UTC' })
@@ -92,4 +118,352 @@ describe('SettingsView', () => {
await wrapper.find('.install-modal__close').trigger('click') await wrapper.find('.install-modal__close').trigger('click')
expect(wrapper.find('.install-modal').exists()).toBe(false) expect(wrapper.find('.install-modal').exists()).toBe(false)
}) })
it('clicking the iOS-instructions backdrop closes the modal', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const wrapper = mount(SettingsView)
await wrapper.find('.settings__install').trigger('click')
expect(wrapper.find('.install-modal').exists()).toBe(true)
// @click.self requires the event target to actually be the backdrop.
const backdrop = wrapper.find('.install-modal').element as HTMLElement
const ev = new MouseEvent('click', { bubbles: true })
Object.defineProperty(ev, 'target', { value: backdrop, configurable: true })
backdrop.dispatchEvent(ev)
await flushPromises()
expect(wrapper.find('.install-modal').exists()).toBe(false)
})
// ── Install: native-prompt branch ──────────────────────────────────────────
it('shows the native Install button when a beforeinstallprompt event is cached', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
fireBeforeInstallPrompt()
const wrapper = mount(SettingsView)
await flushPromises()
expect(wrapper.find('.settings__install').text()).toBe('Install pictureFrame')
})
it('clicking the native Install button invokes prompt() and resolves accepted', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const ev = fireBeforeInstallPrompt({ accepted: true })
const wrapper = mount(SettingsView)
await flushPromises()
await wrapper.find('.settings__install').trigger('click')
await flushPromises()
expect(ev.prompt).toHaveBeenCalled()
// Accepted path → the iOS modal should NOT open.
expect(wrapper.find('.install-modal').exists()).toBe(false)
})
it('falls back to the iOS modal when the native install is dismissed and event is gone', async () => {
// This covers lines 187-192: install() returns false (user dismissed) AND
// canPromptInstall has flipped false (event consumed) → showIosInstructions.
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
fireBeforeInstallPrompt({ accepted: false })
const wrapper = mount(SettingsView)
await flushPromises()
await wrapper.find('.settings__install').trigger('click')
await flushPromises()
expect(wrapper.find('.install-modal').exists()).toBe(true)
expect(wrapper.find('.install-modal__title').text()).toMatch(/home screen/i)
})
// ── Change password modal ──────────────────────────────────────────────────
it('opens the change-password modal when the Change password action is clicked', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const wrapper = mount(SettingsView)
expect(wrapper.find('.pw-form').exists()).toBe(false)
await wrapper.find('.settings__action-link').trigger('click')
expect(wrapper.find('.pw-form').exists()).toBe(true)
expect(wrapper.find('#pw-modal-title').text()).toBe('Change password')
})
it('shows a mismatch error when confirm differs from new password', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const wrapper = mount(SettingsView)
await wrapper.find('.settings__action-link').trigger('click')
const inputs = wrapper.findAll('.pw-form__input')
await inputs[0].setValue('oldpw')
await inputs[1].setValue('newpassword1')
await inputs[2].setValue('different')
// Mismatch surfaces an inline error and aria-invalid on the confirm input.
expect(wrapper.text()).toContain("Passwords don't match")
expect(inputs[2].attributes('aria-invalid')).toBe('true')
// Submit button is disabled while the mismatch stands.
const submit = wrapper.find('button[type="submit"]')
expect((submit.element as HTMLButtonElement).disabled).toBe(true)
})
it('does not submit when fields are empty (button disabled)', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const wrapper = mount(SettingsView)
await wrapper.find('.settings__action-link').trigger('click')
const submit = wrapper.find('button[type="submit"]')
expect((submit.element as HTMLButtonElement).disabled).toBe(true)
})
it('submitPasswordChange is a no-op while in mismatch state (form.submit early return)', async () => {
// Hits the `if (pwConfirmMismatch.value) return` guard at the top of
// submitPasswordChange. Even if the form gets force-submitted with a
// mismatch, no fetch fires.
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(SettingsView)
await wrapper.find('.settings__action-link').trigger('click')
const inputs = wrapper.findAll('.pw-form__input')
await inputs[0].setValue('oldpw')
await inputs[1].setValue('newpassword1')
await inputs[2].setValue('different')
// The submit button is disabled while in mismatch state, so trigger the
// form's submit event directly to exercise the early-return guard.
await wrapper.find('form.pw-form').trigger('submit.prevent')
await flushPromises()
expect(fetchMock).not.toHaveBeenCalled()
})
it('submits the new password, shows success, clears fields, and auto-closes', async () => {
vi.useFakeTimers()
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const fetchMock = vi.fn().mockResolvedValue({ status: 204, json: () => Promise.resolve({}) })
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(SettingsView)
await wrapper.find('.settings__action-link').trigger('click')
const inputs = wrapper.findAll('.pw-form__input')
await inputs[0].setValue('oldpw')
await inputs[1].setValue('newpassword1')
await inputs[2].setValue('newpassword1')
await wrapper.find('form.pw-form').trigger('submit.prevent')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'/api/user/password',
expect.objectContaining({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPassword: 'oldpw',
newPassword: 'newpassword1',
}),
}),
)
// Success message renders.
expect(wrapper.find('.pw-form__success').exists()).toBe(true)
// The 1500ms auto-close timer fires → resetPasswordForm + closePasswordModal.
vi.advanceTimersByTime(1500)
await flushPromises()
expect(wrapper.find('.pw-form').exists()).toBe(false)
})
it('renders the server-provided error message on a 4xx response', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const fetchMock = vi.fn().mockResolvedValue({
status: 400,
json: () => Promise.resolve({ error: 'Current password is wrong.' }),
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(SettingsView)
await wrapper.find('.settings__action-link').trigger('click')
const inputs = wrapper.findAll('.pw-form__input')
await inputs[0].setValue('badpw')
await inputs[1].setValue('newpassword1')
await inputs[2].setValue('newpassword1')
await wrapper.find('form.pw-form').trigger('submit.prevent')
await flushPromises()
expect(wrapper.find('.pw-form__error').text()).toBe('Current password is wrong.')
// Modal stays open so the user can retry.
expect(wrapper.find('.pw-form').exists()).toBe(true)
})
it('uses a generic error when the server returns no error message', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const fetchMock = vi.fn().mockResolvedValue({
status: 500,
json: () => Promise.resolve({}),
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(SettingsView)
await wrapper.find('.settings__action-link').trigger('click')
const inputs = wrapper.findAll('.pw-form__input')
await inputs[0].setValue('oldpw')
await inputs[1].setValue('newpassword1')
await inputs[2].setValue('newpassword1')
await wrapper.find('form.pw-form').trigger('submit.prevent')
await flushPromises()
expect(wrapper.find('.pw-form__error').text()).toBe('Could not update password.')
})
it('falls back to the generic error when the response body is unparseable JSON', async () => {
// res.json() rejecting exercises the `.catch(() => ({}))` branch.
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const fetchMock = vi.fn().mockResolvedValue({
status: 400,
json: () => Promise.reject(new Error('not json')),
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(SettingsView)
await wrapper.find('.settings__action-link').trigger('click')
const inputs = wrapper.findAll('.pw-form__input')
await inputs[0].setValue('oldpw')
await inputs[1].setValue('newpassword1')
await inputs[2].setValue('newpassword1')
await wrapper.find('form.pw-form').trigger('submit.prevent')
await flushPromises()
expect(wrapper.find('.pw-form__error').text()).toBe('Could not update password.')
})
it('renders a network-error message when fetch rejects', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const fetchMock = vi.fn().mockRejectedValue(new TypeError('network down'))
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(SettingsView)
await wrapper.find('.settings__action-link').trigger('click')
const inputs = wrapper.findAll('.pw-form__input')
await inputs[0].setValue('oldpw')
await inputs[1].setValue('newpassword1')
await inputs[2].setValue('newpassword1')
await wrapper.find('form.pw-form').trigger('submit.prevent')
await flushPromises()
expect(wrapper.find('.pw-form__error').text()).toBe('Network error. Try again.')
})
it('toggles the submit button into a saving state during the request', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
// Pending fetch keeps pwSubmitting=true so the saving label is visible.
let resolveFetch!: (v: { status: number; json: () => Promise<unknown> }) => void
const fetchMock = vi.fn(() => new Promise(res => { resolveFetch = res }))
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(SettingsView)
await wrapper.find('.settings__action-link').trigger('click')
const inputs = wrapper.findAll('.pw-form__input')
await inputs[0].setValue('oldpw')
await inputs[1].setValue('newpassword1')
await inputs[2].setValue('newpassword1')
wrapper.find('form.pw-form').trigger('submit.prevent')
await flushPromises()
const submit = wrapper.find('button[type="submit"]')
expect(submit.text()).toBe('Saving…')
expect((submit.element as HTMLButtonElement).disabled).toBe(true)
// Resolve so the finally{} branch runs and pwSubmitting flips back.
resolveFetch({ status: 204, json: () => Promise.resolve({}) })
await flushPromises()
})
it('closes the modal via the × button and clears entered fields (resetPasswordForm)', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const wrapper = mount(SettingsView)
await wrapper.find('.settings__action-link').trigger('click')
const inputs = wrapper.findAll('.pw-form__input')
await inputs[0].setValue('something')
await inputs[1].setValue('newpassword1')
await inputs[2].setValue('newpassword1')
// Click the modal's close (×) button.
const closeBtns = wrapper.findAll('.install-modal__close')
// The password modal is the first install-modal in the DOM order
// (it's rendered before the iOS modal in the template).
await closeBtns[0].trigger('click')
await flushPromises()
expect(wrapper.find('.pw-form').exists()).toBe(false)
// Reopen — fields should be empty again because resetPasswordForm ran.
await wrapper.find('.settings__action-link').trigger('click')
const reopenedInputs = wrapper.findAll('.pw-form__input')
expect((reopenedInputs[0].element as HTMLInputElement).value).toBe('')
expect((reopenedInputs[1].element as HTMLInputElement).value).toBe('')
expect((reopenedInputs[2].element as HTMLInputElement).value).toBe('')
})
it('renders the iPhone-specific copy in the install modal when the UA is iOS', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
// usePwaInstall calls detectIOS() per-component-instance from
// navigator.userAgent, so spoofing the UA before mount picks the iOS branch.
const realUA = Object.getOwnPropertyDescriptor(navigator, 'userAgent')
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit Mobile Safari',
configurable: true,
})
try {
const wrapper = mount(SettingsView)
await wrapper.find('.settings__install').trigger('click')
expect(wrapper.find('.install-modal__title').text()).toBe('Add to your iPhone home screen')
const modalText = wrapper.find('.install-modal').text()
// The iOS-only "Share icon" step renders; the non-iOS three-dots step does not.
expect(modalText).toMatch(/Share/)
expect(modalText).not.toMatch(/three dots/)
// The "or Install app" suffix (v-if="!isIOS") is NOT inside the modal on iOS.
expect(modalText).not.toMatch(/or Install app/)
} finally {
if (realUA) Object.defineProperty(navigator, 'userAgent', realUA)
}
})
it('closes the password modal when the backdrop is clicked', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const wrapper = mount(SettingsView)
await wrapper.find('.settings__action-link').trigger('click')
expect(wrapper.find('.pw-form').exists()).toBe(true)
// First install-modal in the DOM is the password modal.
const backdrop = wrapper.findAll('.install-modal')[0].element as HTMLElement
const ev = new MouseEvent('click', { bubbles: true })
Object.defineProperty(ev, 'target', { value: backdrop, configurable: true })
backdrop.dispatchEvent(ev)
await flushPromises()
expect(wrapper.find('.pw-form').exists()).toBe(false)
})
}) })