const fs = require('node:fs'); const test = require('node:test'); const assert = require('node:assert/strict'); const projects = fs.readFileSync('web/static/js/projects.js', 'utf8'); const styles = fs.readFileSync('web/static/css/style.css', 'utf8'); const chat = fs.readFileSync('web/static/js/chat.js', 'utf8'); const html = fs.readFileSync('web/templates/index.html', 'utf8'); const rbac = fs.readFileSync('web/static/js/rbac-guards.js', 'utf8'); const zh = fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8'); const en = fs.readFileSync('web/static/i18n/en-US.json', 'utf8'); function functionSource(source, name, nextName) { const start = source.indexOf(`function ${name}(`); const end = source.indexOf(`function ${nextName}(`, start); assert.notEqual(start, -1, `${name} should exist`); assert.notEqual(end, -1, `${nextName} should follow ${name}`); return source.slice(start, end); } function cssBlock(source, selector) { const match = source.match(new RegExp(`${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}`)); assert.ok(match, `${selector} style block should exist`); return match[0]; } test('无项目文件夹与普通项目共用悬浮和键盘聚焦预览', () => { const source = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem'); assert.match(source, /row\.addEventListener\('mouseenter', \(\) => scheduleShowProjectFolderPreview/); assert.match(source, /button\.addEventListener\('focus', \(\) => scheduleShowProjectFolderPreview/); assert.doesNotMatch( source, /if \(!isUnassigned\) \{\s*row\.addEventListener\('mouseenter', \(\) => scheduleShowProjectFolderPreview/ ); }); test('无项目预览隐藏测试范围和编辑入口', () => { const source = functionSource(projects, 'showProjectFolderPreview', 'scheduleShowProjectFolderPreview'); assert.match(source, /preview\.classList\.toggle\('is-unassigned', isUnassigned\)/); assert.match(source, /scopeRow\.hidden = isUnassigned \|\| !scope/); assert.match(source, /editButton\.hidden = isUnassigned/); assert.match(styles, /\.project-folder-preview\.is-unassigned \.project-folder-preview-edit\s*\{\s*display: none !important;/); assert.match(styles, /\.project-folder-preview\.is-unassigned \.project-folder-preview-details\s*\{\s*border-bottom: 0;/); }); test('项目标题提供受权限保护的新建项目入口', () => { const source = functionSource(projects, 'showNewProjectModalFromChatSidebar', 'saveProjectModal'); assert.match(html, /class="add-group-btn project-folders-add-btn"[\s\S]*?onclick="showNewProjectModalFromChatSidebar\(\)"/); assert.match(chat, /projectHeader\.querySelector\('\.project-folders-add-btn'\)/); assert.match(source, /window\._projectModalFromChat = false/); assert.match(source, /window\._projectModalFromChatSidebar = true/); assert.match(rbac, /showNewProjectModalFromChatSidebar: 'project:write'/); }); test('对话项目归属尚未加载时不会误展开无项目', () => { const resolver = functionSource(projects, 'resolveChatProjectFolderSelection', 'renderChatProjectFolders'); const render = functionSource(projects, 'renderChatProjectFolders', 'refreshChatProjectFolders'); assert.match(resolver, /if \(!chatProjectFolderContext\.ready\) return null/); assert.match(resolver, /if \(!conversation\) return null/); assert.match(resolver, /conversation\.projectId \|\| conversation\.project_id \|\| ''/); assert.match(render, /const selectedId = resolveChatProjectFolderSelection\(\)/); assert.match(render, /selectedId !== null && chatProjectFolderLastSelectionId !== selectedId/); }); test('项目按展开状态切换 Codex 风格的打开和关闭文件夹', () => { const icon = functionSource(projects, 'projectFolderIconMarkup', 'clampProjectPreviewText'); const folder = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem'); assert.match(icon, /const path = isExpanded/); assert.match(icon, /M3\.5 18V6\.5/); assert.match(icon, /M3\.5 7a2 2 0 0 1 2-2h4l2 2/); assert.match(folder, /icon\.className = 'project-folder-icon';/); assert.match(folder, /icon\.innerHTML = projectFolderIconMarkup\(isExpanded\);/); }); test('项目名仅在界面按 12 个 Unicode 字符省略并保留完整悬浮信息', () => { const formatterSource = functionSource(chat, 'formatProjectNameForDisplay', 'applyProjectNameDisplay'); const formatter = new Function( 'PROJECT_NAME_DISPLAY_MAX_CHARACTERS', `${formatterSource}; return formatProjectNameForDisplay;` )(12); const folder = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem'); const picker = functionSource(projects, 'appendChatProjectPanelItem', 'appendChatProjectPanelMessage'); const button = functionSource(projects, 'updateChatProjectButtonLabel', 'renderChatProjectPanel'); assert.equal(formatter('十二字符以内'), '十二字符以内'); assert.equal(formatter('这是一个非常非常长的项目名称'), '这是一个非常非常长的项目…'); assert.equal(formatter('😀😀😀😀😀😀😀😀😀😀😀😀😀'), '😀😀😀😀😀😀😀😀😀😀😀😀…'); assert.match(chat, /const PROJECT_NAME_DISPLAY_MAX_CHARACTERS = 12/); assert.match(folder, /applyProjectNameDisplay\(title, project\.name/); assert.match(projects, /applyProjectNameDisplay\(titleEl, text\)/); assert.match(picker, /title="\$\{escapeAttr\(fullName\)\}"/); assert.match(picker, /setAttribute\('aria-label', fullName\)/); assert.match(button, /applyProjectNameDisplay/); assert.match(styles, /\.project-selector-wrapper \.role-selector-text\s*\{[\s\S]*?max-width: 13em/); }); test('项目文件夹首批显示 6 个并通过加载更多按批追加', () => { const loadMore = functionSource(projects, 'loadMoreChatProjectFolders', 'renderChatProjectFolders'); const render = functionSource(projects, 'renderChatProjectFolders', 'refreshChatProjectFolders'); const search = functionSource(projects, 'handleProjectFolderSearch', 'clearProjectFolderSearch'); assert.match(projects, /const CHAT_PROJECT_FOLDER_PAGE_SIZE = 6/); assert.match(loadMore, /chatProjectFolderVisibleCount \+= CHAT_PROJECT_FOLDER_PAGE_SIZE/); assert.match(render, /const visibleFolders = folders\.slice\(0, chatProjectFolderVisibleCount\)/); assert.match(render, /appendChatProjectFoldersLoadMore\(list, folders\.length - visibleFolders\.length\)/); assert.match(render, /chatProjectFolderVisibleCount = selectedIndex \+ 1/); assert.match(search, /renderChatProjectFolders\(projectsCacheAll\)/); assert.match(styles, /\.project-folders-load-more\s*\{/); assert.match(zh, /"projectFoldersLoadMoreRemaining": "加载更多,剩余 \{\{count\}\} 个项目"/); assert.match(en, /"projectFoldersLoadMoreRemaining": "Load more, \{\{count\}\} projects remaining"/); }); test('对话悬浮预览显示本地年月日时分', () => { const age = functionSource(projects, 'formatProjectConversationPreviewAge', 'getProjectConversationModeLabel'); assert.match(age, /date\.getFullYear\(\)/); assert.match(age, /date\.getMonth\(\) \+ 1/); assert.match(age, /date\.getDate\(\)/); assert.match(age, /date\.getHours\(\)/); assert.match(age, /date\.getMinutes\(\)/); assert.match(age, /chat\.conversationPreviewDateTime/); assert.doesNotMatch(age, /elapsedMs|conversationPreviewDays|conversationPreviewHours/); assert.match(zh, /"conversationPreviewDateTime": "\{\{year\}\}年\{\{month\}\}月\{\{day\}\}日 \{\{hour\}\}:\{\{minute\}\}"/); assert.match(en, /"conversationPreviewDateTime": "\{\{year\}\}-\{\{month\}\}-\{\{day\}\} \{\{hour\}\}:\{\{minute\}\}"/); }); test('对话悬浮预览标题与时间分行显示并保留更多标题内容', () => { const titleStyles = cssBlock(styles, '.project-conversation-preview-title'); assert.match(styles, /\.conversation-sidebar\s*\{[\s\S]*?width: 320px;/); assert.match(styles, /\.project-conversation-preview\s*\{[\s\S]*?width: min\(340px, calc\(100vw - 32px\)\);/); assert.match(styles, /\.project-conversation-preview-header\s*\{[\s\S]*?flex-direction: column;[\s\S]*?align-items: flex-start;/); assert.match(titleStyles, /-webkit-line-clamp: 2;/); assert.doesNotMatch(titleStyles, /white-space: nowrap;/); }); test('对话悬浮预览使用美化后的代理模式徽标', () => { const projects = fs.readFileSync('web/static/js/projects.js', 'utf8'); assert.match(projects, /function getProjectConversationModeIconClass\(conversation\)/); assert.match(projects, /project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--default/); assert.match(projects, /agent-mode-logo__svg/); assert.match(projects, /