Files
odysseus/tests/notesWorkflows.test.mjs
T
Alexandre TeixeiraandLéo f1a47f3812 refactor(notes): decompose Notes into feature-owned modules (#6713)
* refactor(notes): decompose Notes into feature-owned modules

* fix(notes): avoid HTML interpolation in reminder picker

* docs(notes): drop checkpoint archives, session logs and publication drafts

The N0-N6 tarballs and raw pytest/node logs are not read by any script or
test, and the logs carry absolute home-directory paths. Once squashed into
dev they would stay in history for good. notes-pr.md and notes-issue.md
were the drafts used to open #6712 and #6713, not documentation.

notes-validation.md keeps the counts, the checkpoint hashes and
implementation.sha256 as the record; local interpreter paths and the local
Docker image name are replaced with portable placeholders.

* fix(notes): keep the state store raw and stop masonry re-observing after close

- state: writes built from views (filter, spread, splice) stored nested
  read-only views in the store, so structuredClone on a stored note threw
  DataCloneError. Store writes now unwrap nested views.
- layout: a save that resolves after closePanel re-renders the leaving pane
  and re-created the masonry ResizeObserver that teardown had disposed.
  Skip creating it once Notes is closed.
- index.js: drop 30 imported or declared names that were never used and
  group the imports at the top. Import order is unchanged.
- Revert unrelated punctuation edits in .gitattributes and keep the header
  comment and docstring first in compare/vote.js and
  test_notes_dom_xss_helpers.py.

Both fixes have a node test that fails on the previous head.

* test(notes): make script rejection assertion case-insensitive

Address CodeQL js/bad-tag-filter alert #1150 in test-only assertion and refresh Notes evidence manifest checksum.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-10-15 09:29:41 +01:00

148 lines
8.5 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
import { createCardOperations } from '../frontend/src/features/notes/application/cardOperations.js';
import { createChecklistOperations } from '../frontend/src/features/notes/application/checklistOperations.js';
import { createNotesSubmission } from '../frontend/src/features/notes/application/submission.js';
import { createSelectionOperations } from '../frontend/src/features/notes/application/selection.js';
import { createNotesPersistence } from '../frontend/src/features/notes/infrastructure/storage.js';
import { createNotesLifecycle } from '../frontend/src/features/notes/ui/lifecycle.js';
import { createText } from '../frontend/src/features/notes/ui/text.js';
import { createDeletion } from '../frontend/src/features/notes/application/deletion.js';
const drain = () => new Promise(setImmediate);
function owner(notes) { const app = createNotesState(); app.replaceNotes(notes); return app; }
test('reorder retains desktop persistence-only and mobile post-response state ordering', async () => {
const app = owner([{ id: 'a', sort_order: 9 }, { id: 'b', sort_order: 8 }]), requests = [];
let resolve;
const operations = createCardOperations({ app, api: { reorder: ids => { requests.push(ids); return new Promise(done => { resolve = done; }); } } });
const desktop = operations.persistOrder(['b', 'a']);
resolve({ ok: false }); await desktop;
assert.deepEqual(app.state.notes.map(note => note.sort_order), [9, 8]);
const mobile = operations.commitMobileOrder(['b', 'a']);
assert.deepEqual(app.state.notes.map(note => note.sort_order), [9, 8]);
resolve({ ok: false }); await mobile;
assert.deepEqual(app.state.notes.map(note => note.sort_order), [1, 0]);
assert.deepEqual(requests, [['b', 'a'], ['b', 'a']]);
});
test('editor deletion waits for confirmation and preserves optimistic failure/refetch timing', async () => {
const app = owner([{ id: 'a' }]), events = [];
app.setEditingId('a');
let confirm, fail;
const operations = createDeletion({ app, state: app.state,
_deleteNoteApi: id => { events.push('delete:' + id); return new Promise((_resolve, reject) => { fail = reject; }); },
_fetchNotes: async () => { events.push('fetch'); app.replaceNotes([{ id: 'a' }]); },
_renderNotes: () => events.push('render'),
effects: { styledConfirm: () => new Promise(resolve => { confirm = resolve; }), showToast: () => events.push('toast'), showError: error => events.push(error) },
});
const deletion = operations.deleteFromEditor('a');
assert.equal(app.state.notes.length, 1);
confirm(false); await deletion;
assert.deepEqual(events, []);
const accepted = operations.deleteFromEditor('a');
confirm(true); await accepted;
assert.equal(app.state.notes.length, 0);
assert.equal(app.state.editingId, null);
assert.deepEqual(events, ['render', 'delete:a']);
fail(Error('403')); await drain();
assert.equal(app.state.notes[0].id, 'a');
assert.deepEqual(events, ['render', 'delete:a', 'Failed to delete', 'fetch', 'render']);
});
test('pin and delete failures roll back captured state and order', async () => {
const app = owner([{ id: 'a', pinned: false, sort_order: 4 }, { id: 'b', pinned: true, sort_order: -3 }]), errors = [];
const operations = createCardOperations({ app, api: { patchNote: async () => { throw Error('403'); }, deleteNote: async () => { throw Error('403'); } }, effects: { render() {}, showError: error => errors.push(error) } });
operations.togglePin('a');
assert.equal(app.state.notes[0].pinned, true);
assert.equal(app.state.notes[0].sort_order, -4);
await drain();
assert.equal(app.state.notes[0].pinned, false);
assert.equal(app.state.notes[0].sort_order, 4);
operations.deleteOptimistically('a');
assert.deepEqual(app.state.notes.map(n => n.id), ['b']);
await drain();
assert.deepEqual(app.state.notes.map(n => n.id), ['a', 'b']);
assert.deepEqual(errors, ['Failed to pin', 'Failed to delete']);
});
test('item removal, quick add and checkbox errors preserve checklist contents', async () => {
const app = owner([{ id: 'a', items: [{ id: 'first', text: 'A', done: false }, { id: 'second', text: 'B', done: true }] }]);
const operations = createChecklistOperations({ app, api: { patchNote: async () => { throw Error('offline'); } }, uid: () => 'new', effects: { render() {}, showError() {} } });
operations.removeItem('a', 0);
assert.equal(app.state.notes[0].items[0].id, 'second');
await drain();
assert.deepEqual(app.state.notes[0].items.map(it => it.id), ['first', 'second']);
const done = [];
operations.toggleItem('a', 0, value => done.push(value), () => {});
await drain();
assert.deepEqual(done, [true, false]);
const persist = operations.addItem('a', 'C', () => {});
assert.equal(app.state.notes[0].items.length, 3);
persist(); await drain();
assert.equal(app.state.notes[0].items.length, 2);
operations.editItem(app.state.notes[0], 1, 'Changed');
await drain();
assert.equal(app.state.notes[0].items[1].text, 'B');
});
test('submission preserves top ordering, server ID replacement and newer draft recovery', async () => {
const app = owner([{ id: 'existing', sort_order: -2 }]), values = new Map();
const persistence = createNotesPersistence({ storage: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) } });
const draftKey = persistence._draftKey('__new__');
values.set(draftKey, '{"content":"submitted"}');
let resolve, renders = 0;
const operations = createNotesSubmission({ app, persistence, clock: { now: () => 0 }, uid: () => 'id', api: { saveNote: () => new Promise(r => { resolve = r; }) }, effects: { render: () => renders++, showError() {} }, fetchNotes: async () => {} });
operations.submitNote({ title: 'New' }, null, false);
assert.equal(app.state.notes[0].id, 'tmp_id');
assert.equal(app.state.notes[0].sort_order, -3);
values.set(draftKey, '{"content":"newer"}');
resolve({ id: 'server-id' }); await drain();
assert.equal(app.state.notes[0].id, 'server-id');
assert.equal(values.get(draftKey), '{"content":"newer"}');
assert.equal(renders, 2);
});
test('selection can exit only after synchronous acceptance', () => {
const app = owner([]), order = [];
app.selectId('a');
let accepted = false;
const operations = createSelectionOperations({ app, sendContext: detail => { order.push('dispatch'); detail.accepted = accepted; }, exitSelection: () => order.push('exit'), effects: { showToast: () => order.push('rejected') } });
operations.attachSelected();
assert.deepEqual(order, ['dispatch', 'rejected']);
accepted = true; operations.attachSelected();
assert.deepEqual(order, ['dispatch', 'rejected', 'dispatch', 'exit']);
});
test('listener owners release on replacement and disposal is idempotent', () => {
const previous = { document: globalThis.document, MutationObserver: globalThis.MutationObserver };
let check, disconnected = 0;
globalThis.document = { body: {} };
globalThis.MutationObserver = class { constructor(callback) { check = callback; } observe() {} disconnect() { disconnected++; } };
try {
const lifecycle = createNotesLifecycle(), target = new EventTarget(), canvas = { isConnected: true };
let calls = 0;
lifecycle.listen(canvas, target, 'mouseup', () => calls++);
let released = 0;
lifecycle.onDispose(canvas, () => released++);
target.dispatchEvent(new Event('mouseup'));
canvas.isConnected = false; check();
target.dispatchEvent(new Event('mouseup'));
lifecycle.dispose(); lifecycle.dispose();
assert.equal(calls, 1); assert.equal(disconnected, 1);
assert.equal(released, 1);
} finally {
for (const [name, value] of Object.entries(previous)) if (value === undefined) delete globalThis[name]; else globalThis[name] = value;
}
});
test('render helpers reject script-capable media and escape URL attributes', () => {
const helpers = createText({ uiModule: { esc: value => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;') } });
for (const value of ['javascript:alert(1)', 'data:image/svg+xml;base64,abc', 'data:text/html;base64,abc']) assert.equal(helpers._safeImgSrc(value), '');
assert.equal(helpers._safeImgSrc('data:image/png;base64,abc'), 'data:image/png;base64,abc');
assert.equal(helpers._attrEsc('"\'<>`'), '&quot;&#39;&lt;&gt;&#96;');
assert.match(helpers._linkify('<script> https://example.org/test'), /&lt;script&gt;/);
assert.doesNotMatch(helpers._linkify('<script> https://example.org/test'), /<script>/i);
});