Install & Onboarding:
- Remove DEV_MODE=true from production ISO service file (auto-created
users, skipped password setup)
- Auto-install no longer overwrites rootfs service file with bad template
- Login.vue always checks auth.isSetup — shows password creation form
on fresh install without requiring dev build flag
- Deploy image-versions.sh to /opt/archipelago/scripts/ on installed nodes
- First-boot-containers sources image-versions.sh, runs podman as
archipelago user (rootless), enables linger + podman.socket
- Correct volume ownership (100000:100000 for rootless UID mapping)
Container Security:
- FileBrowser: add --cap-add=DAC_OVERRIDE for rootless podman volume access
- FileBrowser: add --read-only, /data volume for database, proper cmd args
- First-boot script matches backend config (security hardening + health check)
CI Pipeline:
- Add vue-tsc type check + vitest run to build-iso.yml (runs every push)
- Add post-install-tests.yml workflow (workflow_dispatch, SSH to target)
- Build report: set +eo pipefail, fix rootfs path, add || true guards
- Bundle run-post-install-tests.sh into ISO
E2E Test Suite (scripts/run-post-install-tests.sh):
- Phase 1: Install verification (files, services, podman, linger, DEV_MODE check)
- Phase 2: Onboarding flow (auth.isSetup, auth.setup, login, DID, complete)
- Phase 3: Container lifecycle (install 3 apps via package.install RPC,
verify running, stop, verify stopped, restart, verify running, health)
- Phase 4: Log verification (first-boot log, diagnostics, journal errors)
- Correct package.install params: {"id", "dockerImage"}
Frontend:
- Fix backdrop-filter tab-switch bug (keep animations paused during rebuild)
- Dashboard glitch animations paused during tab-hidden
- Gamepad nav: auto-focus first container on route change
- Tab roving: Left/Right on role="tab" cycles and activates sibling tabs
- ContainerApps: data-controller-launch on running app cards
- 515 tests passing (fixed 30 broken, added 19 new keyboard nav tests)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
509 lines
19 KiB
TypeScript
509 lines
19 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
// Mock vue-router
|
|
const mockRoute = { path: '/dashboard' }
|
|
const mockRouter = { push: vi.fn().mockResolvedValue(undefined) }
|
|
|
|
vi.mock('vue-router', () => ({
|
|
useRoute: () => mockRoute,
|
|
useRouter: () => mockRouter,
|
|
}))
|
|
|
|
// Mock stores
|
|
vi.mock('@/stores/controller', () => ({
|
|
useControllerStore: () => ({
|
|
setActive: vi.fn(),
|
|
setGamepadCount: vi.fn(),
|
|
isActive: false,
|
|
gamepadCount: 0,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/stores/spotlight', () => ({
|
|
useSpotlightStore: () => ({
|
|
isOpen: false,
|
|
close: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/stores/cli', () => ({
|
|
useCLIStore: () => ({
|
|
isOpen: false,
|
|
close: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/stores/appLauncher', () => ({
|
|
useAppLauncherStore: () => ({
|
|
isOpen: false,
|
|
close: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
// Mock useNavSounds
|
|
vi.mock('@/composables/useNavSounds', () => ({
|
|
playNavSound: vi.fn(),
|
|
}))
|
|
|
|
// ─── Module Export Tests ────────────────────────────────────────
|
|
|
|
describe('useControllerNav - module', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.useFakeTimers()
|
|
Object.defineProperty(navigator, 'getGamepads', {
|
|
value: vi.fn().mockReturnValue([null, null, null, null]),
|
|
configurable: true,
|
|
writable: true,
|
|
})
|
|
})
|
|
afterEach(() => { vi.useRealTimers() })
|
|
|
|
it('exports useControllerNav as a function', async () => {
|
|
const mod = await import('../useControllerNav')
|
|
expect(typeof mod.useControllerNav).toBe('function')
|
|
})
|
|
})
|
|
|
|
// ─── Nav Key Classification ─────────────────────────────────────
|
|
|
|
describe('useControllerNav - nav keys', () => {
|
|
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
|
|
|
it('classifies all arrow keys, Enter, and Escape as nav keys', () => {
|
|
for (const key of navKeys) {
|
|
expect(navKeys.includes(key)).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('rejects non-nav keys', () => {
|
|
for (const key of ['a', 'Space', 'Tab', 'Shift', 'F1', 'Delete']) {
|
|
expect(navKeys.includes(key)).toBe(false)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ─── Route Pattern Tests ────────────────────────────────────────
|
|
|
|
describe('useControllerNav - route patterns', () => {
|
|
it('recognizes detail page patterns for Escape-back behavior', () => {
|
|
const pattern = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/
|
|
expect(pattern.test('/apps/bitcoin')).toBe(true)
|
|
expect(pattern.test('/marketplace/electrs')).toBe(true)
|
|
expect(pattern.test('/cloud/photos')).toBe(true)
|
|
expect(pattern.test('/dashboard')).toBe(false)
|
|
expect(pattern.test('/apps')).toBe(false)
|
|
})
|
|
|
|
it('recognizes all page type patterns for right-arrow targets', () => {
|
|
expect(/^\/dashboard(\/)?$/.test('/dashboard')).toBe(true)
|
|
expect(/^\/dashboard(\/)?$/.test('/dashboard/')).toBe(true)
|
|
expect(/^\/dashboard\/(apps|marketplace)(\/|$)/.test('/dashboard/apps')).toBe(true)
|
|
expect(/^\/dashboard\/(apps|marketplace)(\/|$)/.test('/dashboard/marketplace')).toBe(true)
|
|
expect(/^\/dashboard\/cloud(\/|$)/.test('/dashboard/cloud')).toBe(true)
|
|
expect(/^\/dashboard\/server(\/|$)/.test('/dashboard/server')).toBe(true)
|
|
expect(/^\/dashboard\/web5(\/|$)/.test('/dashboard/web5')).toBe(true)
|
|
expect(/^\/dashboard\/settings(\/|$)/.test('/dashboard/settings')).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ─── Focusable Element Detection ────────────────────────────────
|
|
|
|
describe('useControllerNav - focusable elements', () => {
|
|
afterEach(() => { document.body.innerHTML = '' })
|
|
|
|
it('finds buttons, links, and inputs as focusable', () => {
|
|
document.body.innerHTML = `
|
|
<div>
|
|
<button>Click</button>
|
|
<a href="/test">Link</a>
|
|
<input type="text" />
|
|
<button disabled>Disabled</button>
|
|
</div>
|
|
`
|
|
const focusable = document.querySelectorAll(
|
|
'a[href], button:not([disabled]), input:not([disabled])'
|
|
)
|
|
expect(focusable.length).toBe(3)
|
|
})
|
|
|
|
it('finds elements with tabindex as focusable', () => {
|
|
document.body.innerHTML = `
|
|
<div tabindex="0">Container</div>
|
|
<div tabindex="-1">Hidden</div>
|
|
<div>Not focusable</div>
|
|
`
|
|
const focusable = document.querySelectorAll('[tabindex]:not([tabindex="-1"])')
|
|
expect(focusable.length).toBe(1)
|
|
})
|
|
|
|
it('finds data-controller-container as focusable', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-container tabindex="0">Card 1</div>
|
|
<div data-controller-container tabindex="0">Card 2</div>
|
|
<div>Regular div</div>
|
|
`
|
|
const containers = document.querySelectorAll('[data-controller-container]')
|
|
expect(containers.length).toBe(2)
|
|
})
|
|
|
|
it('excludes data-controller-ignore elements', () => {
|
|
document.body.innerHTML = `
|
|
<button>Visible</button>
|
|
<button data-controller-ignore>Ignored</button>
|
|
<div data-controller-ignore><button>Also ignored</button></div>
|
|
`
|
|
const all = Array.from(document.querySelectorAll<HTMLElement>('button:not([disabled])')).filter(
|
|
el => !el.hasAttribute('data-controller-ignore') && !el.closest('[data-controller-ignore]')
|
|
)
|
|
expect(all.length).toBe(1)
|
|
expect(all[0]?.textContent).toBe('Visible')
|
|
})
|
|
})
|
|
|
|
// ─── Zone Detection ─────────────────────────────────────────────
|
|
|
|
describe('useControllerNav - zones', () => {
|
|
afterEach(() => { document.body.innerHTML = '' })
|
|
|
|
it('sidebar elements belong to sidebar zone', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-zone="sidebar"><a href="/home">Home</a></div>
|
|
<div data-controller-zone="main"><button>Action</button></div>
|
|
`
|
|
const link = document.querySelector('a')!
|
|
const btn = document.querySelector('button')!
|
|
expect(link.closest('[data-controller-zone="sidebar"]')).toBeTruthy()
|
|
expect(btn.closest('[data-controller-zone="main"]')).toBeTruthy()
|
|
expect(link.closest('[data-controller-zone="main"]')).toBeNull()
|
|
})
|
|
|
|
it('main elements belong to main zone', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-zone="sidebar"><a href="/">Nav</a></div>
|
|
<div data-controller-zone="main"><div data-controller-container tabindex="0"><button>Inner</button></div></div>
|
|
`
|
|
const inner = document.querySelector('button')!
|
|
expect(inner.closest('[data-controller-zone="main"]')).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
// ─── Container Drill-in/Drill-out ───────────────────────────────
|
|
|
|
describe('useControllerNav - container behavior', () => {
|
|
afterEach(() => { document.body.innerHTML = '' })
|
|
|
|
it('container elements are identified via data-controller-container', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-container tabindex="0">
|
|
<button>Stop</button>
|
|
<button>Launch</button>
|
|
</div>
|
|
`
|
|
const container = document.querySelector('[data-controller-container]')
|
|
expect(container).toBeTruthy()
|
|
expect(container?.getAttribute('tabindex')).toBe('0')
|
|
})
|
|
|
|
it('inner buttons are found within containers', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-container tabindex="0">
|
|
<button>Stop</button>
|
|
<button data-controller-launch-btn>Launch</button>
|
|
</div>
|
|
`
|
|
const container = document.querySelector('[data-controller-container]')!
|
|
const inner = Array.from(container.querySelectorAll<HTMLElement>('button:not([disabled])')).filter(
|
|
el => el !== container
|
|
)
|
|
expect(inner.length).toBe(2)
|
|
})
|
|
|
|
it('isInsideContainer detects when element is nested in a container', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-container tabindex="0">
|
|
<button id="inner">Action</button>
|
|
</div>
|
|
<button id="outer">Outside</button>
|
|
`
|
|
const inner = document.getElementById('inner')!
|
|
const outer = document.getElementById('outer')!
|
|
const innerContainer = inner.closest('[data-controller-container]')
|
|
|
|
expect(innerContainer).toBeTruthy()
|
|
expect(innerContainer !== inner).toBe(true)
|
|
expect(outer.closest('[data-controller-container]')).toBeNull()
|
|
})
|
|
|
|
it('data-controller-launch marks a card for Enter=launch behavior', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-container data-controller-launch tabindex="0">
|
|
<button data-controller-launch-btn>Launch</button>
|
|
</div>
|
|
`
|
|
const container = document.querySelector('[data-controller-container]')!
|
|
expect(container.hasAttribute('data-controller-launch')).toBe(true)
|
|
const btn = container.querySelector('[data-controller-launch-btn]')
|
|
expect(btn).toBeTruthy()
|
|
})
|
|
|
|
it('data-controller-install marks a card for Enter=install behavior', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-container data-controller-install tabindex="0">
|
|
<button data-controller-install-btn>Install</button>
|
|
</div>
|
|
`
|
|
const container = document.querySelector('[data-controller-container]')!
|
|
expect(container.hasAttribute('data-controller-install')).toBe(true)
|
|
const btn = container.querySelector('[data-controller-install-btn]')
|
|
expect(btn).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
// ─── Spatial Navigation (findNearestInDirection) ────────────────
|
|
|
|
describe('useControllerNav - spatial navigation logic', () => {
|
|
afterEach(() => { document.body.innerHTML = '' })
|
|
|
|
it('direction filtering works correctly', () => {
|
|
// Simulate the direction check logic from findNearestInDirection
|
|
const fromRect = { left: 200, right: 350, top: 0, bottom: 150, width: 150, height: 150 }
|
|
const threshold = 50
|
|
|
|
// Element to the left
|
|
const leftRect = { left: 0, right: 150, top: 0, bottom: 150 }
|
|
expect(leftRect.right <= fromRect.left + threshold).toBe(true) // is to the left
|
|
|
|
// Element to the right
|
|
const rightRect = { left: 400, right: 550, top: 0, bottom: 150 }
|
|
expect(rightRect.left >= fromRect.right - threshold).toBe(true) // is to the right
|
|
|
|
// Element below
|
|
const belowRect = { left: 200, right: 350, top: 200, bottom: 350 }
|
|
expect(belowRect.top >= fromRect.bottom - threshold).toBe(true) // is below
|
|
|
|
// Element above (from below position)
|
|
expect(fromRect.bottom <= belowRect.top + threshold).toBe(true) // fromRect is above belowRect
|
|
})
|
|
|
|
it('overlap scoring prefers aligned elements', () => {
|
|
// Two elements to the right: one aligned, one offset
|
|
const fromRect = { left: 0, right: 150, top: 50, bottom: 200, width: 150, height: 150 }
|
|
|
|
// Aligned (same row, full overlap on Y axis)
|
|
const alignedRect = { left: 200, right: 350, top: 50, bottom: 200, width: 150, height: 150 }
|
|
const alignedOverlap = Math.max(0, Math.min(fromRect.bottom, alignedRect.bottom) - Math.max(fromRect.top, alignedRect.top))
|
|
|
|
// Offset (partially overlapping on Y axis)
|
|
const offsetRect = { left: 200, right: 350, top: 160, bottom: 310, width: 150, height: 150 }
|
|
const offsetOverlap = Math.max(0, Math.min(fromRect.bottom, offsetRect.bottom) - Math.max(fromRect.top, offsetRect.top))
|
|
|
|
expect(alignedOverlap).toBeGreaterThan(offsetOverlap) // aligned element wins
|
|
expect(alignedOverlap).toBe(150) // full overlap
|
|
expect(offsetOverlap).toBe(40) // partial overlap
|
|
})
|
|
})
|
|
|
|
// ─── Gamepad Detection ──────────────────────────────────────────
|
|
|
|
describe('useControllerNav - gamepad', () => {
|
|
it('counts connected gamepads', () => {
|
|
const gamepads = [
|
|
{ connected: true } as Gamepad,
|
|
null,
|
|
{ connected: true } as Gamepad,
|
|
null,
|
|
]
|
|
expect(gamepads.filter(g => g?.connected).length).toBe(2)
|
|
})
|
|
|
|
it('handles null gamepad list', () => {
|
|
const getCount = (gp: (Gamepad | null)[] | null): number =>
|
|
gp ? gp.filter(g => g?.connected).length : 0
|
|
expect(getCount(null)).toBe(0)
|
|
})
|
|
|
|
it('handles all-null gamepad list', () => {
|
|
const gamepads: (Gamepad | null)[] = [null, null, null, null]
|
|
expect(Array.from(gamepads).filter(g => g?.connected).length).toBe(0)
|
|
})
|
|
})
|
|
|
|
// ─── Sidebar Navigation ─────────────────────────────────────────
|
|
|
|
describe('useControllerNav - sidebar behavior', () => {
|
|
afterEach(() => { document.body.innerHTML = '' })
|
|
|
|
it('sidebar has linear up/down navigation with wrap', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-zone="sidebar">
|
|
<a href="/dashboard" class="nav-tab-active">Home</a>
|
|
<a href="/dashboard/apps">Apps</a>
|
|
<a href="/dashboard/cloud">Cloud</a>
|
|
<button>Logout</button>
|
|
</div>
|
|
`
|
|
const items = document.querySelectorAll('[data-controller-zone="sidebar"] a, [data-controller-zone="sidebar"] button')
|
|
expect(items.length).toBe(4)
|
|
|
|
// Wrap: last→first
|
|
const lastIdx = items.length - 1
|
|
const nextIdx = lastIdx >= items.length - 1 ? 0 : lastIdx + 1
|
|
expect(nextIdx).toBe(0) // wraps to Home
|
|
|
|
// Wrap: first→last
|
|
const firstIdx = 0
|
|
const prevIdx = firstIdx <= 0 ? items.length - 1 : firstIdx - 1
|
|
expect(prevIdx).toBe(3) // wraps to Logout
|
|
})
|
|
|
|
it('left arrow from main goes to active sidebar tab', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-zone="sidebar">
|
|
<a href="/dashboard">Home</a>
|
|
<a href="/dashboard/apps" class="nav-tab-active">Apps</a>
|
|
</div>
|
|
<div data-controller-zone="main">
|
|
<button id="mainBtn">Action</button>
|
|
</div>
|
|
`
|
|
const activeTab = document.querySelector('.nav-tab-active')
|
|
expect(activeTab).toBeTruthy()
|
|
expect(activeTab?.textContent).toBe('Apps')
|
|
})
|
|
})
|
|
|
|
// ─── Auto-focus Behavior ─────────────────────────────────────────
|
|
|
|
describe('useControllerNav - auto-focus', () => {
|
|
afterEach(() => { document.body.innerHTML = '' })
|
|
|
|
it('first container in main zone is the auto-focus target', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-zone="main">
|
|
<div data-controller-container tabindex="0" id="first">Card 1</div>
|
|
<div data-controller-container tabindex="0" id="second">Card 2</div>
|
|
</div>
|
|
`
|
|
const mainZone = document.querySelector('[data-controller-zone="main"]')
|
|
const firstContainer = mainZone?.querySelector<HTMLElement>('[data-controller-container]')
|
|
expect(firstContainer?.id).toBe('first')
|
|
})
|
|
|
|
it('does not auto-focus when input is active', () => {
|
|
document.body.innerHTML = `
|
|
<input id="search" type="text" />
|
|
<div data-controller-zone="main">
|
|
<div data-controller-container tabindex="0">Card</div>
|
|
</div>
|
|
`
|
|
const input = document.getElementById('search') as HTMLInputElement
|
|
input.focus()
|
|
// Auto-focus should skip when input is active
|
|
expect(document.activeElement?.tagName).toBe('INPUT')
|
|
})
|
|
})
|
|
|
|
// ─── Tab Roving Behavior ─────────────────────────────────────────
|
|
|
|
describe('useControllerNav - tab roving', () => {
|
|
afterEach(() => { document.body.innerHTML = '' })
|
|
|
|
it('role="tab" elements are found as siblings within tablist', () => {
|
|
document.body.innerHTML = `
|
|
<div role="tablist">
|
|
<button role="tab" id="tab1">Dashboard</button>
|
|
<button role="tab" id="tab2">Setup</button>
|
|
</div>
|
|
`
|
|
const tabs = document.querySelectorAll('[role="tab"]')
|
|
expect(tabs.length).toBe(2)
|
|
const tablist = tabs[0]?.closest('[role="tablist"]')
|
|
expect(tablist).toBeTruthy()
|
|
})
|
|
|
|
it('tab roving cycles right: first → second → first', () => {
|
|
const tabs = ['tab1', 'tab2']
|
|
// Right from index 0
|
|
expect((0 + 1) % tabs.length).toBe(1)
|
|
// Right from index 1 (wraps)
|
|
expect((1 + 1) % tabs.length).toBe(0)
|
|
})
|
|
|
|
it('tab roving cycles left: second → first → second', () => {
|
|
const tabs = ['tab1', 'tab2']
|
|
// Left from index 1
|
|
expect((1 - 1 + tabs.length) % tabs.length).toBe(0)
|
|
// Left from index 0 (wraps)
|
|
expect((0 - 1 + tabs.length) % tabs.length).toBe(1)
|
|
})
|
|
|
|
it('tab roving falls back to parent when no role="tablist" wrapper', () => {
|
|
document.body.innerHTML = `
|
|
<div class="mode-switcher">
|
|
<button role="tab" id="tab1">Dashboard</button>
|
|
<button role="tab" id="tab2">Setup</button>
|
|
</div>
|
|
`
|
|
const tab = document.getElementById('tab1')!
|
|
// No role="tablist" — falls back to parentElement
|
|
const tablist = tab.closest('[role="tablist"]') ?? tab.parentElement
|
|
expect(tablist).toBeTruthy()
|
|
const tabs = tablist!.querySelectorAll('[role="tab"]:not([disabled])')
|
|
expect(tabs.length).toBe(2)
|
|
})
|
|
})
|
|
|
|
// ─── Scroll Behavior ──────────────────────────────────────────────
|
|
|
|
describe('useControllerNav - scroll helpers', () => {
|
|
it('focused elements have scrollIntoView method', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-zone="main">
|
|
<div data-controller-container tabindex="0">Card 1</div>
|
|
</div>
|
|
`
|
|
const card = document.querySelector('[data-controller-container]') as HTMLElement
|
|
// jsdom provides scrollIntoView as a no-op
|
|
expect(card).toBeTruthy()
|
|
expect(card.focus).toBeDefined()
|
|
})
|
|
})
|
|
|
|
// ─── Container Grid Navigation ────────────────────────────────────
|
|
|
|
describe('useControllerNav - grid navigation patterns', () => {
|
|
afterEach(() => { document.body.innerHTML = '' })
|
|
|
|
it('marketplace 3-column grid has correct spatial relationships', () => {
|
|
// Simulate a 3-column grid (like marketplace)
|
|
document.body.innerHTML = `
|
|
<div data-controller-zone="main">
|
|
<div data-controller-container tabindex="0" id="c1" style="position:absolute;left:0;top:0;width:200px;height:200px">App 1</div>
|
|
<div data-controller-container tabindex="0" id="c2" style="position:absolute;left:220px;top:0;width:200px;height:200px">App 2</div>
|
|
<div data-controller-container tabindex="0" id="c3" style="position:absolute;left:440px;top:0;width:200px;height:200px">App 3</div>
|
|
<div data-controller-container tabindex="0" id="c4" style="position:absolute;left:0;top:220px;width:200px;height:200px">App 4</div>
|
|
<div data-controller-container tabindex="0" id="c5" style="position:absolute;left:220px;top:220px;width:200px;height:200px">App 5</div>
|
|
<div data-controller-container tabindex="0" id="c6" style="position:absolute;left:440px;top:220px;width:200px;height:200px">App 6</div>
|
|
</div>
|
|
`
|
|
const containers = document.querySelectorAll('[data-controller-container]')
|
|
expect(containers.length).toBe(6)
|
|
// Row 1: c1, c2, c3; Row 2: c4, c5, c6
|
|
expect(containers[0]?.id).toBe('c1')
|
|
expect(containers[3]?.id).toBe('c4')
|
|
})
|
|
|
|
it('home 2-column grid has correct container count', () => {
|
|
document.body.innerHTML = `
|
|
<div data-controller-zone="main">
|
|
<div data-controller-container tabindex="0">My Apps</div>
|
|
<div data-controller-container tabindex="0">Wallet</div>
|
|
<div data-controller-container tabindex="0">System</div>
|
|
</div>
|
|
`
|
|
const containers = document.querySelectorAll('[data-controller-container]')
|
|
expect(containers.length).toBe(3)
|
|
})
|
|
})
|