SHA256
315 lines
16 KiB
JavaScript
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();
|