622 lines
19 KiB
TypeScript
622 lines
19 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
|
import router from '@/router'
|
|
import { ApiError, NetworkError, useApi, type UserRole } from '@/composables/useApi'
|
|
import type { UpdateStatus } from '@/composables/useUpdate'
|
|
import { useSessionStore } from '@/stores/session'
|
|
import SettingsView from '../SettingsView.vue'
|
|
import {
|
|
CHECK_PATH,
|
|
DOWNLOAD_PATH,
|
|
NOTE_ITEMS,
|
|
RUNNING_VERSION,
|
|
STATUS_PATH,
|
|
downloading,
|
|
failedWith,
|
|
findButton,
|
|
newerAvailable,
|
|
ready,
|
|
startable,
|
|
verifying,
|
|
visibleButtonLabels,
|
|
} from './updateTestKit'
|
|
|
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
|
return {
|
|
...actual,
|
|
useApi: vi.fn(),
|
|
}
|
|
})
|
|
|
|
let toastAdd: ReturnType<typeof vi.fn>
|
|
|
|
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
|
return {
|
|
...actual,
|
|
useToast: () => ({ add: toastAdd }),
|
|
}
|
|
})
|
|
|
|
type Outcome = UpdateStatus | Error
|
|
type Handler = () => Promise<unknown>
|
|
|
|
let requestMock: ReturnType<typeof vi.fn>
|
|
let getMeMock: ReturnType<typeof vi.fn>
|
|
let wrapper: VueWrapper | null = null
|
|
|
|
function sequence(...outcomes: Outcome[]): Handler {
|
|
let index = 0
|
|
return () => {
|
|
const outcome = outcomes[Math.min(index, outcomes.length - 1)]!
|
|
index++
|
|
return outcome instanceof Error ? Promise.reject(outcome) : Promise.resolve(outcome)
|
|
}
|
|
}
|
|
|
|
interface MountOptions {
|
|
role: UserRole
|
|
status: Handler | UpdateStatus
|
|
download?: Handler | UpdateStatus | Error
|
|
meAfterRefresh?: UserRole
|
|
}
|
|
|
|
function asHandler(value: Handler | Outcome): Handler {
|
|
if (typeof value === 'function') {
|
|
return value
|
|
}
|
|
return sequence(value)
|
|
}
|
|
|
|
async function mountSettings(options: MountOptions) {
|
|
setActivePinia(createPinia())
|
|
toastAdd = vi.fn()
|
|
const session = useSessionStore()
|
|
session.session = { verified: true, username: 'marta', id: 1, role: options.role }
|
|
session.status = 'ready'
|
|
const statusHandler = asHandler(options.status)
|
|
const downloadHandler = asHandler(options.download ?? new ApiError(500))
|
|
requestMock = vi.fn((path: string) => {
|
|
if (path === '/v1/settings/') {
|
|
return Promise.resolve({ version: RUNNING_VERSION })
|
|
}
|
|
if (path === STATUS_PATH) {
|
|
return statusHandler()
|
|
}
|
|
if (path === DOWNLOAD_PATH) {
|
|
return downloadHandler()
|
|
}
|
|
throw new Error(`Unexpected request: ${path}`)
|
|
})
|
|
getMeMock = vi.fn().mockResolvedValue({
|
|
verified: true,
|
|
username: 'marta',
|
|
id: 1,
|
|
displayName: '',
|
|
role: options.meAfterRefresh ?? options.role,
|
|
})
|
|
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
|
|
await router.push('/settings')
|
|
wrapper = mount(SettingsView, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, uiPlugin] },
|
|
})
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
|
|
function useTimers() {
|
|
vi.useFakeTimers({
|
|
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
|
|
})
|
|
}
|
|
|
|
function updatesCard() {
|
|
return document.body.querySelector('[data-testid="updates-card"]')!
|
|
}
|
|
|
|
function cardText() {
|
|
return updatesCard().textContent ?? ''
|
|
}
|
|
|
|
function cardButtons() {
|
|
return visibleButtonLabels(updatesCard())
|
|
}
|
|
|
|
function callsTo(path: string) {
|
|
return requestMock.mock.calls.filter(([called]) => called === path)
|
|
}
|
|
|
|
function progressbar() {
|
|
return updatesCard().querySelector('[role="progressbar"]')
|
|
}
|
|
|
|
function announcer() {
|
|
return updatesCard().querySelector('[data-testid="update-announcer"]')!
|
|
}
|
|
|
|
async function pressUpdate() {
|
|
findButton('Update')!.click()
|
|
await flushPromises()
|
|
}
|
|
|
|
async function tick(milliseconds: number) {
|
|
await vi.advanceTimersByTimeAsync(milliseconds)
|
|
}
|
|
|
|
afterEach(async () => {
|
|
wrapper?.unmount()
|
|
wrapper = null
|
|
vi.useRealTimers()
|
|
await router.push('/login')
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('Settings, owner, Update available with the helper detected', () => {
|
|
it('shows the info block, collapsed notes, the one Update button and the hint', async () => {
|
|
await mountSettings({ role: 'owner', status: startable() })
|
|
|
|
expect(cardText()).toContain('Version 2.3.0-beta is available')
|
|
expect(cardText()).toContain(
|
|
'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.',
|
|
)
|
|
expect(cardText()).toMatch(/Last checked (today|yesterday) at \d\d:\d\d/)
|
|
expect(findButton("What's new in 2.3.0-beta")!.getAttribute('aria-expanded')).toBe('false')
|
|
expect(cardButtons()).toEqual(["What's new in 2.3.0-beta", 'Update'])
|
|
expect(cardText()).toContain('Takes about 2 minutes. Your site stays online.')
|
|
expect(updatesCard().querySelector('code')).toBeNull()
|
|
expect(cardText()).not.toContain('How to update')
|
|
expect(cardText()).not.toContain('For whoever manages the server')
|
|
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(0)
|
|
expect(callsTo(CHECK_PATH)).toHaveLength(0)
|
|
})
|
|
|
|
it('opens the notes with the release date and the plain-text list', async () => {
|
|
await mountSettings({ role: 'owner', status: startable() })
|
|
|
|
const toggle = findButton("What's new in 2.3.0-beta")!
|
|
toggle.click()
|
|
await flushPromises()
|
|
|
|
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
|
expect(cardText()).toContain('Released 24 Sep 2026')
|
|
for (const item of NOTE_ITEMS) {
|
|
expect(cardText()).toContain(item)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Settings, owner, Downloading', () => {
|
|
it('shows the card copy, the progressbar, the text and a disabled Updating button', async () => {
|
|
useTimers()
|
|
await mountSettings({ role: 'owner', status: startable(), download: downloading() })
|
|
|
|
await pressUpdate()
|
|
|
|
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(1)
|
|
expect(requestMock).toHaveBeenCalledWith(DOWNLOAD_PATH, { method: 'POST' })
|
|
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
|
|
expect(cardText()).toContain(
|
|
'You can leave this page. The download continues and your site stays online.',
|
|
)
|
|
const bar = progressbar()!
|
|
expect(bar.getAttribute('aria-label')).toBe('Downloading version 2.3.0-beta')
|
|
expect(bar.getAttribute('aria-valuenow')).toBe('42')
|
|
expect(cardText()).toContain('42% · 11 of 26 MB')
|
|
const updating = findButton('Updating…') as HTMLButtonElement
|
|
expect(updating.disabled).toBe(true)
|
|
expect(cardButtons()).toEqual(['Updating…'])
|
|
})
|
|
|
|
it('reads the status once a second while the job runs', async () => {
|
|
useTimers()
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: sequence(startable(), downloading(42)),
|
|
download: downloading(),
|
|
})
|
|
await pressUpdate()
|
|
const before = callsTo(STATUS_PATH).length
|
|
|
|
await tick(1000)
|
|
await tick(1000)
|
|
await tick(1000)
|
|
|
|
expect(callsTo(STATUS_PATH)).toHaveLength(before + 3)
|
|
})
|
|
})
|
|
|
|
describe('Settings, progress is announced at 25 percent steps only', () => {
|
|
it('changes the announcer at 25, 50 and 75 while the bar updates on every poll', async () => {
|
|
useTimers()
|
|
const percents = [3, 24, 25, 26, 49, 50, 75, 100]
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: sequence(startable(), ...percents.map((percent) => downloading(percent))),
|
|
download: downloading(0),
|
|
})
|
|
await pressUpdate()
|
|
|
|
const announced: string[] = []
|
|
const valueNow: string[] = []
|
|
for (let index = 0; index < percents.length; index++) {
|
|
await tick(1000)
|
|
announced.push(announcer().textContent!.trim())
|
|
valueNow.push(progressbar()!.getAttribute('aria-valuenow')!)
|
|
}
|
|
|
|
expect(announced).toEqual([
|
|
'Downloading version 2.3.0-beta',
|
|
'Downloading version 2.3.0-beta',
|
|
'Downloading version 2.3.0-beta, 25 percent',
|
|
'Downloading version 2.3.0-beta, 25 percent',
|
|
'Downloading version 2.3.0-beta, 25 percent',
|
|
'Downloading version 2.3.0-beta, 50 percent',
|
|
'Downloading version 2.3.0-beta, 75 percent',
|
|
'Downloading version 2.3.0-beta, 75 percent',
|
|
])
|
|
expect(valueNow).toEqual(percents.map(String))
|
|
expect(announcer().getAttribute('role')).toBe('status')
|
|
expect(updatesCard().querySelector('[aria-live="polite"]')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Settings, owner, Verifying', () => {
|
|
it('shows an indeterminate bar, Almost there and announces the step once', async () => {
|
|
useTimers()
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: sequence(startable(), verifying()),
|
|
download: downloading(100),
|
|
})
|
|
await pressUpdate()
|
|
|
|
await tick(1000)
|
|
|
|
expect(cardText()).toContain('Checking the download…')
|
|
expect(cardText()).toContain(
|
|
'Making sure the file is complete and safe to install. This takes a few seconds.',
|
|
)
|
|
const bar = progressbar()!
|
|
expect(bar.hasAttribute('aria-valuenow')).toBe(false)
|
|
expect(bar.getAttribute('aria-label')).toBe('Checking the download')
|
|
expect(cardText()).toContain('Almost there')
|
|
expect((findButton('Updating…') as HTMLButtonElement).disabled).toBe(true)
|
|
expect(announcer().textContent!.trim()).toBe('Checking the download')
|
|
})
|
|
})
|
|
|
|
describe('Settings, owner, Ready to install', () => {
|
|
it('shows the success block, an enabled Restart, the notes and the hint, and stops polling', async () => {
|
|
useTimers()
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: sequence(startable(), ready()),
|
|
download: downloading(100),
|
|
})
|
|
await pressUpdate()
|
|
await tick(1000)
|
|
|
|
expect(cardText()).toContain('Version 2.3.0-beta is ready to install')
|
|
expect(cardText()).toContain(
|
|
'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.',
|
|
)
|
|
expect(cardText()).toContain('Nothing has changed yet.')
|
|
expect(cardText()).toContain(
|
|
'Not now? Leave this page. The update waits here, and the banner on Home reminds you.',
|
|
)
|
|
expect(findButton("What's new in 2.3.0-beta")!.getAttribute('aria-expanded')).toBe('false')
|
|
const restart = findButton('Restart') as HTMLButtonElement
|
|
expect(restart.disabled).toBe(false)
|
|
const tag = updatesCard().querySelector('[data-testid="update-tag"]')
|
|
expect(tag?.textContent?.trim()).toBe('Pending')
|
|
expect(updatesCard().querySelector('svg.text-warning')).not.toBeNull()
|
|
expect(updatesCard().querySelector('svg.text-success')).toBeNull()
|
|
const block = updatesCard().querySelector('[aria-live]') as HTMLElement
|
|
expect(block.className).toContain('--rc-warning-soft')
|
|
expect(block.contains(restart)).toBe(true)
|
|
expect(restart.className).toContain('h-11')
|
|
expect(restart.className).toContain('md:h-8')
|
|
expect(updatesCard().querySelector('[class*="md:flex-row"]')).toBeNull()
|
|
|
|
const readsBefore = callsTo(STATUS_PATH).length
|
|
await tick(3000)
|
|
|
|
expect(callsTo(STATUS_PATH)).toHaveLength(readsBefore)
|
|
expect(requestMock.mock.calls.map(([path]) => path)).not.toContain(
|
|
'/v1/system/update/restart',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('Settings, Download failed', () => {
|
|
function activeId() {
|
|
return document.activeElement?.id
|
|
}
|
|
|
|
it.each([
|
|
{
|
|
name: 'a failed record on a poll',
|
|
status: sequence(startable(), failedWith('download')),
|
|
download: downloading(),
|
|
},
|
|
{ name: 'a 500', status: startable(), download: new ApiError(500) },
|
|
{ name: 'a network error', status: startable(), download: new NetworkError() },
|
|
])('shows the error card and focuses the heading after $name', async (scenario) => {
|
|
useTimers()
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: scenario.status,
|
|
download: scenario.download,
|
|
})
|
|
await pressUpdate()
|
|
await tick(1000)
|
|
await flushPromises()
|
|
|
|
expect(cardText()).toContain("The download didn't finish")
|
|
expect(cardText()).toContain(
|
|
'The connection dropped before the file was complete. Nothing was changed and your site is running as before.',
|
|
)
|
|
expect(cardButtons()).toEqual(['Try again'])
|
|
expect(activeId()).toBe('updates-h')
|
|
})
|
|
|
|
it('posts the download again from Try again', async () => {
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: startable(),
|
|
download: sequence(new ApiError(500), downloading()),
|
|
})
|
|
await pressUpdate()
|
|
|
|
findButton('Try again')!.click()
|
|
await flushPromises()
|
|
|
|
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(2)
|
|
expect(callsTo(CHECK_PATH)).toHaveLength(0)
|
|
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
|
|
})
|
|
})
|
|
|
|
describe('Settings, Verification failed', () => {
|
|
function assertUntrustedCard() {
|
|
expect(cardText()).toContain("We didn't use this update")
|
|
expect(cardText()).toContain(
|
|
"The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support.",
|
|
)
|
|
expect(cardButtons()).toEqual(['Try again'])
|
|
expect(document.activeElement?.id).not.toBe('updates-h')
|
|
expect(cardText()).not.toMatch(/signature|checksum|sha/i)
|
|
}
|
|
|
|
it('shows the warning card from a failed record', async () => {
|
|
useTimers()
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: sequence(startable(), failedWith('verification')),
|
|
download: downloading(),
|
|
})
|
|
await pressUpdate()
|
|
await tick(1000)
|
|
|
|
assertUntrustedCard()
|
|
})
|
|
|
|
it('shows the warning card after a 422', async () => {
|
|
await mountSettings({ role: 'owner', status: startable(), download: new ApiError(422) })
|
|
|
|
await pressUpdate()
|
|
|
|
assertUntrustedCard()
|
|
})
|
|
})
|
|
|
|
describe('Settings, 409 already running', () => {
|
|
it('toasts and shows the other run while it downloads', async () => {
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: sequence(startable(), downloading(42)),
|
|
download: new ApiError(409),
|
|
})
|
|
|
|
await pressUpdate()
|
|
|
|
expect(toastAdd).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
color: 'info',
|
|
title: 'An update is already running',
|
|
description: 'Another owner started it. Progress shows here.',
|
|
}),
|
|
)
|
|
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
|
|
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
|
|
})
|
|
|
|
it('shows Ready to install without a toast when the reload answers ready', async () => {
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: sequence(startable(), ready()),
|
|
download: new ApiError(409),
|
|
})
|
|
|
|
await pressUpdate()
|
|
|
|
expect(toastAdd).not.toHaveBeenCalled()
|
|
expect(cardText()).toContain('Version 2.3.0-beta is ready to install')
|
|
})
|
|
|
|
it('shows the Not prepared card without a toast when the reload answers can_apply false', async () => {
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: sequence(startable(), newerAvailable()),
|
|
download: new ApiError(409),
|
|
})
|
|
|
|
await pressUpdate()
|
|
|
|
expect(toastAdd).not.toHaveBeenCalled()
|
|
expect(cardText()).toContain("This site can't update itself from the browser yet.")
|
|
})
|
|
})
|
|
|
|
describe('Settings, 400 nothing to update', () => {
|
|
it('toasts and reloads the status once', async () => {
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: startable(),
|
|
download: new ApiError(400),
|
|
})
|
|
const readsBefore = callsTo(STATUS_PATH).length
|
|
|
|
await pressUpdate()
|
|
|
|
expect(toastAdd).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
color: 'info',
|
|
title: 'Nothing to update',
|
|
description: 'You already have the latest version.',
|
|
}),
|
|
)
|
|
expect(callsTo(STATUS_PATH)).toHaveLength(readsBefore + 1)
|
|
})
|
|
})
|
|
|
|
describe('Settings, 403 role changed on Update', () => {
|
|
it('toasts, reloads and renders the admin view', async () => {
|
|
await mountSettings({
|
|
role: 'owner',
|
|
meAfterRefresh: 'admin',
|
|
status: sequence(startable(), newerAvailable()),
|
|
download: new ApiError(403),
|
|
})
|
|
|
|
await pressUpdate()
|
|
|
|
expect(toastAdd).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
color: 'error',
|
|
title: 'Only the owner can do this',
|
|
description: 'Ask the owner of this site to update RenewCMS.',
|
|
}),
|
|
)
|
|
expect(getMeMock).toHaveBeenCalled()
|
|
expect(cardText()).toContain('Only the owner can update RenewCMS.')
|
|
expect(cardButtons()).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('Settings, 429 on Update', () => {
|
|
it('counts down through useRetryAfter and posts the download again at zero', async () => {
|
|
useTimers()
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: startable(),
|
|
download: sequence(new ApiError(429, 42), downloading()),
|
|
})
|
|
|
|
await pressUpdate()
|
|
|
|
expect(cardText()).toContain('Too many tries')
|
|
expect(cardText()).toContain('Wait a moment before trying again.')
|
|
expect((findButton('Try again in 0:42') as HTMLButtonElement).disabled).toBe(true)
|
|
|
|
await tick(1000)
|
|
expect(findButton('Try again in 0:41')).toBeTruthy()
|
|
|
|
await tick(41000)
|
|
const again = findButton('Try again') as HTMLButtonElement
|
|
expect(again.disabled).toBe(false)
|
|
|
|
again.click()
|
|
await flushPromises()
|
|
|
|
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(2)
|
|
expect(callsTo(CHECK_PATH)).toHaveLength(0)
|
|
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
|
|
})
|
|
})
|
|
|
|
describe('Settings, reload keeps progress', () => {
|
|
it('stops polling on leave and resumes it on the next open', async () => {
|
|
useTimers()
|
|
await mountSettings({ role: 'owner', status: downloading(42) })
|
|
await tick(1000)
|
|
const beforeLeaving = callsTo(STATUS_PATH).length
|
|
expect(beforeLeaving).toBeGreaterThan(1)
|
|
|
|
wrapper!.unmount()
|
|
wrapper = null
|
|
await tick(5000)
|
|
expect(callsTo(STATUS_PATH)).toHaveLength(beforeLeaving)
|
|
|
|
document.body.innerHTML = ''
|
|
await mountSettings({ role: 'owner', status: downloading(42) })
|
|
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
|
|
const afterOpening = callsTo(STATUS_PATH).length
|
|
await tick(2000)
|
|
expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening + 2)
|
|
})
|
|
|
|
it.each([
|
|
{ name: 'idle', status: startable() },
|
|
{ name: 'ready', status: ready() },
|
|
{ name: 'failed', status: failedWith('download') },
|
|
])('does not poll while the state is $name', async ({ status }) => {
|
|
useTimers()
|
|
await mountSettings({ role: 'owner', status })
|
|
const afterOpening = callsTo(STATUS_PATH).length
|
|
|
|
await tick(5000)
|
|
|
|
expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening)
|
|
})
|
|
})
|
|
|
|
describe('Settings, a poll that fails keeps the card', () => {
|
|
it.each([
|
|
{ name: 'a 500', failure: new ApiError(500) },
|
|
{ name: 'a network error', failure: new NetworkError() },
|
|
])('keeps the last progress and polls again after $name', async ({ failure }) => {
|
|
useTimers()
|
|
await mountSettings({
|
|
role: 'owner',
|
|
status: sequence(downloading(42), failure, failure, downloading(60)),
|
|
})
|
|
|
|
await tick(1000)
|
|
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
|
|
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
|
|
expect(cardText()).not.toContain("The download didn't finish")
|
|
|
|
await tick(1000)
|
|
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
|
|
|
|
await tick(1000)
|
|
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('60')
|
|
})
|
|
})
|
|
|
|
describe('Settings, admin view during a job', () => {
|
|
it('shows the admin copy with no button, progressbar or poll', async () => {
|
|
useTimers()
|
|
await mountSettings({
|
|
role: 'admin',
|
|
status: { ...downloading(42), can_apply: false },
|
|
})
|
|
const afterOpening = callsTo(STATUS_PATH).length
|
|
|
|
await tick(5000)
|
|
|
|
expect(cardText()).toContain('Version 2.3.0-beta is available')
|
|
expect(cardText()).toContain(
|
|
'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.',
|
|
)
|
|
expect(cardButtons()).toEqual([])
|
|
expect(progressbar()).toBeNull()
|
|
expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening)
|
|
})
|
|
})
|