469 lines
16 KiB
TypeScript
469 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises } from '@vue/test-utils'
|
|
import router from '@/router'
|
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
|
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
|
|
import {
|
|
allMetaSections,
|
|
bylines,
|
|
callsTo,
|
|
click,
|
|
createMetaBackend,
|
|
failWith,
|
|
metaSection,
|
|
mountMeta,
|
|
patchCalls,
|
|
pending,
|
|
renewSites,
|
|
saveButtonEl,
|
|
textOf,
|
|
unmountMeta,
|
|
type MetaBackend,
|
|
} from './metaTestKit'
|
|
|
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
|
return { ...actual, useApi: vi.fn() }
|
|
})
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear()
|
|
sessionStorage.clear()
|
|
})
|
|
|
|
afterEach(unmountMeta)
|
|
|
|
const EDITOR = '/article/12/edit'
|
|
|
|
function backendWith(sites: 'one' | 'several', authors: string[], siteId = 1): MetaBackend {
|
|
const backend = createMetaBackend({
|
|
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
|
|
})
|
|
backend.article.authors = bylines(...authors)
|
|
backend.article.site_id = siteId
|
|
return backend
|
|
}
|
|
|
|
function summary() {
|
|
return textOf(document.body.querySelector('[data-testid="meta-summary"]'))
|
|
}
|
|
|
|
function toggle() {
|
|
return document.body.querySelector<HTMLElement>('[data-testid="meta-toggle"]')!
|
|
}
|
|
|
|
function listboxTriggers() {
|
|
return Array.from(
|
|
document.body.querySelectorAll<HTMLElement>('button[aria-haspopup="listbox"]'),
|
|
)
|
|
}
|
|
|
|
function bodyOpen() {
|
|
return document.body.querySelector('[data-testid="meta-body"]') !== null
|
|
}
|
|
|
|
function toasts() {
|
|
return Array.from(document.body.querySelectorAll('[data-slot="viewport"]'))
|
|
.map(textOf)
|
|
.join(' ')
|
|
}
|
|
|
|
describe('Editor section: collapsed by default with one site and an author', () => {
|
|
it('shows the one-line summary in the details sidebar, above Created, and no Site select', async () => {
|
|
await mountMeta('editor', EDITOR, {
|
|
backend: backendWith('one', ['Inès Duarte']),
|
|
})
|
|
|
|
const details = document.body.querySelector('[data-testid="article-details"]')!
|
|
const section = metaSection()!
|
|
expect(details.contains(section)).toBe(true)
|
|
expect(textOf(section)).toContain('Site and authors')
|
|
expect(summary()).toBe('Inès Duarte')
|
|
expect(toggle().getAttribute('aria-expanded')).toBe('false')
|
|
expect(bodyOpen()).toBe(false)
|
|
expect(listboxTriggers()).toHaveLength(0)
|
|
const text = textOf(details)
|
|
expect(text.indexOf('Site and authors')).toBeLessThan(text.indexOf('Created'))
|
|
expect(text.indexOf('Details')).toBeLessThan(text.indexOf('Site and authors'))
|
|
})
|
|
})
|
|
|
|
describe('Editor section: loading and not found', () => {
|
|
it('shows the header with a skeleton summary while the article is pending', async () => {
|
|
const backend = backendWith('one', ['Inès Duarte'])
|
|
const gate = pending<unknown>()
|
|
backend.responders['GET /v1/article/12'] = () => gate.promise
|
|
await mountMeta('editor', EDITOR, { backend })
|
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
await flushPromises()
|
|
|
|
const skeleton = document.body.querySelector('[data-testid="skeleton-details"]')!
|
|
expect(textOf(skeleton)).toContain('Site and authors')
|
|
expect(skeleton.querySelector('[data-testid="skeleton-meta"]')).not.toBeNull()
|
|
expect(metaSection()).toBeNull()
|
|
})
|
|
|
|
it('shows no section in the Not found state', async () => {
|
|
const backend = backendWith('one', ['Inès Duarte'])
|
|
backend.responders['GET /v1/article/12'] = failWith(404)
|
|
await mountMeta('editor', EDITOR, { backend })
|
|
|
|
expect(textOf(document.body)).toContain("This article doesn't exist")
|
|
expect(metaSection()).toBeNull()
|
|
expect(document.body.textContent).not.toContain('Site and authors')
|
|
})
|
|
})
|
|
|
|
describe('Editor section: it opens by itself when it needs attention', () => {
|
|
it.each([
|
|
{ sites: 'one', authors: ['Inès Duarte'], state: 'collapsed' },
|
|
{ sites: 'several', authors: ['Inès Duarte'], state: 'open' },
|
|
{ sites: 'one', authors: [], state: 'open' },
|
|
{ sites: 'several', authors: [], state: 'open' },
|
|
] as const)(
|
|
'$sites site(s) and $authors.length author(s): $state',
|
|
async ({ sites, authors, state }) => {
|
|
await mountMeta('editor', EDITOR, {
|
|
backend: backendWith(sites, [...authors]),
|
|
})
|
|
|
|
expect(bodyOpen()).toBe(state === 'open')
|
|
expect(toggle().getAttribute('aria-expanded')).toBe(String(state === 'open'))
|
|
},
|
|
)
|
|
|
|
it('stays open while the user adds the first author, instead of collapsing under them', async () => {
|
|
await mountMeta('editor', EDITOR, { backend: backendWith('one', []) })
|
|
expect(bodyOpen()).toBe(true)
|
|
|
|
document.body.querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
|
|
await flushPromises()
|
|
const option = Array.from(
|
|
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
|
|
).find((item) => textOf(item).includes('Camille Roche'))!
|
|
option.click()
|
|
await flushPromises()
|
|
|
|
expect(bodyOpen()).toBe(true)
|
|
expect(textOf(metaSection())).toContain('Camille Roche')
|
|
})
|
|
})
|
|
|
|
describe('Editor section: the choice is remembered per browser', () => {
|
|
it('keeps an opening for the next article, and a collapse over every auto-open, without any request', async () => {
|
|
const backend = backendWith('one', ['Inès Duarte'])
|
|
await mountMeta('editor', EDITOR, { backend })
|
|
toggle().click()
|
|
await flushPromises()
|
|
expect(bodyOpen()).toBe(true)
|
|
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('true')
|
|
await unmountMeta()
|
|
|
|
await mountMeta('editor', '/article/13/edit', {
|
|
backend: backendWith('one', ['Inès Duarte']),
|
|
})
|
|
expect(bodyOpen()).toBe(true)
|
|
toggle().click()
|
|
await flushPromises()
|
|
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('false')
|
|
await unmountMeta()
|
|
|
|
const crowded = backendWith('several', [])
|
|
await mountMeta('editor', EDITOR, { backend: crowded })
|
|
expect(bodyOpen()).toBe(false)
|
|
expect(summary()).toContain('No authors')
|
|
expect(
|
|
crowded.calls.filter(
|
|
(call) => call.method !== 'GET' || call.path.startsWith('/v1/author'),
|
|
),
|
|
).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('Editor section: mobile, inside the Details sheet', () => {
|
|
function openSheet() {
|
|
return click('Details')
|
|
}
|
|
|
|
function sheet() {
|
|
return document.body.querySelector<HTMLElement>('[role="dialog"]')
|
|
}
|
|
|
|
it('hides the desktop sidebar, starts closed in the sheet and shares the choice with it', async () => {
|
|
await mountMeta('editor', EDITOR, {
|
|
width: MOBILE_WIDTH,
|
|
backend: backendWith('several', ['Inès Duarte'], 2),
|
|
})
|
|
|
|
const sidebar = document.body.querySelector('[data-testid="article-details"]')!
|
|
expect(sidebar.className).toContain('hidden')
|
|
expect(sheet()).toBeNull()
|
|
|
|
await openSheet()
|
|
const inSheet = sheet()!.querySelector<HTMLElement>('[data-testid="meta-section"]')!
|
|
expect(inSheet.querySelector('[data-testid="meta-body"]')).toBeNull()
|
|
expect(textOf(inSheet)).toContain('Journal · Inès Duarte')
|
|
|
|
inSheet.querySelector<HTMLElement>('[data-testid="meta-toggle"]')!.click()
|
|
await flushPromises()
|
|
expect(inSheet.querySelector('[data-testid="meta-body"]')).not.toBeNull()
|
|
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('true')
|
|
|
|
sheet()!.querySelector<HTMLElement>('button[aria-label="Close"]')!.click()
|
|
await flushPromises()
|
|
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
await openSheet()
|
|
const again = sheet()!.querySelector<HTMLElement>('[data-testid="meta-section"]')!
|
|
expect(again.querySelector('[data-testid="meta-body"]')).not.toBeNull()
|
|
expect(allMetaSections().length).toBeGreaterThanOrEqual(2)
|
|
})
|
|
})
|
|
|
|
describe('Editor section: collapsed summary variants', () => {
|
|
it.each([
|
|
{
|
|
sites: 'one',
|
|
authors: ['Inès Duarte'],
|
|
siteId: 1,
|
|
line: 'Inès Duarte',
|
|
},
|
|
{
|
|
sites: 'one',
|
|
authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'],
|
|
siteId: 1,
|
|
line: 'Inès Duarte +2',
|
|
},
|
|
{ sites: 'one', authors: [], siteId: 1, line: 'No authors' },
|
|
{
|
|
sites: 'several',
|
|
authors: ['Inès Duarte'],
|
|
siteId: 2,
|
|
line: 'Journal · Inès Duarte',
|
|
},
|
|
{
|
|
sites: 'several',
|
|
authors: [],
|
|
siteId: 2,
|
|
line: 'Journal · No authors',
|
|
},
|
|
] as const)(
|
|
'$sites site(s), $authors.length author(s): "$line"',
|
|
async ({ sites, authors, siteId, line }) => {
|
|
localStorage.setItem(META_SECTION_OPEN_KEY, 'false')
|
|
await mountMeta('editor', EDITOR, {
|
|
backend: backendWith(sites, [...authors], siteId),
|
|
})
|
|
|
|
expect(summary()).toBe(line)
|
|
},
|
|
)
|
|
})
|
|
|
|
describe('Editor section: the Site select', () => {
|
|
it('is absent with one site and the Authors list is the first control', async () => {
|
|
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
|
|
await mountMeta('editor', EDITOR, {
|
|
backend: backendWith('one', ['Inès Duarte']),
|
|
})
|
|
|
|
const body = document.body.querySelector('[data-testid="meta-body"]')!
|
|
expect(textOf(body)).not.toContain('Canonical host')
|
|
expect(textOf(body.firstElementChild)).toContain('Authors')
|
|
expect(listboxTriggers().map(textOf)).toEqual(['Add an author'])
|
|
})
|
|
|
|
it.each(['owner', 'admin', 'verified'] as const)(
|
|
'is above the authors with the canonical host as helper text for a %s',
|
|
async (role) => {
|
|
const backend = backendWith('several', ['Inès Duarte'], 2)
|
|
backend.me.role = role === 'verified' ? undefined : role
|
|
await mountMeta('editor', EDITOR, { backend })
|
|
|
|
const body = textOf(document.body.querySelector('[data-testid="meta-body"]'))
|
|
expect(listboxTriggers().map(textOf)[0]).toBe('Journal · journal.renew.example')
|
|
expect(body).toContain('Canonical host: journal.renew.example')
|
|
expect(body.indexOf('Site')).toBeLessThan(body.indexOf('Authors'))
|
|
},
|
|
)
|
|
|
|
it('lists every site with its host, the default first', async () => {
|
|
await mountMeta('editor', EDITOR, {
|
|
backend: backendWith('several', ['Inès Duarte'], 2),
|
|
})
|
|
listboxTriggers()[0]!.click()
|
|
await flushPromises()
|
|
|
|
const options = Array.from(
|
|
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
|
|
).map(textOf)
|
|
expect(options).toEqual([
|
|
'Renew renew.example',
|
|
'Journal journal.renew.example',
|
|
'Projects projects.renew.example',
|
|
'Archive archive.renew.example',
|
|
])
|
|
})
|
|
|
|
it('treats a change as an edit: nothing is sent until Save, then one PATCH with only site_id', async () => {
|
|
const backend = backendWith('several', ['Inès Duarte'], 1)
|
|
await mountMeta('editor', EDITOR, { backend })
|
|
expect(textOf(document.body)).toContain('Saved')
|
|
expect(saveButtonEl().disabled).toBe(true)
|
|
|
|
listboxTriggers()[0]!.click()
|
|
await flushPromises()
|
|
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
|
.find((item) => textOf(item).startsWith('Journal'))!
|
|
.click()
|
|
await flushPromises()
|
|
|
|
expect(textOf(document.body)).toContain('Unsaved changes')
|
|
expect(saveButtonEl().disabled).toBe(false)
|
|
expect(patchCalls(backend)).toHaveLength(0)
|
|
|
|
saveButtonEl().click()
|
|
await flushPromises()
|
|
|
|
expect(patchCalls(backend)).toHaveLength(1)
|
|
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
|
|
expect(textOf(document.body)).toContain('Saved')
|
|
expect(textOf(document.body.querySelector('[data-testid="meta-body"]'))).toContain(
|
|
'Canonical host: journal.renew.example',
|
|
)
|
|
})
|
|
|
|
it('still loads the editor when the sites cannot be loaded, with no select and no notice', async () => {
|
|
const backend = backendWith('several', ['Inès Duarte'])
|
|
backend.responders['GET /v1/site/'] = failWith(500)
|
|
await mountMeta('editor', EDITOR, { backend })
|
|
|
|
expect(textOf(document.body)).toContain('How we price a bathroom renovation')
|
|
expect(summary()).toBe('Inès Duarte')
|
|
expect(listboxTriggers()).toHaveLength(0)
|
|
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
|
expect(toasts()).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('Editor section: a site or author that no longer exists', () => {
|
|
it('keeps the changes, shows Save failed, an info toast, and reloads the sites', async () => {
|
|
const backend = backendWith('several', ['Inès Duarte'], 1)
|
|
backend.responders['PATCH /v1/article/12'] = failWith(404, 'site_not_found')
|
|
await mountMeta('editor', EDITOR, { backend })
|
|
listboxTriggers()[0]!.click()
|
|
await flushPromises()
|
|
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
|
.find((item) => textOf(item).startsWith('Journal'))!
|
|
.click()
|
|
await flushPromises()
|
|
|
|
saveButtonEl().click()
|
|
await flushPromises()
|
|
|
|
expect(textOf(document.body)).toContain('Save failed')
|
|
expect(toasts()).toContain('That site no longer exists')
|
|
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2)
|
|
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
|
|
})
|
|
|
|
it('keeps the added author and shows the author toast on author_not_found', async () => {
|
|
const backend = backendWith('one', ['Inès Duarte'])
|
|
backend.responders['PATCH /v1/article/12'] = failWith(404, 'author_not_found')
|
|
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
|
|
await mountMeta('editor', EDITOR, { backend })
|
|
listboxTriggers()[0]!.click()
|
|
await flushPromises()
|
|
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
|
.find((item) => textOf(item).includes('Camille Roche'))!
|
|
.click()
|
|
await flushPromises()
|
|
|
|
saveButtonEl().click()
|
|
await flushPromises()
|
|
|
|
expect(textOf(document.body)).toContain('Save failed')
|
|
expect(toasts()).toContain('That author no longer exists')
|
|
expect(textOf(metaSection())).toContain('Camille Roche')
|
|
})
|
|
})
|
|
|
|
describe('Editor section: the existing save behaviour treats the change like any other edit', () => {
|
|
async function changeSite(backend: MetaBackend) {
|
|
await mountMeta('editor', EDITOR, { backend })
|
|
listboxTriggers()[0]!.click()
|
|
await flushPromises()
|
|
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
|
.find((item) => textOf(item).startsWith('Journal'))!
|
|
.click()
|
|
await flushPromises()
|
|
}
|
|
|
|
it('offers Save and publish and saves with Ctrl+S', async () => {
|
|
const backend = backendWith('several', ['Inès Duarte'])
|
|
await changeSite(backend)
|
|
|
|
expect(textOf(document.body)).toContain('Save and publish')
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
|
|
await flushPromises()
|
|
|
|
expect(patchCalls(backend)).toHaveLength(1)
|
|
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
|
|
})
|
|
|
|
it('asks before leaving with an unsaved site change', async () => {
|
|
await changeSite(backendWith('several', ['Inès Duarte']))
|
|
|
|
const leaving = router.push('/article')
|
|
await flushPromises()
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
await flushPromises()
|
|
|
|
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(
|
|
'Leave without saving?',
|
|
)
|
|
await click('Keep editing')
|
|
await leaving
|
|
expect(router.currentRoute.value.path).toBe(EDITOR)
|
|
})
|
|
|
|
it('still loads the article when sessionStorage throws', async () => {
|
|
const spies = ['getItem', 'setItem', 'removeItem'].map((method) =>
|
|
vi.spyOn(Storage.prototype, method as 'getItem').mockImplementation(() => {
|
|
throw new Error('storage blocked')
|
|
}),
|
|
)
|
|
try {
|
|
await mountMeta('editor', EDITOR, {
|
|
backend: backendWith('several', ['Inès Duarte']),
|
|
})
|
|
|
|
expect(metaSection()).not.toBeNull()
|
|
expect(textOf(document.body)).not.toContain('Unsaved changes')
|
|
} finally {
|
|
spies.forEach((spy) => spy.mockRestore())
|
|
}
|
|
})
|
|
|
|
it('keeps the chosen site in sessionStorage when the session expires and restores it', async () => {
|
|
const backend = backendWith('several', ['Inès Duarte'])
|
|
backend.responders['PATCH /v1/article/12'] = failWith(401)
|
|
await changeSite(backend)
|
|
saveButtonEl().click()
|
|
await flushPromises()
|
|
|
|
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-meta:12')!)).toMatchObject(
|
|
{
|
|
site_id: 2,
|
|
},
|
|
)
|
|
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBeNull()
|
|
await unmountMeta()
|
|
|
|
await mountMeta('editor', EDITOR, {
|
|
backend: backendWith('several', ['Inès Duarte']),
|
|
})
|
|
expect(textOf(document.body)).toContain('Unsaved changes')
|
|
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
|
|
})
|
|
})
|