// Harness de teste do frontend: sobe o app real (web/index.html + web/js/app.js) // em jsdom, com as chamadas de API interceptadas. 'use strict'; const fs = require('node:fs'); const path = require('node:path'); const { JSDOM } = require('jsdom'); const ROOT = path.resolve(__dirname, '..', '..'); const INDEX = path.join(ROOT, 'web', 'index.html'); const APPJS = path.join(ROOT, 'web', 'js', 'app.js'); // FakeDataTransfer: jsdom não implementa DataTransfer nem DragEvent. class FakeDataTransfer { constructor() { this._data = new Map(); this.files = []; this.dropEffect = 'none'; this.effectAllowed = 'all'; } setData(type, value) { this._data.set(String(type), String(value)); } getData(type) { return this._data.has(String(type)) ? this._data.get(String(type)) : ''; } get types() { return [...this._data.keys()]; } clearData(type) { if (type) this._data.delete(String(type)); else this._data.clear(); } } // Fixture padrão: uma pasta com um arquivo, um arquivo na raiz e um anexo. function defaultFixtures() { return { tree: [ { id: 'folder1', name: 'Documentos', parent: '', created_at: '2026-01-01T00:00:00Z', children: [], files: [ { id: 'file1', name: 'nota.txt', folder: 'folder1', note: '', size: 3, mime: 'text/plain', sha256: 'aa', created_at: '2026-01-01T00:00:00Z', url: '/api/files/static/file1' }, ], }, ], rootFiles: [ { id: 'file2', name: 'raiz.txt', folder: '', note: 'note1', size: 4, mime: 'text/plain', sha256: 'bb', created_at: '2026-01-02T00:00:00Z', url: '/api/files/static/file2' }, ], notes: [{ id: 'note1', title: 'Nota', format: 'md', categories: [], tags: [], content: 'oi', author: '', message: '', hash: 'h1', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }], }; } /** * Cria o ambiente do app. * @param {object} opts fixtures (tree/rootFiles/notes) e respostas customizadas. */ function createApp(opts = {}) { const fixtures = { ...defaultFixtures(), ...(opts.fixtures || {}) }; const routes = opts.routes || {}; const html = fs.readFileSync(INDEX, 'utf8'); const dom = new JSDOM(html, { url: 'http://localhost:8080/', pretendToBeVisual: true, runScripts: 'outside-only', }); const { window } = dom; const calls = []; window.fetch = async (url, init = {}) => { const u = String(url); const method = init.method || 'GET'; let body = init.body; if (typeof body === 'string') { try { body = JSON.parse(body); } catch { /* mantém texto */ } } calls.push({ url: u, method, body }); const json = (data, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => data, text: async () => JSON.stringify(data), headers: { get: () => null }, blob: async () => ({}), }); for (const [pattern, responder] of Object.entries(routes)) { if (u.includes(pattern)) return responder({ url: u, method, body, json }); } if (u.includes('/api/me')) return json({ id: 'u1', name: 'tester' }); if (u.includes('/api/files/tree')) return json({ tree: fixtures.tree, rootFiles: fixtures.rootFiles }); const noteMatch = u.match(/\/api\/notes\/([^/?]+)/); if (noteMatch) { const found = fixtures.notes.find((n) => n.id === noteMatch[1]); return json(found || {}); } if (u.includes('/api/notes') && !u.includes('/api/notes?')) return json(fixtures.notes); if (u.includes('/api/tags')) return json([]); if (u.includes('/api/categories')) return json([]); return json({}); }; window.EventSource = class { constructor(u) { this.url = u; } close() {} addEventListener() {} removeEventListener() {} }; if (!window.matchMedia) { window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); } window.prompt = () => (opts.promptAnswer === undefined ? null : opts.promptAnswer); window.confirm = () => true; window.scrollTo = () => {}; window.eval(fs.readFileSync(APPJS, 'utf8')); const doc = window.document; const api = { window, document: doc, calls, fixtures, /** Espera a UI assentar (promessas resolvidas + render). */ async settle(ms = 30) { await new Promise((r) => setTimeout(r, ms)); }, /** Espera até a condição ser verdadeira (ou estoura). */ async waitFor(fn, timeout = 2000) { const deadline = Date.now() + timeout; for (;;) { if (fn()) return; if (Date.now() > deadline) throw new Error('waitFor: condição não satisfeita'); await new Promise((r) => setTimeout(r, 10)); } }, /** Abre o gerenciador de arquivos (botão da toolbar). */ async openManager() { doc.querySelector('#btn-files').click(); await api.waitFor(() => doc.querySelector('#files-overlay') && !doc.querySelector('#files-overlay').classList.contains('hidden')); await api.settle(); return api; }, /** Linhas de arquivo visíveis na lista. */ listRows() { return [...doc.querySelectorAll('#files-list .fitem')]; }, /** Linha de arquivo pelo nome. */ rowByName(name) { return api.listRows().find((r) => r.querySelector('.fitem-name').textContent.includes(name)); }, /** Linha da árvore pelo nome (pastas e Raiz). */ treeRowByName(name) { return [...doc.querySelectorAll('#files-tree .tree-row')].find((r) => r.textContent.includes(name)); }, /** Dispara um evento de drag com DataTransfer simulado. */ fireDrag(el, type, dt) { const ev = new window.Event(type, { bubbles: true, cancelable: true }); Object.defineProperty(ev, 'dataTransfer', { value: dt }); el.dispatchEvent(ev); return ev; }, /** Arrasta um elemento da origem até o destino (dragstart → dragover → drop → dragend). */ dragAndDrop(source, target, dt = new FakeDataTransfer()) { api.fireDrag(source, 'dragstart', dt); api.fireDrag(target, 'dragover', dt); api.fireDrag(target, 'drop', dt); api.fireDrag(source, 'dragend', dt); return dt; }, /** Requisições registradas com o método e filtro informados. */ requests(method, needle = '') { return calls.filter((c) => c.method === method && c.url.includes(needle)); }, /** Última requisição que casa com método/filtro. */ lastRequest(method, needle = '') { const list = api.requests(method, needle); return list.length ? list[list.length - 1] : null; }, /** Texto do toast. */ toastText() { const t = doc.querySelector('#toast'); return t ? t.textContent.trim() : ''; }, close() { window.close(); }, }; return api; } module.exports = { createApp, FakeDataTransfer, defaultFixtures };