Files
SHiNE-server/shine-UI/channel-design-check.mjs
T
2026-09-22 14:09:02 +03:00

315 lines
16 KiB
JavaScript

// Запуск: 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('<button id="opener">Ответить</button><main id="app-screen"></main><div id="modal-root"></div>', { 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();