Files
RenewCMS/web/src/views/__tests__/IntegrationView.spec.ts
T

676 lines
25 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 { useSessionStore } from '@/stores/session'
import UButton from '@nuxt/ui/components/Button.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import {
API_KEY_PLACEHOLDER,
ERROR_SAMPLE,
authExamples,
allEndpoints,
endpointExamples,
} from '@/data/apiCatalogue'
import IntegrationView from '../IntegrationView.vue'
const STUB_ORIGIN = 'https://cms.renew.example'
const SECTION_LABELS = [
'Authentication',
'Articles',
'Search and sharing',
'Media',
'Users and invites',
'Settings',
'Sites',
'Authors',
'Images and variants',
'Session only',
'Errors',
'Notes',
]
let currentWrapper: VueWrapper | null = null
let fetchSpy: ReturnType<typeof vi.fn>
function stubOrigin(origin: string) {
vi.stubGlobal('location', { ...window.location, origin })
}
function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}
async function mountIntegration() {
stubOrigin(STUB_ORIGIN)
fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push('/integration')
const wrapper = mount(IntegrationView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function findButton(wrapper: VueWrapper, name: string) {
return wrapper.findAll('button').find((button) => button.text().trim() === name)
}
function row(wrapper: VueWrapper, route: string) {
const found = wrapper
.findAll('button[aria-expanded]')
.find((button) => button.attributes('aria-label')?.startsWith(`${route}. `))
if (!found) {
throw new Error(`no row for ${route}`)
}
return found
}
function card(wrapper: VueWrapper, route: string) {
const [method, path] = route.split(' ')
return wrapper
.findAllComponents(EndpointCard)
.find(
(item) =>
item.props('endpoint').method === method && item.props('endpoint').path === path,
)!
}
async function expand(wrapper: VueWrapper, route: string) {
await row(wrapper, route).trigger('click')
await flushPromises()
return card(wrapper, route)
}
async function activateTab(container: HTMLElement, label: string) {
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.click()
await flushPromises()
}
function rowRoute(item: ReturnType<VueWrapper['get']>) {
const [method, path] = item.findAll('span.font-mono').map((span) => span.text())
return `${method} ${path}`
}
function section(wrapper: VueWrapper, id: string) {
return wrapper.get(`[data-anchor="${id}"]`)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.unstubAllGlobals()
vi.useRealTimers()
await router.push('/login')
})
describe('Default, every card collapsed', () => {
it('renders the header, intro, base URL and collapsed cards', async () => {
const wrapper = await mountIntegration()
expect(wrapper.text()).toContain('Integration')
expect(wrapper.findAll('h1').map((heading) => heading.text())).toContain('API reference')
expect(wrapper.text()).toContain('Everything a developer needs to build on RenewCMS.')
expect(wrapper.text()).toContain(
'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.',
)
expect(wrapper.text()).toContain(STUB_ORIGIN)
expect(findButton(wrapper, 'Copy')).toBeTruthy()
const rows = wrapper
.findAllComponents(EndpointCard)
.map((item) => item.get('button[aria-expanded]'))
expect(rows).toHaveLength(allEndpoints().length)
rows.forEach((button) => expect(button.attributes('aria-expanded')).toBe('false'))
expect(
wrapper.findAllComponents(EndpointCard).flatMap((item) => item.findAll('[role="tab"]')),
).toHaveLength(0)
})
it('lists the sections in the sticky index and the Jump to select', async () => {
const wrapper = await mountIntegration()
const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.findAll('button').map((button) => button.text())).toEqual(SECTION_LABELS)
const jump = wrapper.get('button[aria-label="Jump to section"]')
expect(jump.text()).toContain('Jump to')
})
it('shows Owner only on the three invite rows and Verified user elsewhere', async () => {
const wrapper = await mountIntegration()
for (const route of [
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
]) {
expect(row(wrapper, route).text()).toContain('Owner only')
expect(row(wrapper, route).text()).not.toContain('Verified user')
}
expect(row(wrapper, 'GET /v1/auth/me').text()).toContain('Verified user')
expect(row(wrapper, 'GET /v1/article/').text()).toContain('Public')
expect(row(wrapper, 'GET /v1/article/').text()).toContain('API key OK')
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
})
it('groups the cards as 8, 2, 10, 6 and 1', async () => {
const wrapper = await mountIntegration()
expect(
['articles', 'seo', 'media', 'users', 'settings'].map(
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
),
).toEqual([8, 2, 10, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('8 routes')
expect(section(wrapper, 'seo').text()).toContain('2 routes')
expect(section(wrapper, 'sites').text()).toContain('1 route')
expect(section(wrapper, 'settings').text()).toContain('1 route')
})
it('makes no /v1 request for a session without the owner or admin role', async () => {
const wrapper = await mountIntegration()
await expand(wrapper, 'POST /v1/article/')
await expand(wrapper, 'GET /v1/media/')
expect(globalThis.fetch).toBe(fetchSpy)
expect(fetchSpy).not.toHaveBeenCalled()
})
})
describe('Group scrolled, one card expanded', () => {
it('reports aria-expanded and shows the tables, tabs, code and errors', async () => {
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
expect(row(wrapper, 'POST /v1/article/').attributes('aria-expanded')).toBe('true')
const text = created.text()
expect(text.indexOf('Query parameters')).toBeGreaterThan(-1)
expect(text.indexOf('Query parameters')).toBeLessThan(text.indexOf('Request body'))
expect(text).toContain('format')
expect(text).toContain('markdown, html or json. Default markdown.')
expect(text).toContain('title')
expect(text).toContain("3 to 200 characters. Can't be changed later.")
expect(text).toContain('Up to 10 000 characters.')
const tabs = created.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['fetch', 'curl', 'Response'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(created.findAll('[role="tablist"]')).toHaveLength(1)
expect(created.get('pre').text()).toBe(
endpointExamples(created.props('endpoint'), STUB_ORIGIN).fetch,
)
expect(text).not.toContain('Shell')
expect(created.findAll('button[aria-label="Copy code"]')).toHaveLength(1)
expect(text).toContain('Errors')
const statuses = created
.findAll('[title]')
.map((item) => item.text())
.join(' ')
expect(statuses).toContain('400')
expect(statuses).toContain('401')
expect(text).toContain('Article title must be between 3 and 200 characters.')
expect(text).toContain('The format must be one of: markdown, html, json.')
})
it('shows "No query parameters." and "No request body." when a card has none', async () => {
const wrapper = await mountIntegration()
const published = await expand(wrapper, 'GET /v1/settings')
expect(published.text()).toContain('No query parameters.')
expect(published.text()).toContain('No request body.')
})
it('shows the seven media query parameters on the media list', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/media/')
for (const name of ['q', 'hash', 'filter', 'sort', 'variants', 'page', 'size']) {
expect(list.text()).toContain(name)
}
expect(list.text()).toContain('5, 25, 50 or 100. Default 25.')
expect(list.text()).not.toContain('next_cursor')
})
it('states the multipart field on the upload and the owner-only 403 and rate limit on invites', async () => {
const wrapper = await mountIntegration()
const upload = await expand(wrapper, 'POST /v1/media/')
expect(upload.text()).toContain('Request body · multipart/form-data, 1 file field')
expect(upload.text()).toContain('file')
expect(upload.text()).toContain('the image could not be read')
expect(upload.text()).toContain('the file exceeds the maximum upload size')
expect(upload.text()).toContain('the file must be a PNG, JPEG, WEBP, or SVG image')
const invite = await expand(wrapper, 'POST /v1/user/invite')
expect(invite.text()).toContain('Only the owner can do this.')
expect(invite.text()).toContain('This route is rate limited. Too many requests answer 429.')
expect(invite.text()).toContain("There's already a pending invite for this address.")
})
})
describe('Copied, the Base URL copy button', () => {
it('writes the origin to the clipboard, shows Copied for 2s, then reverts', async () => {
const writeText = stubClipboard()
vi.useFakeTimers()
const wrapper = await mountIntegration()
await findButton(wrapper, 'Copy')!.trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
expect(findButton(wrapper, 'Copied')).toBeTruthy()
expect(wrapper.find('[aria-live="polite"]').text()).toBe('Copied')
await vi.advanceTimersByTimeAsync(1999)
expect(findButton(wrapper, 'Copied')).toBeTruthy()
await vi.advanceTimersByTimeAsync(1)
expect(wrapper.find('[aria-live="polite"]').text()).toBe('Copy')
expect(findButton(wrapper, 'Copied')).toBeFalsy()
})
})
describe("Copied, a CodeSnippet's Copy button is independent of the Base URL's", () => {
it("copies only that card's active tab and leaves the other buttons unaffected", async () => {
const writeText = stubClipboard()
vi.useFakeTimers()
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
const button = created
.findAll('button')
.find((item) => item.attributes('aria-label') === 'Copy code')!
await button.trigger('click')
await flushPromises()
const expected = endpointExamples(created.props('endpoint'), STUB_ORIGIN).fetch
expect(writeText).toHaveBeenCalledWith(expected)
const copied = wrapper.findAll('button').filter((item) => item.text().trim() === 'Copied')
expect(copied).toHaveLength(1)
expect(copied[0]!.attributes('aria-label')).toBe('Copy code')
expect(copied[0]!.element).toBe(button.element)
expect(findButton(wrapper, 'Copy')!.attributes('aria-label')).toBe('Copy code')
await vi.advanceTimersByTimeAsync(1999)
expect(findButton(wrapper, 'Copied')).toBeTruthy()
await vi.advanceTimersByTimeAsync(1)
expect(findButton(wrapper, 'Copied')).toBeFalsy()
})
})
describe('Authentication', () => {
it('renders the Public and API key cards, the tabs for GET /v1/auth/me, the link and both notes', async () => {
const wrapper = await mountIntegration()
const auth = section(wrapper, 'auth')
expect(auth.text()).toContain(
'No key needed. Two read-only routes, and only online articles are returned.',
)
expect(auth.text()).toContain('GET /v1/article/')
expect(auth.text()).toContain('GET /v1/article/{id}')
expect(auth.text()).toContain(
"Send Authorization: Bearer rcms_…. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.",
)
expect(auth.text()).toContain(
'Keys start with rcms_ and are shown in full once, when you create them.',
)
const tabs = auth.findAll('[role="tab"]').map((tab) => tab.text())
expect(tabs).toEqual(['curl', 'fetch'])
const examples = authExamples(STUB_ORIGIN)
expect(auth.text()).toContain(`${STUB_ORIGIN}/v1/auth/me`)
expect(auth.text()).toContain(API_KEY_PLACEHOLDER)
await activateTab(auth.element as HTMLElement, 'fetch')
expect(auth.text()).toContain(`fetch('${STUB_ORIGIN}/v1/auth/me'`)
expect(examples.curl).toContain('Authorization: Bearer rcms_')
const link = auth.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
expect(link.attributes('href')).toBe('/integration/api-keys')
expect(auth.text()).toContain(
'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under /v1/system. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified.',
)
expect(auth.text()).toContain(
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a Retry-After header.',
)
})
it('navigates the Manage API keys link to the keys screen', async () => {
const wrapper = await mountIntegration()
const anchor = wrapper.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
const button = wrapper
.findAllComponents(UButton)
.find((component) => component.element === anchor.element)!
expect(button.props('variant')).toBe('link')
expect(button.props('trailingIcon')).toBe('i-lucide-arrow-right')
await anchor.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/integration/api-keys'))
})
})
describe('Session only', () => {
it('renders the banner, 25 rows without expand controls and the 403 chips', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(session.text()).toContain('25 routes')
expect(session.text()).toContain(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."',
)
const rows = session.findAll('[data-session-row]')
expect(rows).toHaveLength(25)
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
const chipRows = rows.filter((item) => item.text().includes('Session only'))
expect(chipRows.map(rowRoute)).toEqual([
'GET /v1/system/update',
'POST /v1/system/update/check',
'POST /v1/system/update/download',
'POST /v1/system/update/restart',
'POST /v1/auth/verify',
'DELETE /v1/auth/register',
'POST /v1/site/',
'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default',
'DELETE /v1/site/{id}',
'POST /v1/author/',
'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}',
'POST /v1/author/me',
'PATCH /v1/settings',
'GET /v1/activity',
'GET /v1/activity/summary',
'GET /v1/activity/export',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
])
chipRows.forEach((item) =>
expect(item.find('svg, [class*="lucide-lock"]').exists()).toBe(true),
)
})
it('captions Log in, Log out, Register and Reset password without a 403 claim', async () => {
const wrapper = await mountIntegration()
const rows = section(wrapper, 'session').findAll('[data-session-row]')
for (const path of [
'/v1/auth/login',
'/v1/auth/logout',
'/v1/auth/register',
'/v1/auth/password-reset',
]) {
const item = rows.find(
(candidate) =>
candidate.text().includes(path) &&
candidate.text().includes('Made for the browser.'),
)!
expect(item.text()).toContain('A key is ignored here, with no 403.')
expect(item.text()).not.toContain('Session only')
}
})
it('lists the three sub-groups and keeps /v1/system out of the expandable groups', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(session.text()).toContain('System · owner only')
expect(session.text()).toContain('Sign in and account')
expect(session.text()).toContain('API key management')
const rowLabels = wrapper
.findAll('button[aria-expanded]')
.map((button) => button.attributes('aria-label'))
expect(rowLabels.some((label) => label?.includes('/v1/system'))).toBe(false)
})
it('lists the 6 Public auth flow rows with the Public chip and no lock', async () => {
const wrapper = await mountIntegration()
const rows = section(wrapper, 'session').findAll('[data-public-row]')
expect(rows).toHaveLength(6)
expect(rows.map((item) => item.text())).toEqual([
expect.stringContaining('GET/v1/Hello world.'),
expect.stringContaining('GET/v1/auth/registrationWhether registration is open.'),
expect.stringContaining(
'POST/v1/auth/password-reset/confirmSet a new password with the emailed code.',
),
expect.stringContaining('GET/v1/auth/inviteCheck an invite code.'),
expect.stringContaining('POST/v1/auth/invite/acceptAccept an invite.'),
expect.stringContaining('GET/healthzPlain text "ok". Outside /v1.'),
])
rows.forEach((item) => {
expect(item.text()).toContain('Public')
expect(item.text()).not.toContain('Session only')
expect(item.find('[class*="lucide-lock"]').exists()).toBe(false)
})
})
})
describe('Errors', () => {
it('renders the intro, the real messages and the 403 sample', async () => {
const wrapper = await mountIntegration()
const errors = section(wrapper, 'errors')
expect(errors.text()).toContain(
'Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.',
)
const rows = errors.findAll('tbody tr')
expect(rows.map((item) => item.find('td').text())).toEqual([
'400',
'401',
'403',
'404',
'409',
'429',
'500',
])
const text = errors.text()
for (const message of [
'The request cannot be processed due to a mismatch in the format of the body.',
'The expiry must be a date in the future.',
'You must be logged in to access this resource.',
'This action needs a real session.',
'Only the owner can do this.',
'You must verify your account to access this resource.',
'Article not found',
'Image not found',
'Invite not found',
'API key not found',
"There's already a pending invite for this address.",
'This invite has already been accepted.',
'Too many requests. Please try again later.',
'Internal server error',
]) {
expect(text).toContain(message)
}
const sample = errors.get('pre').text()
expect(sample).toBe(ERROR_SAMPLE)
expect(sample.split('\n')).toEqual([
'HTTP/1.1 403 Forbidden',
'Content-Type: text/plain',
'',
'This action needs a real session.',
])
})
})
describe('Navigation', () => {
it('scrolls to the anchor and marks the index item current', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
})
const wrapper = await mountIntegration()
const index = wrapper.get('nav[aria-label="On this page"]')
const media = index.findAll('button').find((button) => button.text() === 'Media')!
expect(media.attributes('aria-current')).toBeUndefined()
await media.trigger('click')
await flushPromises()
expect(media.attributes('aria-current')).toBe('true')
expect(scrollIntoView).toHaveBeenCalledTimes(1)
expect(scrollIntoView.mock.contexts[0]).toBe(section(wrapper, 'media').element)
const current = index.findAll('button[aria-current="true"]')
expect(current.map((button) => button.text())).toEqual(['Media'])
})
it('lists the same sections in the mobile Jump to select', async () => {
const wrapper = await mountIntegration()
const jump = wrapper.get('button[aria-label="Jump to section"]')
await jump.trigger('click')
await flushPromises()
const options = Array.from(document.body.querySelectorAll('[role="option"]')).map(
(option) => option.textContent?.trim(),
)
expect(options).toEqual(SECTION_LABELS)
})
})
describe('Tabs, curl and Response render, unchecked visually', () => {
it('shows curl with the origin and the Authorization header on a non-public route', async () => {
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
await activateTab(created.element as HTMLElement, 'curl')
expect(created.text()).toContain(`curl -X POST "${STUB_ORIGIN}/v1/article/"`)
expect(created.text()).toContain(`Authorization: Bearer ${API_KEY_PLACEHOLDER}`)
})
it('shows curl without an Authorization header on a public route', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/article/')
await activateTab(list.element as HTMLElement, 'curl')
expect(list.text()).toContain(`curl "${STUB_ORIGIN}/v1/article/"`)
expect(list.text()).not.toContain('Authorization')
})
it('shows the Response sample built from the real article fields', async () => {
const wrapper = await mountIntegration()
const single = await expand(wrapper, 'GET /v1/article/{id}')
await activateTab(single.element as HTMLElement, 'Response')
for (const key of [
'"id"',
'"title"',
'"body"',
'"body_format"',
'"is_online"',
'"created_at"',
'"updated_at"',
]) {
expect(single.text()).toContain(key)
}
expect(single.text()).not.toContain('"images"')
expect(single.text()).not.toContain('"status"')
})
})
describe('Notes', () => {
it('renders the format, image-prefix and CORS notes', async () => {
const wrapper = await mountIntegration()
const notes = section(wrapper, 'notes').text()
expect(notes).toContain(
'Article routes accept ?format=markdown, ?format=html or ?format=json to choose how body is returned. With json, body is the stored JSON structure. Articles have body_format: markdown by default.',
)
expect(notes).toContain(
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
)
expect(notes).toContain(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
)
})
})
describe('CodeSnippet migration', () => {
it('draws the Base URL as a CodeSnippet with the single tab "Base URL"', async () => {
const wrapper = await mountIntegration()
const label = wrapper.findAll('span').find((item) => item.text() === 'Base URL')!
const snippet = label.element.parentElement!
expect(snippet.querySelectorAll('[role="tab"]')).toHaveLength(0)
expect(snippet.querySelector('[role="tablist"]')?.textContent?.trim()).toBe('Base URL')
expect(snippet.querySelector('pre')?.textContent).toBe(STUB_ORIGIN)
})
it('draws the Authentication example with curl and fetch tabs and no Shell or JavaScript label', async () => {
const wrapper = await mountIntegration()
const auth = section(wrapper, 'auth')
expect(auth.findAll('[role="tablist"]')).toHaveLength(1)
expect(auth.findAll('[role="tab"]').map((tab) => tab.text())).toEqual(['curl', 'fetch'])
expect(auth.get('[role="tab"][aria-selected="true"]').text()).toBe('curl')
expect(auth.get('pre').text()).toBe(authExamples(STUB_ORIGIN).curl)
expect(auth.text()).not.toContain('Shell')
expect(auth.text()).not.toContain('JavaScript')
})
it('draws the Errors sample as a CodeSnippet named "Error response · text/plain"', async () => {
const wrapper = await mountIntegration()
const errors = section(wrapper, 'errors')
expect(errors.get('[role="tablist"]').text()).toBe('Error response · text/plain')
expect(errors.findAll('button[aria-label="Copy code"]')).toHaveLength(1)
expect(errors.get('pre').text().split('\n')).toEqual([
'HTTP/1.1 403 Forbidden',
'Content-Type: text/plain',
'',
'This action needs a real session.',
])
})
it('keeps independent Copy buttons across the page', async () => {
const writeText = stubClipboard()
const wrapper = await mountIntegration()
await findButton(wrapper, 'Copy')!.trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
expect(wrapper.findAll('button').filter((item) => item.text() === 'Copied')).toHaveLength(1)
})
it('removes CodeBlock and every import of it, and keeps CodeText', () => {
const sources = import.meta.glob<string>('/src/**/*.{vue,ts}', {
query: '?raw',
import: 'default',
eager: true,
})
const paths = Object.keys(sources)
expect(paths).not.toContain('/src/components/CodeBlock.vue')
expect(paths).toContain('/src/components/CodeText.vue')
const importers = paths.filter(
(path) => !path.endsWith('.spec.ts') && sources[path]!.includes('CodeBlock'),
)
expect(importers).toEqual([])
})
})