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.
This commit is contained in:
1 parent
84f48caaa1
commit
1866463b3d
8 files changed
+936
-1
No files matched your search
@@ -16,7 +16,8 @@ jobs:
|
||||
cache: true
|
||||
- uses: actions/setup-node@v4.1.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- name: gofmt
|
||||
run: test -z "$(gofmt -l internal cmd)"
|
||||
- name: go vet
|
||||
@@ -25,3 +26,5 @@ jobs:
|
||||
run: go test -race ./...
|
||||
- name: frontend syntax
|
||||
run: node --check web/js/app.js
|
||||
- name: frontend tests
|
||||
run: npm ci && npm test
|
||||
@@ -3,3 +3,4 @@
|
||||
.env
|
||||
*.tmp
|
||||
/CLAUDE-SECURITY-*/
|
||||
node_modules/
|
||||
@@ -405,4 +405,13 @@ go test ./... # store (revisões/GC/tags/árvore de categorias/migração),
|
||||
# diffx (diff unificado), api (fluxo HTTP completo + CRUD de categorias)
|
||||
go vet ./...
|
||||
node --check web/js/app.js
|
||||
|
||||
npm ci # dependência de teste do frontend (jsdom)
|
||||
npm test # testes do frontend em jsdom: sobe web/index.html + web/js/app.js
|
||||
# de verdade, intercepta a API e exercita o gerenciador de
|
||||
# arquivos (arrastar-e-soltar, mover/renomear/excluir, filtros,
|
||||
# busca e preview de imagem/compactado)
|
||||
```
|
||||
|
||||
Os testes de front-end rodam com o `node --test` nativo (sem runner extra) e
|
||||
vivem em `frontend/tests/` — fora de `web/`, que é embutido no binário.
|
||||
@@ -0,0 +1,161 @@
|
||||
// Arrastar-e-soltar de arquivos entre pastas no gerenciador.
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createApp, FakeDataTransfer } = require('./harness');
|
||||
|
||||
test('arrastar um arquivo para uma pasta move o arquivo', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
const source = app.rowByName('raiz.txt');
|
||||
const target = app.treeRowByName('Documentos');
|
||||
assert.ok(source, 'arquivo na raiz deve aparecer na lista');
|
||||
assert.ok(target, 'pasta deve aparecer na árvore');
|
||||
|
||||
app.dragAndDrop(source, target);
|
||||
|
||||
const req = app.lastRequest('PUT', '/api/files/file2/move');
|
||||
assert.ok(req, 'deve chamar PUT /api/files/{id}/move');
|
||||
assert.deepStrictEqual(req.body, { folder: 'folder1' });
|
||||
});
|
||||
|
||||
test('o arrasto leva o id do arquivo no payload interno', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
const dt = app.dragAndDrop(app.rowByName('raiz.txt'), app.treeRowByName('Documentos'));
|
||||
|
||||
assert.strictEqual(dt.getData('application/x-notas-file'), 'file2');
|
||||
assert.strictEqual(dt.getData('text/plain'), 'raiz.txt');
|
||||
});
|
||||
|
||||
test('soltar na pasta em que o arquivo já está não chama a API', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
app.treeRowByName('Documentos').click();
|
||||
await app.settle();
|
||||
const source = app.rowByName('nota.txt');
|
||||
assert.ok(source, 'nota.txt deve estar na lista da pasta');
|
||||
|
||||
app.dragAndDrop(source, app.treeRowByName('Documentos'));
|
||||
|
||||
assert.strictEqual(app.requests('PUT', '/move').length, 0, 'nenhuma chamada de movementação');
|
||||
});
|
||||
|
||||
test('soltar na raiz move o arquivo para a raiz', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
app.treeRowByName('Documentos').click();
|
||||
await app.settle();
|
||||
app.dragAndDrop(app.rowByName('nota.txt'), app.treeRowByName('Raiz'));
|
||||
|
||||
const req = app.lastRequest('PUT', '/api/files/file1/move');
|
||||
assert.ok(req, 'deve chamar PUT /api/files/{id}/move');
|
||||
assert.deepStrictEqual(req.body, { folder: '' });
|
||||
});
|
||||
|
||||
test('drop de arquivo do sistema é ignorado', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
const dt = new FakeDataTransfer(); // sem mime interno: é um arquivo do disco
|
||||
app.fireDrag(app.rowByName('raiz.txt'), 'dragover', dt);
|
||||
app.fireDrag(app.treeRowByName('Documentos'), 'drop', dt);
|
||||
|
||||
assert.strictEqual(app.requests('PUT', '/move').length, 0, 'não deve mover nada');
|
||||
});
|
||||
|
||||
test('a pasta alvo é destacada durante o arrasto', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
const source = app.rowByName('raiz.txt');
|
||||
const target = app.treeRowByName('Documentos');
|
||||
const dt = new FakeDataTransfer();
|
||||
|
||||
app.fireDrag(source, 'dragstart', dt);
|
||||
assert.ok(source.classList.contains('dragging'), 'origem fica marcada como arrastando');
|
||||
|
||||
app.fireDrag(target, 'dragover', dt);
|
||||
assert.ok(target.classList.contains('drop-hl'), 'destino fica destacado');
|
||||
|
||||
app.fireDrag(source, 'dragend', dt);
|
||||
assert.ok(!target.classList.contains('drop-hl'), 'destaque é limpo ao fim do arrasto');
|
||||
assert.ok(!source.classList.contains('dragging'), 'origem volta ao normal');
|
||||
});
|
||||
|
||||
test('o destino da movimentação é informado ao usuário', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
app.dragAndDrop(app.rowByName('raiz.txt'), app.treeRowByName('Documentos'));
|
||||
await app.settle();
|
||||
|
||||
assert.match(app.toastText(), /Documentos/);
|
||||
});
|
||||
|
||||
test('após mover, o gerenciador recarrega a árvore', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
const before = app.requests('GET', '/api/files/tree').length;
|
||||
app.dragAndDrop(app.rowByName('raiz.txt'), app.treeRowByName('Documentos'));
|
||||
await app.settle();
|
||||
|
||||
assert.ok(app.requests('GET', '/api/files/tree').length > before, 'a árvore deve ser recarregada');
|
||||
});
|
||||
|
||||
test('a falha da API não deixa a UI afirmar sucesso', async (t) => {
|
||||
const app = createApp({
|
||||
routes: {
|
||||
'/api/files/file2/move': ({ json }) => json({ error: 'pasta inexistente' }, 400),
|
||||
},
|
||||
});
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
app.dragAndDrop(app.rowByName('raiz.txt'), app.treeRowByName('Documentos'));
|
||||
await app.settle();
|
||||
|
||||
assert.match(app.toastText(), /pasta inexistente/, 'o erro da API aparece para o usuário');
|
||||
assert.doesNotMatch(app.toastText(), /Movido para/, 'não pode anunciar sucesso');
|
||||
});
|
||||
|
||||
test('no modo seletor as linhas não são arrastáveis', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
|
||||
// abre o gerenciador como seletor (botão 📎 Anexo)
|
||||
app.document.querySelector('#btn-attach').click();
|
||||
await app.waitFor(() => app.listRows().length > 0);
|
||||
|
||||
for (const row of app.listRows()) {
|
||||
assert.strictEqual(row.draggable, false, 'linha do seletor não deve ser arrastável');
|
||||
}
|
||||
});
|
||||
|
||||
test('o arquivo continua vinculado à nota após mudar de pasta', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
app.dragAndDrop(app.rowByName('raiz.txt'), app.treeRowByName('Documentos'));
|
||||
await app.settle();
|
||||
|
||||
// a movimentação não mexe no vínculo com a nota: só o id e a pasta mudam
|
||||
const req = app.lastRequest('PUT', '/api/files/file2/move');
|
||||
assert.deepStrictEqual(Object.keys(req.body), ['folder']);
|
||||
assert.strictEqual(req.url, '/api/files/file2/move', 'o id do arquivo é o mesmo, então a referência da nota continua válida');
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
// Comportamento do gerenciador de arquivos: ações, filtros, busca e preview.
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createApp } = require('./harness');
|
||||
|
||||
function actionButton(row, label) {
|
||||
return [...row.querySelectorAll('.fitem-acts button')].find((b) => b.textContent.trim() === label);
|
||||
}
|
||||
|
||||
test('a lista mostra os arquivos da pasta selecionada', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
assert.deepStrictEqual(app.listRows().map((r) => r.querySelector('.fitem-name').textContent), ['raiz.txt']);
|
||||
|
||||
app.treeRowByName('Documentos').click();
|
||||
await app.settle();
|
||||
assert.deepStrictEqual(app.listRows().map((r) => r.querySelector('.fitem-name').textContent), ['nota.txt']);
|
||||
});
|
||||
|
||||
test('renomear envia o novo nome e mantém o id', async (t) => {
|
||||
const app = createApp({ promptAnswer: 'renomeado.txt' });
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
actionButton(app.rowByName('raiz.txt'), '✎').click();
|
||||
await app.settle();
|
||||
|
||||
const req = app.lastRequest('PUT', '/api/files/file2/move');
|
||||
assert.ok(req, 'deve chamar PUT /api/files/{id}/move');
|
||||
assert.strictEqual(req.url, '/api/files/file2/move');
|
||||
assert.deepStrictEqual(req.body, { name: 'renomeado.txt' });
|
||||
});
|
||||
|
||||
test('mover pelo botão de pasta escolhe o destino pelo índice informado', async (t) => {
|
||||
const app = createApp({ promptAnswer: '1' }); // 0 = Raiz, 1 = Documentos
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
actionButton(app.rowByName('raiz.txt'), '📂').click();
|
||||
await app.settle();
|
||||
|
||||
const req = app.lastRequest('PUT', '/api/files/file2/move');
|
||||
assert.ok(req, 'deve chamar PUT /api/files/{id}/move');
|
||||
assert.deepStrictEqual(req.body, { folder: 'folder1' });
|
||||
});
|
||||
|
||||
test('associar o arquivo à nota atual envia o id da nota', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
// abre a nota para que exista uma aba com id (clicar no item da lista,
|
||||
// nunca no botão de excluir que fica dentro dele)
|
||||
const noteItem = [...app.document.querySelectorAll('#notes-list li')]
|
||||
.find((li) => li.querySelector('.n-title'));
|
||||
assert.ok(noteItem, 'a nota deve aparecer na lista lateral');
|
||||
noteItem.click();
|
||||
await app.settle();
|
||||
|
||||
const row = app.rowByName('raiz.txt');
|
||||
const pin = actionButton(row, '📌');
|
||||
assert.ok(pin, 'o botão de associar deve existir');
|
||||
pin.click();
|
||||
await app.settle();
|
||||
|
||||
const req = app.lastRequest('PUT', '/api/files/file2/move');
|
||||
assert.ok(req, 'deve chamar PUT /api/files/{id}/move');
|
||||
assert.deepStrictEqual(req.body, { note: 'note1' });
|
||||
});
|
||||
|
||||
test('excluir pede confirmação e só apaga depois de confirmado', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
actionButton(app.rowByName('raiz.txt'), '✕').click();
|
||||
await app.settle();
|
||||
|
||||
const modal = app.document.querySelector('#modal-overlay');
|
||||
assert.ok(!modal.classList.contains('hidden'), 'o modal de confirmação deve abrir');
|
||||
assert.strictEqual(app.requests('DELETE', '/api/files/file2').length, 0, 'não exclui antes de confirmar');
|
||||
|
||||
app.document.querySelector('#modal-ok').click();
|
||||
await app.settle();
|
||||
assert.strictEqual(app.requests('DELETE', '/api/files/file2').length, 1, 'exclui depois de confirmar');
|
||||
});
|
||||
|
||||
test('cancelar a confirmação de exclusão não apaga nada', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
actionButton(app.rowByName('raiz.txt'), '✕').click();
|
||||
await app.settle();
|
||||
app.document.querySelector('#modal-cancel').click();
|
||||
await app.settle();
|
||||
|
||||
assert.strictEqual(app.requests('DELETE', '/api/files/file2').length, 0, 'não deve excluir');
|
||||
});
|
||||
|
||||
test('os filtros separam anexos de arquivos avulsos', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
const chip = (label) => [...app.document.querySelectorAll('#files-filters button')]
|
||||
.find((b) => b.textContent.trim() === label);
|
||||
|
||||
// raiz.txt está vinculado a note1 → some no filtro "Avulsos"
|
||||
chip('Avulsos').click();
|
||||
await app.settle();
|
||||
assert.deepStrictEqual(app.listRows(), [], 'nenhum avulso na raiz');
|
||||
|
||||
chip('Anexos').click();
|
||||
await app.settle();
|
||||
assert.deepStrictEqual(app.listRows().map((r) => r.querySelector('.fitem-name').textContent), ['raiz.txt']);
|
||||
});
|
||||
|
||||
test('a busca filtra os arquivos por nome', async (t) => {
|
||||
const app = createApp();
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
const search = app.document.querySelector('#files-search');
|
||||
search.value = 'raiz';
|
||||
search.dispatchEvent(new app.window.Event('input', { bubbles: true }));
|
||||
await app.settle(400);
|
||||
|
||||
assert.deepStrictEqual(app.listRows().map((r) => r.querySelector('.fitem-name').textContent), ['raiz.txt']);
|
||||
|
||||
search.value = 'nada-encontrado';
|
||||
search.dispatchEvent(new app.window.Event('input', { bubbles: true }));
|
||||
await app.settle(400);
|
||||
assert.deepStrictEqual(app.listRows(), [], 'busca sem resultado limpa a lista');
|
||||
});
|
||||
|
||||
test('criar pasta envia nome e pai', async (t) => {
|
||||
const app = createApp({ promptAnswer: 'Novos' });
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
app.document.querySelector('#files-newfolder').click();
|
||||
await app.settle();
|
||||
|
||||
const req = app.lastRequest('POST', '/api/folders');
|
||||
assert.ok(req, 'deve chamar POST /api/folders');
|
||||
assert.deepStrictEqual(req.body, { name: 'Novos', parent: '' });
|
||||
});
|
||||
|
||||
test('o preview de imagem abre o lightbox com a url do arquivo', async (t) => {
|
||||
const app = createApp({
|
||||
fixtures: {
|
||||
rootFiles: [{ id: 'img1', name: 'foto.png', folder: '', note: '', size: 10, mime: 'image/png', sha256: 'cc', created_at: '2026-01-02T00:00:00Z', url: '/api/files/static/img1' }],
|
||||
},
|
||||
});
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
actionButton(app.rowByName('foto.png'), '👁').click();
|
||||
await app.settle();
|
||||
|
||||
const lightbox = app.document.querySelector('#lightbox');
|
||||
assert.ok(!lightbox.classList.contains('hidden'), 'o lightbox deve abrir');
|
||||
const img = lightbox.querySelector('img');
|
||||
assert.ok(img, 'a imagem deve ser exibida');
|
||||
assert.strictEqual(img.getAttribute('src'), '/api/files/static/img1');
|
||||
});
|
||||
|
||||
test('o preview de compactado lista o conteúdo do arquivo', async (t) => {
|
||||
const app = createApp({
|
||||
fixtures: {
|
||||
rootFiles: [{ id: 'zip1', name: 'backup.zip', folder: '', note: '', size: 10, mime: 'application/zip', sha256: 'dd', created_at: '2026-01-02T00:00:00Z', url: '/api/files/static/zip1' }],
|
||||
},
|
||||
routes: {
|
||||
'/api/files/archive/zip1': ({ json }) => json({
|
||||
entries: [{ name: 'docs/nota.txt', size: 16, dir: false }, { name: 'vazia/', size: 0, dir: true }],
|
||||
total: 2,
|
||||
truncated: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
t.after(() => app.close());
|
||||
await app.openManager();
|
||||
|
||||
actionButton(app.rowByName('backup.zip'), '👁').click();
|
||||
await app.settle();
|
||||
|
||||
const items = [...app.document.querySelectorAll('.archive-list li')];
|
||||
assert.strictEqual(items.length, 2, 'o compactado lista os itens');
|
||||
assert.match(items[0].textContent, /docs\/nota\.txt/);
|
||||
assert.match(items[1].textContent, /vazia\//);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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 };
|
||||
Generated
+369
@@ -0,0 +1,369 @@
|
||||
{
|
||||
"name": "notas-online-frontend-tests",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "notas-online-frontend-tests",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"jsdom": "^31.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
|
||||
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bcp-47": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.2.tgz",
|
||||
"integrity": "sha512-wsv86UuNFuPnVieKLyECTT+CP1WSAHeoIg5P3xu2kA9Z+7fVPoKt7dxzSeEhtJel0AI+NW3fIW3KtTKQKUCngg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-alphabetical": "^2.0.0",
|
||||
"is-alphanumerical": "^2.0.0",
|
||||
"is-decimal": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/bcp-47-match": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz",
|
||||
"integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
|
||||
"integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz",
|
||||
"integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
|
||||
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-alphabetical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||
"integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-alphanumerical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
|
||||
"integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-alphabetical": "^2.0.0",
|
||||
"is-decimal": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-decimal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
|
||||
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "31.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-31.0.0.tgz",
|
||||
"integrity": "sha512-xAkhD9z1eyFC5+QUi9K24CaZJgdCQ2GOGLW1GdVDoQvkp/AxEH9TQ0pAplPOlIiV92hfcgnFOADZypbjgbBT6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.16.0",
|
||||
"bcp-47": "^2.1.2",
|
||||
"bcp-47-match": "^2.0.3",
|
||||
"data-urls": "^8.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^7.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.3",
|
||||
"parse5": "^8.1.0",
|
||||
"parse5-parser-stream": "^8.1.0",
|
||||
"saxes": "^6.0.0",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.12.3",
|
||||
"w3c-xmlserializer": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.3.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@napi-rs/canvas": "^1.0.13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@napi-rs/canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.1.tgz",
|
||||
"integrity": "sha512-EcR/6WDcU7b2N3vQSxKALCk9lXGtUMQxXio008OEMBE0kavu/7JLfBEeFPzi43nMqVDSbEn3QCK+BMCc9zO//w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^9.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-8.1.1.tgz",
|
||||
"integrity": "sha512-hMcVbbUJcQmP8rURWUdYrT1flRiBiBVjq/RYGguJpDUgIXSqdYKlE0CxthmdMWoSlBAuL5bUBa73g0pttSy7Hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^8.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz",
|
||||
"integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.19"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.20",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
|
||||
"integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.1.0.tgz",
|
||||
"integrity": "sha512-FTxc8u8Kl2tz0Lat1u8ksvcy8bwHRyEMawwTWybo51JqaC6O7SvfuhAKg+Ld6VqyIe2QRJBzY9HA7y2WyFRT2w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
|
||||
"integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.12.4",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.12.4.tgz",
|
||||
"integrity": "sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
|
||||
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "17.3.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
|
||||
"integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.16.0",
|
||||
"tr46": "^7.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "notas-online-frontend-tests",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Testes do frontend (jsdom) do notas-online",
|
||||
"scripts": {
|
||||
"test": "node --test frontend/tests/*.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^31.0.0"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user