ci / check (push) Successful in 57s
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.
185 lines
6.7 KiB
JavaScript
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 };
|