Files
lgcosta 1866463b3d
ci / check (push) Successful in 57s
test(frontend): add jsdom tests for the file manager
Sobe web/index.html + web/js/app.js de verdade em jsdom, intercepta a API
e cobre arrastar-e-soltar entre pastas, mover/renomear/excluir, filtros,
busca e preview. CI passa a rodar npm ci && npm test.
2026-11-07 19:17:50 -03:00

185 lines
6.7 KiB
JavaScript

// 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 };