// Запуск: SHINE_UI_TEST_DEPS=/путь/к/node_modules node --experimental-vm-modules shine-UI/channel-design-check.mjs // Зависимости проверки (не приложения): jsdom, postcss. import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; const require = createRequire(path.join(process.env.SHINE_UI_TEST_DEPS || process.cwd() + '/node_modules', '_check.cjs')); const { JSDOM } = require('jsdom'); const postcss = require('postcss'); const ui = path.dirname(fileURLToPath(import.meta.url)); const dom = new JSDOM('
', { url: 'https://shine.test/#channel', pretendToBeVisual: true, runScripts: 'outside-only' }); const context = dom.getInternalVMContext(); const { window } = dom; const { document } = window; const state = { session: { login: 'alice', isAuthorized: true }, entrySettings: {} }; let placed = 0; let authPrompts = 0; const mocks = { 'arweave-attachment-manager.js': { markArweaveAttachmentPlaced: () => placed++, openArweaveAttachmentManager: async () => ({ name: 'photo.jpg', size: 1024, ar: 'test' }) }, 'attachment-format.js': { MAX_MESSAGE_ATTACHMENTS: 10, composeMessageWithAttachments: (text) => text }, 'state.js': { state }, 'ui-error-texts.js': { toUserMessage: (error) => error.message }, 'avatar-image.js': { renderUserAvatar: () => document.createElement('span') }, 'auth-required-modal.js': { openAuthRequiredModal: () => authPrompts++ }, }; async function moduleAt(relative, mockImports = false) { const module = new vm.SourceTextModule(fs.readFileSync(path.join(ui, relative), 'utf8'), { context }); await module.link(async (specifier) => { const values = mockImports && mocks[path.basename(specifier)]; assert.ok(values, `Неожиданный импорт ${specifier}`); return new vm.SyntheticModule(Object.keys(values), function () { for (const [key, value] of Object.entries(values)) this.setExport(key, value); }, { context }); }); await module.evaluate(); return module.namespace; } const tick = () => new Promise((resolve) => setTimeout(resolve, 35)); const query = (selector) => document.querySelector(selector); function input(value) { const field = query('textarea'); field.value = value; field.dispatchEvent(new window.Event('input', { bubbles: true })); } function key(key, options = {}) { const event = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options }); document.activeElement.dispatchEvent(event); return event; } const { openChannelEditor } = await moduleAt('js/components/channel-editor.js', true); let sent = []; const options = { key: 'channel:one:message:1', onSubmit: async (value) => sent.push(value) }; query('#opener').focus(); let editor = openChannelEditor(options); await tick(); assert.equal(document.activeElement.tagName, 'TEXTAREA'); assert.equal(query('.channel-editor__submit').disabled, true); input('Первая строка\nВторая строка'); assert.equal(key('Enter').defaultPrevented, false); assert.equal(sent.length, 0); editor.close(); await tick(); assert.equal(document.activeElement.id, 'opener'); editor = openChannelEditor(options); await tick(); assert.equal(query('textarea').value, 'Первая строка\nВторая строка'); key('Enter', { ctrlKey: true, isComposing: true }); assert.equal(sent.length, 0); key('Enter', { ctrlKey: true }); await tick(); assert.equal(sent.length, 1); assert.equal(query('.channel-editor-overlay'), null); editor = openChannelEditor(options); assert.equal(query('textarea').value, ''); editor.close(); await tick(); editor = openChannelEditor({ ...options, onSubmit: async () => { throw new Error('Нет соединения'); } }); await tick(); input('Не потерять'); query('.channel-editor__submit').click(); await tick(); assert.equal(query('[role="alert"]').textContent, 'Нет соединения'); assert.equal(query('textarea').value, 'Не потерять'); assert.equal(query('.channel-editor__submit').disabled, false); query('.channel-editor__close').focus(); key('Tab', { shiftKey: true }); assert.equal(document.activeElement, query('.channel-editor__submit')); editor.close(); await tick(); state.session.login = 'bob'; editor = openChannelEditor(options); assert.equal(query('textarea').value, ''); editor.close(); await tick(); state.session.login = 'alice'; editor = openChannelEditor(options); assert.equal(query('textarea').value, 'Не потерять'); query('.channel-editor__clear').click(); assert.equal(query('textarea').value, ''); query('.channel-editor__attach').click(); await tick(); assert.equal(document.querySelectorAll('.channel-editor__attachment').length, 1); assert.equal(query('.channel-editor__submit').disabled, false); query('.channel-editor__submit').click(); await tick(); assert.equal(placed, 1); editor = openChannelEditor(options); await tick(); window.history.back(); await tick(); assert.equal(query('.channel-editor-overlay'), null); editor.close(); state.session.isAuthorized = false; assert.equal(openChannelEditor(options), null); assert.equal(authPrompts, 1); const theme = await moduleAt('js/services/theme-service.js'); assert.equal(theme.applyThemeMode('system').resolved, 'dark'); theme.setThemeMode('light'); assert.equal(document.documentElement.dataset.theme, 'light'); theme.setThemeMode('dark'); assert.equal(document.documentElement.dataset.theme, 'dark'); const scroll = await moduleAt('js/services/channel-view-state.js'); query('#app-screen').scrollTop = 123; scroll.rememberChannelPosition('alice:channel:one'); query('#app-screen').scrollTop = 0; scroll.restoreChannelPosition(scroll.readChannelPosition('alice:channel:one')); assert.equal(query('#app-screen').scrollTop, 123); assert.equal(scroll.readChannelPosition('bob:channel:one'), undefined); const { createDropdownMenu } = await moduleAt('js/components/dropdown-menu.js'); const menu = createDropdownMenu({ anchorEl: query('#opener'), items: [{ label: 'Первый' }, { label: 'Второй' }] }); menu.open(); assert.equal(document.activeElement.textContent, 'Первый'); key('ArrowDown'); assert.equal(document.activeElement.textContent, 'Второй'); key('Escape'); assert.equal(document.activeElement.id, 'opener'); menu.destroy(); menu.destroy(); assert.equal(query('.dropdown-portal'), null); console.log('PASS: редактор — клавиши, фокус, черновики, аккаунты, ошибка, вложение, отправка, Назад; темы, позиция чтения, меню.'); // DOM producer страниц исполняется с изолированным API: тест не пишет в блокчейн. let replyOptions; const reactionState = new Map(); const iconModule = await moduleAt('js/components/ui-icon.js'); const pageValues = { state, authService: {}, channels: [], readChannelNotificationsState: () => ({}), createSkeletonCard: () => document.createElement('div'), createTopBar: ({ center }) => { const header = document.createElement('header'); if (center) header.append(center); return header; }, iconHtml: iconModule.iconHtml, parseMessageAttachments: (text) => ({ text: text || '', attachments: [] }), parseDmTechBlocks: (text) => ({ displayText: text, visibleText: text }), loadProfileSnapshot: async () => null, renderAvatar: () => document.createElement('span'), renderUserAvatar: () => document.createElement('span'), formatRelativeTime: () => 'сейчас', escapeHtml: (text) => String(text || ''), openChannelEditor: (options) => { replyOptions = options; }, getMessageReactionState: (target) => reactionState.get(target.blockHash) || 'unliked', setMessageReactionState: (target, value) => reactionState.set(target.blockHash, value), makeShineMessageRoute: () => 'thread:test', attachMessageMenu: (card, head, items) => { const button = document.createElement('button'); head.append(button); card.testMenu = items; const menu = createDropdownMenu({ anchorEl: button, items }); card.cleanup = () => menu.destroy(); }, }; async function pageProducer(file, exported) { const source = fs.readFileSync(path.join(ui, 'js/pages', file), 'utf8'); const imports = new Map(); for (const match of source.matchAll(/import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) { imports.set(match[2], [...new Set([...(imports.get(match[2]) || []), ...match[1].split(',').map((name) => name.trim().split(/\s+as\s+/)[0]).filter(Boolean)])]); } const module = new vm.SourceTextModule(source + (exported === 'render' ? '' : `\nexport { ${exported} };`), { context }); await module.link(async (specifier) => { const names = imports.get(specifier); assert.ok(names, specifier); return new vm.SyntheticModule(names, function () { names.forEach((name) => this.setExport(name, pageValues[name] || (() => {}))); }, { context }); }); await module.evaluate(); return module.namespace[exported]; } state.session.isAuthorized = true; state.session.login = 'alice'; const ref = { blockchainName: 'alice-1', blockNumber: 2, blockHash: 'a'.repeat(64) }; let likes = 0; let navigations = 0; const handlers = { selector: { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, navigate: () => navigations++, onToggleLike: async () => likes++, onReply: async () => {}, onEdit: async () => {}, isActive: () => true }; const renderPost = await pageProducer('channel-view.js', 'renderPostCard'); const post = renderPost({ body: 'Публикация', authorLogin: 'alice', localNumber: 1, messageRef: ref, isOwnMessage: true, msgSubType: 10, likesCount: 7, repliesCount: 2 }, handlers); document.body.append(post); assert.equal(post.querySelector('.channel-message-body').textContent, 'Публикация'); assert.equal(post.querySelectorAll('.channel-action-counter').length, 3); assert.ok(post.testMenu.some((item) => item.label === 'Редактировать')); assert.ok(post.testMenu.some((item) => item.label === 'Удалить')); post.querySelector('.channel-action-like').click(); await tick(); assert.equal(likes, 1); assert.equal(post.querySelector('.channel-action-like').disabled, false); post.querySelector('.channel-action-reply').click(); assert.equal(replyOptions.context.author, 'alice'); assert.equal(replyOptions.context.text, 'Публикация'); assert.equal(navigations, 0); post.cleanup(); post.cleanup(); post.remove(); const renderNode = await pageProducer('channel-thread-view.js', 'renderNodeCard'); const node = renderNode({ authorBlockchainName: ref.blockchainName, messageRef: ref, authorLogin: 'bob', text: 'Ответ', msgSubType: 10, likesCount: 3 }, '', handlers, 2); document.body.append(node); assert.equal(node.querySelector('.channel-message-body').textContent, 'Ответ'); assert.ok(!node.testMenu.some((item) => item.label === 'Удалить')); node.querySelector('.thread-reply-btn').click(); assert.equal(replyOptions.context.author, 'bob'); node.cleanup(); node.cleanup(); node.remove(); console.log('PASS: карточки канала/ветки — текст, общий лайк без диалога, контекст ответа, меню по авторству, cleanup.'); pageValues.authService.onEvent = () => () => {}; pageValues.toUserMessage = (error) => error.message; pageValues.rememberChannelPosition = scroll.rememberChannelPosition; pageValues.readChannelPosition = scroll.readChannelPosition; pageValues.restoreChannelPosition = scroll.restoreChannelPosition; state.channelsFeed = {}; state.channelIndex = {}; const chrome = { setTopbar() {}, setComposer() {} }; for (const [file, method, params, payload] of [ ['channels-list.js', 'listSubscriptionsFeed', {}, { ownedChannels: [], followedUsersChannels: [], followedChannels: [] }], ['channel-view.js', 'getChannelMessages', { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, { channel: { ownerLogin: 'alice', channelName: 'news' }, messages: [] }], ['channel-thread-view.js', 'getMessageThread', { messageBlockchainName: 'alice-1', messageBlockNumber: 2, messageBlockHash: ref.blockHash }, { focus: null, descendants: [], ancestors: [] }], ]) { let finish; pageValues.authService[method] = () => new Promise((resolve) => { finish = resolve; }); const render = await pageProducer(file, 'render'); const screen = render({ route: { params }, navigate() {}, chrome }); query('#app-screen').append(screen); await tick(); assert.ok(finish, `${file}: запрос начат`); screen.cleanup(); screen.cleanup(); const markup = screen.innerHTML; finish(payload); await tick(); assert.equal(screen.innerHTML, markup, `${file}: async после dispose`); assert.equal(query('#app-screen').firstElementChild, screen, `${file}: root identity`); screen.remove(); pageValues.authService[method] = async () => payload; const loaded = render({ route: { params }, navigate() {}, chrome }); query('#app-screen').append(loaded); await tick(); const expected = file === 'channels-list.js' ? '.channels-empty-state' : file === 'channel-view.js' ? '.channel-feed' : '.thread-block'; assert.ok(loaded.querySelector(expected), `${file}: успешная загрузка ${loaded.textContent}`); if (loaded.refresh) await loaded.refresh(); assert.equal(query('#app-screen').firstElementChild, loaded, `${file}: refresh сохраняет root`); loaded.cleanup(); loaded.remove(); } console.log('PASS: список/канал/ветка — стабильный root, идемпотентный cleanup, поздний API-ответ после dispose.'); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)]); } const scripts = walk(path.join(ui, 'js')).filter((file) => file.endsWith('.js')); for (const file of scripts) { const source = fs.readFileSync(file, 'utf8'); for (const match of source.matchAll(/(?:from\s*|import\s*\()(['"])(\.[^'"]+)\1/g)) { assert.ok(fs.existsSync(path.resolve(path.dirname(file), match[2].split(/[?#]/)[0])), `${file}: ${match[2]}`); } } const repo = path.dirname(ui); const changed = execFileSync('git', ['ls-files', '--modified', '--others', '--exclude-standard'], { cwd: repo, encoding: 'utf8' }).trim().split('\n'); let cssCount = 0; for (const relative of changed) { const file = path.join(repo, relative); if (relative.endsWith('.js')) execFileSync('node', ['--input-type=module', '--check'], { input: fs.readFileSync(file) }); if (!relative.startsWith('shine-UI/styles/') || !relative.endsWith('.css')) continue; cssCount++; const source = fs.readFileSync(file, 'utf8'); const tree = postcss.parse(source, { from: file }); let baseline = ''; try { baseline = execFileSync('git', ['show', `HEAD:${relative}`], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch {} const baselineRules = new Map(); postcss.parse(baseline).walkRules((rule) => baselineRules.set(rule.selector, (baselineRules.get(rule.selector) || 0) + 1)); const rules = new Map(); const duplicates = []; tree.walkRules((rule) => { const parents = []; for (let node = rule.parent; node; node = node.parent) if (node.type === 'atrule') parents.push(node.name + ':' + node.params); const key = parents.join('/') + ':' + rule.selector; const count = (rules.get(key) || 0) + 1; rules.set(key, count); if (count > Math.max(1, baselineRules.get(rule.selector) || 0)) duplicates.push(key); }); assert.deepEqual(duplicates, [], `Новые повторы selectors: ${relative}`); assert.ok((source.match(/!important/g) || []).length <= (baseline.match(/!important/g) || []).length, `Вырос !important: ${relative}`); } const html = fs.readFileSync(path.join(ui, 'index.html'), 'utf8'); const cssManifest = [...html.matchAll(/['"]\.\/(styles\/[^'"]+\.css)['"]/g)].map((match) => match[1]); assert.equal(new Set(cssManifest).size, cssManifest.length); for (const file of cssManifest) assert.ok(fs.existsSync(path.join(ui, file)), `CSS manifest: ${file}`); assert.ok(cssManifest.includes('styles/components/channel-editor.css')); console.log(`PASS: импорты ${scripts.length} JS; синтаксис изменённых JS; ${cssCount} CSS — parser, дубликаты selectors, !important; CSS manifest.`); dom.window.close();