199 lines
15 KiB
JavaScript
199 lines
15 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import test from 'node:test'
|
|
import ts from 'typescript'
|
|
import * as vue from 'vue'
|
|
|
|
const layoutSource = readFileSync(new URL('./AppLayout.vue', import.meta.url), 'utf8')
|
|
const shellStyles = readFileSync(new URL('../styles/shell.css', import.meta.url), 'utf8')
|
|
const globalStyles = readFileSync(new URL('../styles/index.css', import.meta.url), 'utf8')
|
|
const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
|
|
const sidebarMenuSource = readFileSync(new URL('../components/ClientSidebarMenu.vue', import.meta.url), 'utf8')
|
|
const sidebarMenuNodeSource = readFileSync(new URL('../components/ClientSidebarMenuNode.vue', import.meta.url), 'utf8')
|
|
|
|
test('source contract: renders the grouped sidebar menu and mobile drawer controls', () => {
|
|
assert.match(layoutSource, /import \{ computed, nextTick, onBeforeUnmount, onMounted, ref, watch \} from 'vue'/)
|
|
assert.match(layoutSource, /import \{[\s\S]*?\bX\s*\} from '@lucide\/vue'/)
|
|
assert.match(layoutSource, /import ClientSidebarMenu from '@\/components\/ClientSidebarMenu\.vue'/)
|
|
assert.match(layoutSource, /:class="\{ 'mobile-menu-open': mobileMenuOpen, 'is-dashboard-route': route\.path === '\/' \}"/)
|
|
assert.match(layoutSource, /<button\s+v-if="mobileMenuOpen"[^>]*class="client-sidebar-scrim"[^>]*aria-label="关闭导航"[^>]*@click="closeMobileMenu"/s)
|
|
assert.match(layoutSource, /<aside\s+ref="sidebarRef"\s+id="client-sidebar"\s+class="client-sidebar"\s+:inert="!desktopViewport && !mobileMenuOpen"\s+:aria-hidden="!desktopViewport && !mobileMenuOpen \? 'true' : undefined"\s*>/)
|
|
assert.doesNotMatch(layoutSource, /<aside[^>]*aria-label=/)
|
|
assert.match(layoutSource, /<nav aria-label="客户工作台导航">/)
|
|
assert.match(layoutSource, /<div class="client-sidebar-brand">[\s\S]*?<RouterLink class="client-sidebar-brand-link" to="\/" aria-label="返回业务总览" @click="closeMobileMenu">[\s\S]*?alt="柏悦物流"/s)
|
|
assert.doesNotMatch(layoutSource, /client-sidebar-brand-copy/)
|
|
assert.match(layoutSource, /<button[^>]*ref="sidebarCloseButtonRef"[^>]*aria-label="关闭导航"[^>]*@click="closeMobileMenu"[^>]*>\s*<X :size="20" \/>/s)
|
|
assert.match(layoutSource, /<ClientSidebarMenu\s+:active-path="route\.path"\s+@navigate="closeMobileMenu"\s*\/>/s)
|
|
assert.match(layoutSource, /<button type="button" class="client-sidebar-logout" @click="handleLogout">[\s\S]*?退出登录/s)
|
|
assert.doesNotMatch(layoutSource, /v-for="item in clientNavigation"/)
|
|
assert.doesNotMatch(layoutSource, /from '@\/router'/)
|
|
})
|
|
|
|
test('source contract: exposes and opens the controlled mobile sidebar', () => {
|
|
assert.match(layoutSource, /const mobileMenuOpen = ref\(false\)/)
|
|
assert.match(layoutSource, /const menuButtonRef = ref<HTMLButtonElement>\(\)/)
|
|
assert.match(layoutSource, /const sidebarRef = ref<HTMLElement>\(\)/)
|
|
assert.match(layoutSource, /const sidebarCloseButtonRef = ref<HTMLButtonElement>\(\)/)
|
|
assert.match(layoutSource, /ref="menuButtonRef"[^>]*aria-label="打开导航"[^>]*aria-controls="client-sidebar"[^>]*:aria-expanded="mobileMenuOpen"[^>]*@click="openMobileMenu"/s)
|
|
})
|
|
|
|
test('source contract: moves focus into the drawer and restores it only after an open-to-closed transition', () => {
|
|
assert.match(layoutSource, /watch\(mobileMenuOpen, async \(open, wasOpen\) =>/)
|
|
assert.match(layoutSource, /await nextTick\(\)\s*if \(mobileMenuOpen\.value\) sidebarCloseButtonRef\.value\?\.focus\(\)/)
|
|
assert.match(layoutSource, /if \(wasOpen\) \{\s*await nextTick\(\)\s*if \(!mobileMenuOpen\.value && !desktopViewport\.value\) menuButtonRef\.value\?\.focus\(\)/s)
|
|
})
|
|
|
|
test('source contract: removes the header and main content from the accessibility tree while the drawer is open', () => {
|
|
assert.match(layoutSource, /<header\s+class="top-header"\s+:inert="mobileMenuOpen"\s+:aria-hidden="mobileMenuOpen \? 'true' : undefined"\s*>/)
|
|
assert.match(layoutSource, /<main\s+class="client-main"\s+:inert="mobileMenuOpen"\s+:aria-hidden="mobileMenuOpen \? 'true' : undefined"\s*>/)
|
|
})
|
|
|
|
test('source contract: keeps the prototype greeting and fixed message and profile utilities in the compact header', () => {
|
|
assert.match(layoutSource, /const pageTitle = computed\(\(\) => route\.meta\.title \|\| '业务总览'\)/)
|
|
assert.doesNotMatch(layoutSource, /const pageDescription/)
|
|
assert.match(layoutSource, /<p class="client-header-greeting">Hi, 客户<\/p>/)
|
|
assert.match(layoutSource, /<div class="client-user">[\s\S]*\{\{ auth\.user\?\.name \}\}/)
|
|
assert.match(layoutSource, /class="client-header-notifications" to="\/notifications" aria-label="打开我的消息"/)
|
|
assert.match(layoutSource, /class="client-header-profile"[\s\S]*to="\/account\/profile"[\s\S]*aria-label="打开我的信息"/)
|
|
assert.doesNotMatch(layoutSource, /<div class="client-user">[\s\S]*aria-label="退出登录"/)
|
|
assert.doesNotMatch(layoutSource, /Building2|tenant-name|tenantName/)
|
|
assert.doesNotMatch(routerSource, /showPageHeading|showTenantName/)
|
|
})
|
|
|
|
test('source contract: renders the complete route breadcrumb before page content', () => {
|
|
assert.match(layoutSource, /import \{ getFirstAccessiblePath, getRouteBreadcrumbs \} from '@\/navigation\/clientNavigation'/)
|
|
assert.match(layoutSource, /const breadcrumbs = computed\(\(\) => getRouteBreadcrumbs\(route\.path, String\(pageTitle\.value\)\)\)/)
|
|
assert.match(layoutSource, /<nav v-if="route\.path !== '\/'" class="client-breadcrumb" aria-label="页面路径">[\s\S]*?v-for="\(item, index\) in breadcrumbs"[\s\S]*?<RouterLink v-if="item\.path" :to="item\.path">[\s\S]*?<strong v-else aria-current="page">/)
|
|
assert.match(shellStyles, /\.client-breadcrumb \{[^}]*margin: 0 0 14px;/)
|
|
})
|
|
|
|
test('source contract: aligns the blue-tinted prototype shell around a compact sidebar and bounds the mobile header', () => {
|
|
assert.match(shellStyles, /\.top-header \{[^}]*inset: 0 0 auto 224px;[^}]*max-width: calc\(100vw - 224px\);[^}]*padding: 0 24px;/)
|
|
assert.match(shellStyles, /\.top-header \{[^}]*background: rgba\(255, 255, 255, \.98\)/)
|
|
assert.match(shellStyles, /\.client-sidebar \{[^}]*inset: 0 auto 0 0;[^}]*width: 224px;[^}]*border-right: 1px solid #dfe8f3;[^}]*background: linear-gradient\(180deg, #f5f8ff 0%, #edf5fc 100%\)/)
|
|
assert.match(shellStyles, /\.client-sidebar-brand \{[^}]*border-radius: 15px;[^}]*background: linear-gradient\(145deg, #0a2b50, #0b3d6c\)/)
|
|
assert.match(shellStyles, /\.client-main \{[^}]*padding: 126px 20px 40px;/)
|
|
assert.match(shellStyles, /\.is-dashboard-route \.client-main \{ padding-top: 88px; \}/)
|
|
assert.match(globalStyles, /@media \(max-width: 720px\) \{[\s\S]*\.top-header \{ inset: 0;[^}]*padding: 0 14px; \}/)
|
|
assert.match(globalStyles, /\.client-header-menu-button \{ display: grid; \}/)
|
|
assert.match(globalStyles, /\.client-header-greeting, \.client-user-copy \{ display: none; \}/)
|
|
})
|
|
|
|
test('source contract: keeps the dashboard free of a redundant workspace tab', () => {
|
|
assert.match(layoutSource, /<WorkspaceTabs\s+v-if="route\.path !== '\/'"/)
|
|
assert.match(layoutSource, /'is-dashboard-route': route\.path === '\/'/)
|
|
})
|
|
|
|
test('source contract: closes the mobile menu on route changes and Escape', () => {
|
|
assert.match(layoutSource, /watch\(\(\) => route\.fullPath, \(\) => \{\s*closeMobileMenu\(\)\s*registerCurrentRoute\(\)\s*\}\)/)
|
|
assert.match(layoutSource, /event\.key === 'Escape'/)
|
|
assert.match(layoutSource, /onMounted\(\(\) => \{[\s\S]*document\.addEventListener\('keydown', handleKeydown\)[\s\S]*\}\)/)
|
|
assert.match(layoutSource, /document\.removeEventListener\('keydown', handleKeydown\)/)
|
|
})
|
|
|
|
test('source contract: closes and unlocks the mobile drawer when the viewport enters desktop', () => {
|
|
assert.match(layoutSource, /const desktopViewport = ref\(true\)/)
|
|
assert.match(layoutSource, /let desktopMediaQuery: MediaQueryList \| undefined/)
|
|
assert.match(layoutSource, /function handleDesktopBreakpoint\(event: MediaQueryList \| MediaQueryListEvent\) \{\s*desktopViewport\.value = event\.matches\s*if \(event\.matches\) closeMobileMenu\(\)\s*\}/)
|
|
assert.match(layoutSource, /desktopMediaQuery = window\.matchMedia\('\(min-width: 721px\)'\)/)
|
|
assert.match(layoutSource, /desktopMediaQuery\.addEventListener\('change', handleDesktopBreakpoint\)/)
|
|
assert.match(layoutSource, /handleDesktopBreakpoint\(desktopMediaQuery\)/)
|
|
assert.match(layoutSource, /desktopMediaQuery\?\.removeEventListener\('change', handleDesktopBreakpoint\)/)
|
|
})
|
|
|
|
test('source contract: locks body scrolling while open and restores it on close or unmount', () => {
|
|
assert.match(layoutSource, /watch\(mobileMenuOpen, async \(open, wasOpen\) =>/)
|
|
assert.match(layoutSource, /previousBodyOverflow = document\.body\.style\.overflow/)
|
|
assert.match(layoutSource, /document\.body\.style\.overflow = 'hidden'/)
|
|
assert.match(layoutSource, /document\.body\.style\.overflow = previousBodyOverflow/)
|
|
assert.match(layoutSource, /onBeforeUnmount\(\(\) => \{[\s\S]*restoreBodyOverflow\(\)[\s\S]*\}\)/)
|
|
})
|
|
|
|
test('source contract: handles confirmation cancellation but rethrows unexpected logout errors', () => {
|
|
assert.match(layoutSource, /async function handleLogout\(\) \{\s*const logoutToken = auth.token\s*try \{/)
|
|
assert.match(layoutSource, /\} catch \(error\) \{\s*if \(error === 'cancel' \|\| error === 'close'\) return\s*throw error\s*\}/)
|
|
})
|
|
|
|
test('source contract: uses a native recursive disclosure list without Element menu roles', () => {
|
|
assert.doesNotMatch(sidebarMenuSource, /MenuInstance|<el-menu|openNavigationParentKeys/)
|
|
assert.doesNotMatch(sidebarMenuNodeSource, /<el-(?:menu-item|sub-menu)|role="(?:menubar|menu|menuitem)"/)
|
|
assert.match(sidebarMenuSource, /filterNavigationByPermissions\(clientNavigation, auth\.permissionCodes\)/)
|
|
assert.match(sidebarMenuSource, /const activeParentKeys = computed\(\(\) => new Set\(getNavigationParentKeys\(visibleNavigation\.value, props\.activePath\)\)\)/)
|
|
assert.match(sidebarMenuSource, /const openedKeys = ref<ReadonlySet<string>>\(new Set\(activeParentKeys\.value\)\)/)
|
|
assert.match(sidebarMenuSource, /openedKeys\.value = new Set\(\[\.\.\.openedKeys\.value, \.\.\.activeParentKeys\.value\]\)/)
|
|
assert.doesNotMatch(sidebarMenuSource, /if \(activeParentKeys\.value\.has\(key\)\) return/)
|
|
assert.match(sidebarMenuSource, /if \(next\.has\(key\)\) next\.delete\(key\)/)
|
|
assert.match(sidebarMenuSource, /<ul class="client-nav-menu">/)
|
|
assert.match(sidebarMenuSource, /:active-path="activePath"/)
|
|
assert.match(sidebarMenuSource, /:opened-keys="openedKeys"/)
|
|
assert.match(sidebarMenuSource, /:active-parent-keys="activeParentKeys"/)
|
|
assert.match(sidebarMenuSource, /@toggle="handleToggle"/)
|
|
assert.match(sidebarMenuNodeSource, /<li\s+class="client-nav-node"[\s\S]*?v-if="node\.children"/)
|
|
assert.match(sidebarMenuNodeSource, /'has-divider-before': node\.dividerBefore/)
|
|
assert.match(sidebarMenuNodeSource, /<button[\s\S]*?:aria-expanded="openedKeys\.has\(node\.key\)"[\s\S]*?:aria-controls="groupId"[\s\S]*?@click="emit\('toggle', node\.key\)"/)
|
|
assert.match(sidebarMenuNodeSource, /<ul\s+v-show="openedKeys\.has\(node\.key\)"\s+:id="groupId"\s+class="client-nav-submenu"\s*>/)
|
|
assert.match(sidebarMenuNodeSource, /<RouterLink[\s\S]*?:aria-current="node\.path === activePath \? 'page' : undefined"[\s\S]*?@click="emit\('navigate', node\.path\)"/)
|
|
assert.match(sidebarMenuNodeSource, /<component\s+:is="node\.icon"\s+class="client-sidebar-node-icon"\s+:size="level \? 16 : 20"\s+aria-hidden="true"/)
|
|
assert.doesNotMatch(sidebarMenuNodeSource, /<component\s+v-if="!level"/)
|
|
assert.match(sidebarMenuNodeSource, /@navigate="emit\('navigate', \$event\)"/)
|
|
assert.match(sidebarMenuNodeSource, /@toggle="emit\('toggle', \$event\)"/)
|
|
})
|
|
|
|
test('source contract: keeps navigation data out of the router module', () => {
|
|
assert.doesNotMatch(routerSource, /clientNavigationTree/)
|
|
assert.doesNotMatch(routerSource, /getNavigationLeaves/)
|
|
assert.doesNotMatch(routerSource, /export const clientNavigation/)
|
|
})
|
|
|
|
test('source contract: routes denied pages to an explicit 403 state', () => {
|
|
assert.match(routerSource, /name: 'forbidden'/)
|
|
assert.match(routerSource, /!auth\.hasPermission\(permission\)/)
|
|
assert.match(routerSource, /getFirstAccessiblePath\(auth\.permissionCodes\)/)
|
|
})
|
|
|
|
function logoutHarness(t) {
|
|
const script = layoutSource.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
|
const ast = ts.createSourceFile('layout.ts', script, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
|
const nodes = ast.statements.filter(node => (ts.isFunctionDeclaration(node) && node.name?.text === 'handleLogout') || (ts.isExpressionStatement(node) && node.getText(ast).startsWith('watch(') && node.getText(ast).includes('() => auth.isAuthenticated')))
|
|
const code = ts.transpileModule(nodes.map(node => node.getText(ast)).join('\n'), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
|
let rejectLogout, confirm
|
|
const remote = new Promise((_, no) => { rejectLogout = no })
|
|
const confirmation = new Promise(yes => { confirm = yes })
|
|
const calls = [], warnings = []
|
|
const auth = vue.reactive({ token: 'A', isAuthenticated: true, logout: () => { calls.push('logout'); auth.token = ''; auth.isAuthenticated = false; return remote } })
|
|
const scope = vue.effectScope(); t.after(() => scope.stop())
|
|
const handler = scope.run(() => new Function('watch', 'auth', 'workspace', 'unsaved', 'router', 'ElMessageBox', 'ElMessage', code + ';return handleLogout')(vue.watch, auth, { clear: () => calls.push('workspace') }, { hasDirty: false, clear: () => calls.push('unsaved') }, { replace: path => { calls.push(path); return Promise.resolve() } }, { confirm: () => confirmation }, { warning: value => warnings.push(value) }))
|
|
return { auth, handler, confirm, rejectLogout, calls, warnings }
|
|
}
|
|
|
|
test('logout navigates before remote completion and late failure cannot affect a new login', async t => {
|
|
const h = logoutHarness(t)
|
|
const clicked = h.handler(); h.confirm(); await clicked; await vue.nextTick()
|
|
assert.deepEqual(h.calls, ['logout', 'workspace', 'unsaved', '/login'])
|
|
assert.equal(h.auth.isAuthenticated, false)
|
|
h.auth.token = 'B'; h.auth.isAuthenticated = true; await vue.nextTick()
|
|
h.rejectLogout(new Error('SESSION_CHANGED')); await Promise.resolve(); await vue.nextTick()
|
|
assert.equal(h.auth.token, 'B'); assert.equal(h.auth.isAuthenticated, true)
|
|
assert.equal(h.calls.filter(item => item === '/login').length, 1)
|
|
assert.equal(h.warnings.length, 0)
|
|
})
|
|
|
|
test('cross-tab logout leaves protected route and stale confirmation never logs out a new identity', async t => {
|
|
const h = logoutHarness(t)
|
|
const clicked = h.handler()
|
|
h.auth.token = 'B'; h.confirm(); await clicked
|
|
assert.equal(h.calls.length, 0)
|
|
h.auth.token = ''; h.auth.isAuthenticated = false; await vue.nextTick()
|
|
assert.deepEqual(h.calls, ['workspace', 'unsaved', '/login'])
|
|
assert.match(layoutSource, /<div v-if="auth.isAuthenticated" class="client-shell"/)
|
|
})
|
|
|
|
test('remote logout failure reports incomplete server revocation without leaving protected content visible', async t => {
|
|
const h = logoutHarness(t)
|
|
const clicked = h.handler(); h.confirm(); await clicked; await vue.nextTick()
|
|
h.rejectLogout(new Error('offline')); await Promise.resolve()
|
|
assert.equal(h.auth.isAuthenticated, false)
|
|
assert.ok(h.calls.includes('/login'))
|
|
assert.deepEqual(h.warnings, ['已退出本页,服务器会话注销未完成'])
|
|
})
|