diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0ede80..a840efb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 82e1279..bde81f0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .env *.tmp /CLAUDE-SECURITY-*/ +node_modules/ diff --git a/README.md b/README.md index 4aacb5f..34fe8d4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/frontend/tests/files-dnd.test.js b/frontend/tests/files-dnd.test.js new file mode 100644 index 0000000..f2f8f73 --- /dev/null +++ b/frontend/tests/files-dnd.test.js @@ -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'); +}); diff --git a/frontend/tests/files-manager.test.js b/frontend/tests/files-manager.test.js new file mode 100644 index 0000000..d717db0 --- /dev/null +++ b/frontend/tests/files-manager.test.js @@ -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\//); +}); diff --git a/frontend/tests/harness.js b/frontend/tests/harness.js new file mode 100644 index 0000000..4441cf2 --- /dev/null +++ b/frontend/tests/harness.js @@ -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 }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d3f23c5 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f2b875c --- /dev/null +++ b/package.json @@ -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" + } +}