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>
This commit is contained in:
Alexandre Teixeira
2026-10-15 09:29:41 +01:00
committed by GitHub
co-authored by Léo
parent 9f8eae72fd
commit f1a47f3812
121 changed files with 15318 additions and 5544 deletions
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { chromium } from 'playwright';
import { randomUUID } from 'node:crypto';
import { spawnSync } from 'node:child_process';
const origin = process.env.ODYSSEUS_NOTES_RUNTIME_ORIGIN;
const evidence = process.env.NOTES_NATIVE_EVIDENCE_DIR;
if (evidence) fs.mkdirSync(evidence, { recursive: true });
const browser = await chromium.launch({ headless: true });
const credentials = { username: 'notes-reference', password: randomUUID() + 'Aa1!' };
try {
for (const mobile of [false, true]) {
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, hasTouch: mobile, isMobile: mobile });
const page = await context.newPage();
if (!mobile) {
const setup = await context.request.post(`${origin}/api/auth/setup`, { data: credentials });
assert.equal(setup.ok(), true, await setup.text());
}
const login = await context.request.post(`${origin}/api/auth/login`, { data: credentials });
assert.equal(login.ok(), true, await login.text());
const response = await context.request.post(`${origin}/api/notes`, { data: { title: mobile ? 'Mobile reference' : 'Desktop reference', content: 'Native API draft', note_type: 'note', label: 'reference' } });
assert.equal(response.ok(), true, await response.text());
const created = await response.json();
assert.ok(created.id);
assert.equal(created.owner, credentials.username);
await page.goto(origin);
await page.waitForFunction(() => window.notesModule?.openPanel);
await page.evaluate(() => { localStorage.setItem('odysseus-notes-first-open-hint-v1', '1'); window.notesModule.openPanel(); });
const card = page.locator(`.note-card[data-note-id="${created.id}"]`);
await card.waitFor();
if (evidence) await page.screenshot({ path: path.join(evidence, mobile ? 'native-mobile.png' : 'native-desktop.png'), fullPage: true, animations: 'disabled' });
await page.locator('#notes-select-btn').click();
await card.locator('.note-card-cb').check({ force: mobile });
await page.locator('#notes-send-agent').click();
assert.ok(await page.locator('.note-card-selectmode').count());
await page.evaluate(async () => {
const sessions = await import('/static/js/sessions.js');
sessions.createDirectChat('http://127.0.0.1:1', 'reference-model', null);
});
await page.locator('#notes-send-agent').click();
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
assert.equal(await page.locator('#selected-notes-context-count').textContent(), '1 note selected');
await page.evaluate(async () => (await import('/static/js/sessions.js')).createDirectChat('http://127.0.0.1:1', 'another-reference-model', null));
assert.equal(await page.locator('#selected-notes-context-count').textContent(), '');
if (mobile) await card.locator('.note-card-title').tap();
else await card.locator('.note-card-title').click();
if (mobile) await page.locator('.note-form-content-reader').tap({ position: { x: 5, y: 5 } });
await page.locator('.note-form-content').fill('Saved by the native Notes application');
await page.waitForFunction(id => !localStorage.getItem('odysseus-note-draft-' + id), created.id);
const fetched = await context.request.get(`${origin}/api/notes`);
const notes = (await fetched.json()).notes;
assert.equal(notes.find(note => note.id === created.id).content, 'Saved by the native Notes application');
await page.evaluate(() => window.notesModule.closePanel());
await page.locator('#notes-pane').waitFor({ state: 'detached' });
await context.close();
console.log(JSON.stringify({ native: true, mobile, noteId: created.id, crud: true, selectedContext: true }));
}
const smoke = spawnSync(process.env.NOTES_TEST_PYTHON, ['-m', 'pytest', '-q', 'tests/smoke/test_notes_smoke.py'], {
env: { ...process.env, APP_PORT: new URL(origin).port, ODYSSEUS_ADMIN_USER: credentials.username, ODYSSEUS_ADMIN_PASSWORD: credentials.password },
encoding: 'utf8', timeout: 90000,
});
assert.equal(smoke.status, 0, smoke.stdout + smoke.stderr);
console.log('PASS: original Notes release smoke test against the disposable authenticated instance');
} finally { await browser.close(); }
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
import fs from 'node:fs';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
const manifest = JSON.parse(fs.readFileSync(new URL('../../frontend/modules.json', import.meta.url), 'utf8'));
const outputs = manifest.modules.map(entry => '/static/' + entry.deployment);
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({ serviceWorkers: 'allow' });
const page = await context.newPage();
await page.goto(`${origin}/static/js/panels.js`);
const cached = await page.evaluate(async outputs => {
await navigator.serviceWorker.register('/static/sw.js');
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
const missing = [];
for (const output of outputs) if (!(await caches.match(output))?.ok) missing.push(output);
return missing;
}, outputs);
assert.deepEqual(cached, []);
assert.equal(await page.evaluate(async () => {
const shared = await import('/static/js/shared/ui/confetti.js');
const compare = await import('/static/js/compare/vote.js?v=20260828resendcaldrag1');
return shared.spawnConfetti === compare.spawnConfetti;
}), true);
await context.setOffline(true);
const offline = await context.newPage();
offline.on('requestfailed', request => console.log('offline failed:', request.url(), request.failure()));
offline.on('console', message => { if (message.type() === 'error') console.log('browser:', message.text()); });
await offline.goto(`${origin}/static/js/panels.js`);
const result = await offline.evaluate(async () => {
const facade = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
const feature = await import('/static/js/features/notes/index.js');
return { identity: facade.default === feature.default && window.notesModule === feature.default, closed: facade.isPanelOpen() === false, exports: Object.keys(facade).sort() };
});
assert.equal(result.identity && result.closed, true);
console.log(JSON.stringify({ ...result, maintainedPrecache: outputs.length }));
} finally { await browser.close(); }
+148
View File
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { chromium } from 'playwright';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
const evidence = process.env.NOTES_EVIDENCE_DIR;
if (evidence) fs.mkdirSync(evidence, { recursive: true });
const baseline = process.env.NOTES_BASELINE === '1';
const browser = await chromium.launch({ headless: true });
try {
for (const device of ['desktop', 'mobile', 'tablet']) {
const mobile = device === 'mobile';
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : device === 'tablet' ? { width: 1024, height: 800 } : { width: 1440, height: 1000 }, hasTouch: device !== 'desktop', isMobile: mobile });
const page = await context.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.stack));
if (baseline) {
const body = execFileSync('git', ['show', '1cc56cb9:static/js/notes.js'], { encoding: 'utf8' });
await page.route('**/static/js/notes.js*', route => route.fulfill({ contentType: 'text/javascript', body }));
}
const requests = [];
const notes = [
{ id: 'plain', title: 'Reference note', note_type: 'note', content: 'Visit https://example.org and keep this draft.', label: 'work', sort_order: 0 },
{ id: 'todo', title: 'Reference checklist', note_type: 'todo', items: [{ text: 'First task', done: false }, { text: 'Second task', done: true }], sort_order: 1 },
{ id: 'goal', title: 'Reference goal', note_type: 'goal', content: 'A bounded goal', items: [{ text: 'Next step', done: false }], sort_order: 2 },
];
await page.route('**/api/**', async route => {
const request = route.request();
requests.push({ url: new URL(request.url()).pathname, method: request.method(), body: request.postData() });
const id = new URL(request.url()).pathname.split('/').at(-1);
if (request.method() === 'PUT') Object.assign(notes.find(n => n.id === id) || {}, request.postDataJSON());
await route.fulfill({ json: request.method() === 'GET' ? { notes } : notes.find(n => n.id === id) || {} });
});
await page.goto(`${origin}/static/js/panels.js`);
const shell = fs.readFileSync(new URL('../../static/index.html', import.meta.url), 'utf8');
const styles = [...shell.matchAll(/<link[^>]+rel="stylesheet"[^>]*>/g)].map(m => m[0]).join('\n');
await page.setContent(`<html><head><meta name="viewport" content="width=device-width,initial-scale=1">${styles}</head><body><div id="chat-container"></div><div id="toast"></div><button id="tool-notes-btn"><span id="notes-new-note-btn"></span></button><button id="rail-notes"></button></body></html>`);
await page.evaluate(() => {
localStorage.setItem('odysseus-notes-first-open-hint-v1', '1');
window.referenceIntervals = [];
window.referenceMouseups = new Set();
const add = window.addEventListener.bind(window), remove = window.removeEventListener.bind(window);
window.addEventListener = (type, fn, options) => { if (type === 'mouseup' && fn.name === '_end') window.referenceMouseups.add(fn); add(type, fn, options); };
window.removeEventListener = (type, fn, options) => { if (type === 'mouseup' && fn.name === '_end') window.referenceMouseups.delete(fn); remove(type, fn, options); };
const interval = window.setInterval.bind(window);
window.setInterval = (fn, ms, ...args) => { const id = interval(fn, ms, ...args); window.referenceIntervals.push({ id, ms }); return id; };
});
const publicAPI = await page.evaluate(async () => {
const mod = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
const again = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
window.referenceNotes = mod;
mod.openPanel();
return { names: Object.keys(mod).sort(), defaults: Object.keys(mod.default).sort(), arities: Object.fromEntries(Object.keys(mod).filter(k => k !== 'default').map(k => [k, mod[k].length])), asyncMethods: Object.keys(mod).filter(k => mod[k]?.constructor?.name === 'AsyncFunction').sort(), singleton: again === mod, bridge: window.notesModule === mod.default, aliases: mod.openNotes === mod.openPanel && mod.closeNotes === mod.closePanel && mod.isNotesOpen === mod.isPanelOpen && mod.default.openNotes === mod.openPanel };
});
assert.equal(publicAPI.singleton && publicAPI.bridge && publicAPI.aliases, true);
assert.deepEqual(publicAPI.names, ['closeNotes', 'closePanel', 'default', 'dismissFiredReminderDot', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
assert.deepEqual(publicAPI.defaults, ['closeNotes', 'closePanel', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
assert.deepEqual(publicAPI.arities, { closeNotes: 1, closePanel: 1, dismissFiredReminderDot: 0, isNotesOpen: 0, isPanelOpen: 0, newNote: 0, openNote: 1, openNotes: 0, openPanel: 0, refreshDueBadge: 0, togglePanel: 0 });
assert.deepEqual(publicAPI.asyncMethods, ['openNote', 'refreshDueBadge']);
await page.locator('.note-card').first().waitFor();
assert.equal(await page.locator('.note-card').count(), 3);
if (evidence) await page.screenshot({ path: path.join(evidence, `notes-${device}.png`), fullPage: true, animations: 'disabled' });
await page.locator('#notes-search').fill('checklist');
assert.equal(await page.locator('.note-card').count(), 1);
await page.locator('#notes-search').fill('');
await page.locator('#notes-select-btn').click();
await page.locator('.note-card-cb').first().check({ force: mobile });
await page.locator('#notes-send-agent').click();
assert.equal(await page.locator('.note-card-selectmode').count(), 3, 'rejected selection stays selected');
await page.evaluate(() => window.addEventListener('odysseus:notes-context-selected', e => { window.referenceSelected = e.detail.noteIds; e.detail.accepted = true; }, { once: true }));
await page.locator('#notes-send-agent').click();
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
assert.equal((await page.evaluate(() => window.referenceSelected)).length, 1);
await page.locator('#notes-search').fill('checklist');
await page.evaluate(() => window.referenceNotes.closePanel());
await page.locator('#notes-pane').waitFor({ state: 'detached' });
await page.evaluate(() => window.referenceNotes.openPanel());
await page.locator('.note-card').first().waitFor();
assert.equal(await page.locator('#notes-search').inputValue(), '');
assert.equal(await page.locator('.note-card').count(), 3);
if (!baseline && device !== 'desktop') {
const beginTouch = () => page.evaluate(() => {
const card = document.querySelector('.note-card');
const rect = card.getBoundingClientRect();
const touch = new Touch({ identifier: 1, target: card, clientX: rect.left + 5, clientY: rect.top + 5 });
card.dispatchEvent(new TouchEvent('touchstart', { touches: [touch], bubbles: true }));
});
if (device === 'tablet') {
await beginTouch();
await page.waitForFunction(() => document.querySelector('.note-card.dragging'));
await page.evaluate(() => document.querySelector('.note-card.dragging').dispatchEvent(new TouchEvent('touchcancel', { bubbles: true })));
await page.locator('#notes-search').fill('checklist');
await page.locator('#notes-search').fill('');
await beginTouch();
await page.waitForFunction(() => document.querySelector('.note-card.dragging'));
} else {
await beginTouch(); // Close before the selection hold timer can fire.
}
await page.evaluate(() => window.referenceNotes.closePanel());
await page.locator('#notes-pane').waitFor({ state: 'detached' });
await page.waitForTimeout(500);
assert.equal(await page.evaluate(() => document.documentElement.style.touchAction), '');
await page.evaluate(() => window.referenceNotes.openPanel());
await page.locator('.note-card').first().waitFor();
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
}
if (mobile) await page.locator('[data-note-id="plain"] .note-card-title').tap();
else await page.locator('[data-note-id="plain"] .note-card-title').click();
if (mobile) await page.locator('.note-form-content-reader').tap({ position: { x: 5, y: 5 } });
await page.locator('.note-form-content').fill('Recovered draft');
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('odysseus-note-draft-plain')).content), 'Recovered draft');
await page.waitForFunction(() => !localStorage.getItem('odysseus-note-draft-plain'));
assert.ok(requests.some(r => r.method === 'PUT' && JSON.parse(r.body).content === 'Recovered draft'));
await page.locator('.note-form-type-pill[data-type="draw"]').click();
const canvas = page.locator('.note-form-canvas');
const box = await canvas.boundingBox();
assert.ok(box);
await page.mouse.move(box.x + 25, box.y + 25);
await page.mouse.down();
await page.mouse.move(box.x + 55, box.y + 55, { steps: 5 });
await page.mouse.up();
await page.locator('.note-form-draw-undo').waitFor();
assert.equal(await page.locator('.note-form-draw-undo').isDisabled(), false);
await page.locator('.note-form-draw-undo').click();
assert.equal(await page.locator('.note-form-draw-undo').isDisabled(), true);
await page.locator('.note-form-type-pill[data-type="note"]').click();
if (!baseline) await page.waitForFunction(() => window.referenceMouseups.size === 0);
await page.locator('.note-form-remind-btn').click();
await page.locator('.note-reminder-menu').waitFor();
await page.evaluate(() => window.referenceNotes.closePanel());
await page.locator('#notes-pane').waitFor({ state: 'detached' });
if (!baseline) {
assert.equal(await page.locator('.note-reminder-menu').count(), 0);
assert.equal(await page.evaluate(() => window.referenceIntervals.filter(t => t.ms === 30000).length), 1);
const identity = await page.evaluate(async () => {
const feature = await import('/static/js/features/notes/index.js');
const unversioned = await import('/static/js/notes.js');
return feature.default === window.referenceNotes.default && unversioned.default === feature.default && feature.openPanel === window.referenceNotes.openPanel;
});
assert.equal(identity, true);
}
assert.deepEqual(errors, []);
await context.close();
console.log(JSON.stringify({ device, mobile, ...publicAPI, requests: requests.length }));
}
} finally { await browser.close(); }
+269
View File
@@ -0,0 +1,269 @@
// Immutable N0 oracle copied from F1 commit 1cc56cb9; used only for differential tests.
const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th'];
const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function _hasTimeComponent(dateStr) {
return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr);
}
function _formatDueDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (isNaN(d)) return '';
const now = new Date();
const hasTime = _hasTimeComponent(dateStr);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const due = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round((due - today) / 86400000);
const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : '';
if (hasTime && d < now) return 'overdue';
if (!hasTime && diffDays < 0) return 'overdue';
if (diffDays === 0) return hasTime ? timeStr : 'today';
if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow';
const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
return hasTime ? `${dateLabel} ${timeStr}` : dateLabel;
}
function _isDueOverdue(dateStr) {
if (!dateStr) return false;
const d = new Date(dateStr);
if (isNaN(d)) return false;
if (_hasTimeComponent(dateStr)) return d < new Date();
return d < new Date(new Date().toDateString());
}
function _isDueTodayOrOverdue(dateStr) {
if (!dateStr) return false;
const d = new Date(dateStr);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const due = new Date(d.getFullYear(), d.getMonth(), d.getDate());
return due <= today;
}
function _laterTodayDate() {
const now = new Date();
const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today
// If less than 1 hour before 6pm, push to "in 3 hours" instead
if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000);
return eight;
}
function _tomorrowDate() {
const t = new Date();
t.setDate(t.getDate() + 1);
t.setHours(8, 0, 0, 0);
return t;
}
function _nextWeekDate() {
const t = new Date();
const daysUntilMon = (8 - t.getDay()) % 7 || 7;
t.setDate(t.getDate() + daysUntilMon);
t.setHours(8, 0, 0, 0);
return t;
}
function _toLocalDatetimeStr(d) {
// Format as YYYY-MM-DDTHH:MM (local, no TZ)
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function _formatReminderTag(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (isNaN(d)) return '';
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow = d.toDateString() === tomorrow.toDateString();
const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (sameDay) return `Today, ${time}`;
if (isTomorrow) return `Tomorrow, ${time}`;
const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
return `${dateLabel}, ${time}`;
}
function _nthWeekdayLabel(d) {
const n = Math.ceil(d.getDate() / 7); // 1..5
return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`;
}
function _isLastWeekdayOfMonth(d) {
const test = new Date(d);
test.setDate(d.getDate() + 7);
return test.getMonth() !== d.getMonth();
}
function _nthWeekdayOfMonth(year, month, weekday, n) {
const first = new Date(year, month, 1);
const offset = (weekday - first.getDay() + 7) % 7;
let day = 1 + offset + (n - 1) * 7;
// Last day of month
const lastDay = new Date(year, month + 1, 0).getDate();
if (day > lastDay) day -= 7;
return new Date(year, month, day, 0, 0, 0);
}
function _lastWeekdayOfMonth(year, month, weekday) {
const lastDay = new Date(year, month + 1, 0);
const back = (lastDay.getDay() - weekday + 7) % 7;
return new Date(year, month, lastDay.getDate() - back, 0, 0, 0);
}
function _snapToRepeat(currentDate, normRepeat) {
const hh = currentDate.getHours();
const mm = currentDate.getMinutes();
const now = Date.now();
const anchor = currentDate.getTime() > now ? currentDate : new Date();
const parts = normRepeat.split(':');
const kind = parts[0];
if (kind === 'weekly') {
const targetWd = parseInt(parts[1], 10);
if (isNaN(targetWd)) return null;
const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0);
const delta = (targetWd - d.getDay() + 7) % 7;
d.setDate(d.getDate() + delta);
if (d.getTime() <= now) d.setDate(d.getDate() + 7);
return d;
}
if (kind === 'monthly') {
const sub = parts[1];
let y = anchor.getFullYear();
let m = anchor.getMonth();
// Walk forward up to 14 months to find the next matching slot.
for (let tries = 0; tries < 14; tries++) {
let target;
if (sub === 'day') {
const wantDay = parseInt(parts[2], 10);
if (isNaN(wantDay)) return null;
const lastDay = new Date(y, m + 1, 0).getDate();
target = new Date(y, m, Math.min(wantDay, lastDay));
} else if (sub === 'nth') {
const n = parseInt(parts[2], 10);
const wd = parseInt(parts[3], 10);
if (isNaN(n) || isNaN(wd)) return null;
target = _nthWeekdayOfMonth(y, m, wd, n);
} else if (sub === 'last') {
const wd = parseInt(parts[2], 10);
if (isNaN(wd)) return null;
target = _lastWeekdayOfMonth(y, m, wd);
} else {
return null;
}
target.setHours(hh, mm, 0, 0);
if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target;
m++;
if (m > 11) { m = 0; y++; }
}
return null;
}
return null;
}
function _formatRepeatLabel(repeat, originalDate) {
if (!repeat || repeat === 'none') return '';
const norm = _normalizeRepeat(repeat, originalDate);
if (norm === 'daily') return 'Daily';
if (norm === 'yearly') return 'Yearly';
const parts = norm.split(':');
if (parts[0] === 'weekly') {
const wd = parseInt(parts[1], 10);
if (isNaN(wd)) return 'Weekly';
return `Weekly on ${_DAYS[wd]}s`;
}
if (parts[0] === 'monthly') {
if (parts[1] === 'day') return `Monthly on day ${parts[2]}`;
if (parts[1] === 'nth') {
const n = parseInt(parts[2], 10);
const wd = parseInt(parts[3], 10);
return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`;
}
if (parts[1] === 'last') {
const wd = parseInt(parts[2], 10);
return `Monthly on last ${_DAYS[wd]}`;
}
}
return norm;
}
function _normalizeRepeat(repeat, originalDate) {
if (!repeat || repeat === 'none') return 'none';
if (repeat === 'daily' || repeat === 'yearly') return repeat;
if (/^(weekly|monthly):/.test(repeat)) return repeat;
// Legacy bare values - derive params from the original date
const wd = originalDate.getDay();
const n = Math.ceil(originalDate.getDate() / 7);
if (repeat === 'weekly') return `weekly:${wd}`;
if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`;
if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`;
if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`;
return repeat;
}
function _advanceRecurring(dateStr, repeat) {
const orig = new Date(dateStr);
const hh = orig.getHours();
const mm = orig.getMinutes();
let d = new Date(orig);
const norm = _normalizeRepeat(repeat, orig);
if (norm === 'none') return null;
function step() {
if (norm === 'daily') {
d.setDate(d.getDate() + 1);
return;
}
if (norm === 'yearly') {
d.setFullYear(d.getFullYear() + 1);
return;
}
const parts = norm.split(':');
const kind = parts[0];
if (kind === 'weekly') {
// Snap to the requested weekday in the next 1-7 days
const targetWd = parseInt(parts[1], 10);
let delta = (targetWd - d.getDay() + 7) % 7;
if (delta === 0) delta = 7;
d.setDate(d.getDate() + delta);
d.setHours(hh, mm, 0, 0);
return;
}
if (kind === 'monthly') {
const sub = parts[1];
const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0);
const nm = (d.getMonth() + 1) % 12;
let target;
if (sub === 'day') {
const wantDay = parseInt(parts[2], 10);
const lastDay = new Date(ny, nm + 1, 0).getDate();
target = new Date(ny, nm, Math.min(wantDay, lastDay));
} else if (sub === 'nth') {
const n = parseInt(parts[2], 10);
const wd = parseInt(parts[3], 10);
target = _nthWeekdayOfMonth(ny, nm, wd, n);
} else if (sub === 'last') {
const wd = parseInt(parts[2], 10);
target = _lastWeekdayOfMonth(ny, nm, wd);
} else {
d = null; return;
}
target.setHours(hh, mm, 0, 0);
d = target;
return;
}
d = null;
}
step();
if (d === null) return null;
const now = Date.now();
// Cap catch-up to avoid runaway on a malformed/very-old date.
let guard = 5000;
while (d.getTime() <= now) {
if (--guard <= 0) return null;
step();
if (d === null) return null;
}
return _toLocalDatetimeStr(d);
}
+10
View File
@@ -0,0 +1,10 @@
"""Read deployed Notes responsibilities for source guards; runtime tests execute their real modules."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def notes_source(*modules):
base = ROOT / "static/js/features/notes"
paths = [base / f"{module}.js" for module in modules] if modules else sorted(base.rglob("*.js"))
return "\n".join(path.read_text(encoding="utf-8") for path in paths)
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
import { createNotesWorkflows } from '../frontend/src/features/notes/application/workflows.js';
import { createLayout } from '../frontend/src/features/notes/ui/layout.js';
test('views cannot mutate Notes state, but captured identities observe application updates', () => {
const app = createNotesState();
app.replaceNotes([{ id: 'a', items: [{ text: 'Task', done: false }] }]);
const note = app.state.notes[0];
assert.throws(() => { note.items[0].done = true; }, /state operations/);
assert.throws(() => app.state.notes.push({ id: 'other' }), /state operations/);
assert.throws(() => app.state.selectedIds.add('a'), /selection operations/);
assert.throws(() => Object.defineProperty(note, 'title', { value: 'unowned' }), /state operations/);
assert.throws(() => Object.preventExtensions(note), /state operations/);
assert.throws(() => Object.getOwnPropertyDescriptor(note, 'items').value.push({}), /state operations/);
app.updateItem(note, 0, { done: true });
assert.equal(note.items[0].done, true);
assert.equal(app.selectId('a'), undefined, 'selection operations do not leak the mutable set');
assert.deepEqual([...app.state.selectedIds], ['a']);
assert.throws(() => app.state.selectedIds.valueOf().clear(), /selection operations/);
app.state.selectedIds.forEach((_value, _key, set) => {
assert.equal(set, app.state.selectedIds);
assert.throws(() => set.clear(), /selection operations/);
});
app.mergeNote(note, { title: 'Changed' });
assert.equal(note.title, 'Changed');
});
test('fetch preserves archived query, failures clear notes, and successful fallback shapes', async () => {
const app = createNotesState(), requests = [];
let response = { ok: true, json: async () => [{ id: 'a' }] };
const workflows = createNotesWorkflows({ app, api: { listNotes: async archived => { requests.push(archived); return response; } }, effects: {} });
app.setShowingArchived(true);
await workflows._fetchNotes();
assert.deepEqual(requests, [true]);
assert.equal(app.state.loading, false);
assert.equal(app.state.notes[0].id, 'a');
response = { ok: false };
await workflows._fetchNotes();
assert.equal(app.state.notes.length, 0);
response = { ok: true, json: async () => ({ notes: [{ id: 'b' }] }) };
await workflows._fetchNotes();
assert.equal(app.state.notes[0].id, 'b');
});
test('fetch completions retain the original last-completion ordering', async () => {
const app = createNotesState(), releases = [];
const workflows = createNotesWorkflows({ app, api: { listNotes: () => new Promise(resolve => releases.push(resolve)) }, effects: {} });
const first = workflows._fetchNotes(), second = workflows._fetchNotes();
releases[1]({ ok: true, json: async () => ({ notes: [{ id: 'second' }] }) });
await second;
assert.equal(app.state.notes[0].id, 'second');
releases[0]({ ok: true, json: async () => ({ notes: [{ id: 'first' }] }) });
await first;
assert.equal(app.state.notes[0].id, 'first');
});
test('writes built from views store raw data, not read-only views', () => {
const app = createNotesState();
const raw = [{ id: 'a', items: [{ id: 'x', done: false }, { id: 'y', done: false }] }];
app.replaceNotes(raw);
const note = app.state.notes[0];
app.mergeNote(note, { items: note.items.filter(item => item.id !== 'x') });
app.prependNote({ ...note, id: 'b' });
app.spliceItems(app.state.notes[1], 1, 0, note.items[0]);
assert.doesNotThrow(() => structuredClone(raw));
raw[1].items[0].done = true;
assert.equal(app.state.notes[1].items[0].done, true);
assert.throws(() => { app.state.notes[1].items[0].done = false; }, /state operations/);
});
test('masonry observer is not re-created once Notes is closed', () => {
const created = [];
const previous = { window: globalThis.window, ResizeObserver: globalThis.ResizeObserver };
class FakeObserver { constructor() { created.push(this); this.live = true; } observe() {} disconnect() { this.live = false; } }
globalThis.ResizeObserver = FakeObserver;
globalThis.window = { ResizeObserver: FakeObserver, matchMedia: () => ({ matches: false }) };
try {
let open = true;
const layout = createLayout({ isOpen: () => open });
const body = { isConnected: true, closest: () => ({ classList: { contains: name => name === 'notes-view-grid' } }), querySelector: () => null, querySelectorAll: () => [] };
layout._applyMasonry(body);
assert.equal(created.filter(o => o.live).length, 1);
layout.dispose();
open = false;
layout._applyMasonry(body);
assert.equal(created.filter(o => o.live).length, 0);
} finally {
globalThis.window = previous.window;
globalThis.ResizeObserver = previous.ResizeObserver;
}
});
+105
View File
@@ -0,0 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import { parse } from 'acorn';
import { createNotesApi } from '../frontend/src/features/notes/infrastructure/http.js';
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
import { createNotesWorkflows } from '../frontend/src/features/notes/application/workflows.js';
const source = fs.readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8');
const ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module' });
const implementation = fs.readFileSync(new URL('../frontend/src/features/notes/index.js', import.meta.url), 'utf8');
const implementationAST = parse(implementation, { ecmaVersion: 'latest', sourceType: 'module' });
function functions(names, ports = {}) {
const datesSource = fs.readFileSync(new URL('../frontend/src/features/notes/domain/recurrence.js', import.meta.url), 'utf8').replaceAll('export ', '');
const context = vm.createContext(ports);
const datesContext = vm.createContext({ ...ports, clock: { now: () => (ports.Date || Date).now() } });
vm.runInContext(datesSource, datesContext);
context.dates = datesContext;
context.clock = { now: () => (ports.Date || Date).now() };
context.api = createNotesApi({ origin: ports.API_BASE, request: ports.fetch });
context._saveNote = context.api.saveNote;
context._patchNote = context.api.patchNote;
context._deleteNoteApi = context.api.deleteNote;
context.workflows = createNotesWorkflows({ app: createNotesState(), api: context.api, effects: {} });
for (const statement of implementationAST.body) {
const node = statement.declaration || statement;
if (node.type === 'FunctionDeclaration' && names.includes(node.id.name)) {
vm.runInContext(implementation.slice(node.start, node.end), context);
}
}
return context;
}
test('legacy public namespace and default API are intentional', () => {
const names = [];
for (const node of ast.body) {
if (node.type !== 'ExportNamedDeclaration') continue;
if (node.declaration) names.push(node.declaration.id.name);
for (const spec of node.specifiers) if (spec.exported.name !== 'default') names.push(spec.exported.name);
}
assert.deepEqual(names.sort(), ['closeNotes', 'closePanel', 'dismissFiredReminderDot', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
assert.match(source, /window.notesModule = notesModule/);
});
test('CRUD preserves same-origin credentials, payloads and error contracts', async () => {
const requests = [];
let ok = true;
const api = functions(['_saveNote', '_patchNote', '_deleteNoteApi'], {
API_BASE: 'https://notes.invalid',
fetch: async (url, options) => {
requests.push({ url, ...options });
return { ok, status: 403, json: async () => ({ id: 'saved' }) };
},
});
await api._saveNote({ title: 'New' });
await api._saveNote({ id: 'a', title: '' });
await api._patchNote('a', { due_date: null });
await api._deleteNoteApi('a');
assert.deepEqual(requests.map(r => [r.url, r.method, r.credentials]), [
['https://notes.invalid/api/notes', 'POST', 'same-origin'],
['https://notes.invalid/api/notes/a', 'PUT', 'same-origin'],
['https://notes.invalid/api/notes/a', 'PUT', 'same-origin'],
['https://notes.invalid/api/notes/a', 'DELETE', 'same-origin'],
]);
assert.equal(requests[2].body, '{"due_date":null}');
ok = false;
await assert.rejects(api._saveNote({}), /Failed to save note/);
await assert.rejects(api._patchNote('a', {}), /Failed to update note/);
await assert.rejects(api._deleteNoteApi('a'), /HTTP 403/);
});
test('recurrence clock is read after first advancement and never for none', () => {
const calls = [];
class ClockDate extends Date {
static now() { calls.push('clock'); return Date.parse('2024-02-01T12:00'); }
setDate(value) { calls.push('step'); return super.setDate(value); }
}
const domain = functions(['_normalizeRepeat', '_advanceRecurring', '_toLocalDatetimeStr', '_nthWeekdayOfMonth', '_lastWeekdayOfMonth'], { Date: ClockDate });
assert.equal(domain.dates._advanceRecurring('2024-02-01T09:00', 'daily', domain.clock), '2024-02-02T09:00');
assert.deepEqual(calls, ['step', 'clock']);
calls.length = 0;
assert.equal(domain.dates._advanceRecurring('2024-02-01T09:00', 'none', domain.clock), null);
assert.deepEqual(calls, []);
});
test('archive failure restores position; undo failure rolls back reinsertion', async () => {
const notes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }], errors = [];
const stateOwner = createNotesState();
stateOwner.replaceNotes(notes);
const app = createNotesWorkflows({
app: stateOwner,
api: { patchNote: async () => { throw new Error('refused'); } },
effects: { render: () => {}, showError: e => errors.push(e) },
});
assert.equal(app._archiveNoteById('b'), true);
assert.deepEqual(notes.map(n => n.id), ['a', 'c']);
await new Promise(setImmediate);
assert.deepEqual(notes.map(n => n.id), ['a', 'b', 'c']);
notes.splice(1, 1);
app._popAndRunUndo();
await new Promise(setImmediate);
assert.deepEqual(notes.map(n => n.id), ['a', 'c']);
assert.deepEqual(errors, ['Failed to archive', 'Undo failed']);
});
+13 -6
View File
@@ -1,21 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import { createNotesPersistence } from '../frontend/src/features/notes/infrastructure/storage.js';
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
import { createNotesDrafts } from '../frontend/src/features/notes/application/drafts.js';
import { createDraftEditor } from '../frontend/src/features/notes/ui/draftEditor.js';
const source = fs.readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8');
function harness(patch = async () => ({})) {
const data = new Map(), timers = new Map(), errors = [];
let nextTimer = 0;
const context = vm.createContext({
const context = {
localStorage: { getItem: k => data.get(k) ?? null, setItem: (k,v) => data.set(k,v), removeItem: k => data.delete(k) },
setTimeout: f => { timers.set(++nextTimer, f); return nextTimer; },
clearTimeout: id => timers.delete(id),
_patchNote: patch, _notes: [{id:'note1'}],
_collectItems: form => form.items,
uiModule: {showError: message => errors.push(message)},
});
vm.runInContext(source.slice(source.indexOf("const _DRAFT_PREFIX"), source.indexOf('// ---- Create / Edit Form ----')), context);
};
context.persistence = createNotesPersistence({ storage: context.localStorage });
context.app = createNotesState();
context.app.replaceNotes(context._notes);
context.state = context.app.state;
Object.assign(context, context.persistence);
const drafts = createNotesDrafts({ app: context.app, api: { patchNote: patch }, persistence: context.persistence, scheduler: context, effects: context.uiModule });
Object.assign(context, createDraftEditor({ ...context, ...context.persistence, bindDraft: drafts.bindDraft }));
const fields = {'.note-form-title':{value:'Title'}, '.note-form-content':{value:'Original'}};
const handlers = {};
const form = { dataset:{noteType:'note'}, items:[], querySelector: s => fields[s], addEventListener:(n,f) => handlers[n]=f };
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNotesApi } from '../frontend/src/features/notes/infrastructure/http.js';
import { createNotesPersistence, REMINDER_FIRED_KEY } from '../frontend/src/features/notes/infrastructure/storage.js';
test('list, reorder, upload, reminder and agent transports retain exact requests', async () => {
const calls = [], result = { ok: false, status: 403 };
const api = createNotesApi({ origin: 'https://notes.invalid', request: async (...args) => { calls.push(args); return result; } });
const body = { opaque: true };
assert.equal(await api.listNotes(true), result);
await api.listNotes(false); await api.reorder(['b', 'a']); await api.upload(body);
await api.fireReminder({ note_id: 'a' }); await api.defaultChat();
await api.createSession(body); await api.startChat(body);
assert.deepEqual(calls.map(([url, o]) => [url, o.method || 'GET', o.credentials]), [
['https://notes.invalid/api/notes?archived=true', 'GET', 'same-origin'],
['https://notes.invalid/api/notes', 'GET', 'same-origin'],
['https://notes.invalid/api/notes/reorder', 'POST', 'same-origin'],
['https://notes.invalid/api/upload', 'POST', 'same-origin'],
['/api/notes/fire-reminder', 'POST', 'same-origin'],
['https://notes.invalid/api/default-chat', 'GET', 'same-origin'],
['https://notes.invalid/api/session', 'POST', 'same-origin'],
['https://notes.invalid/api/chat_stream', 'POST', 'same-origin'],
]);
assert.equal(calls[2][1].body, '{"ids":["b","a"]}');
assert.equal(calls[3][1].body, body);
assert.equal(api.uploadUrl('file'), 'https://notes.invalid/api/upload/file');
});
test('draft and marker persistence retain keys, formats and malformed fallback', () => {
const values = new Map();
const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) };
const persistence = createNotesPersistence({ storage });
assert.equal(persistence._draftKey(null), 'odysseus-note-draft-__new__');
persistence._saveFiredReminders(new Set(['a', 'b']));
assert.equal(values.get(REMINDER_FIRED_KEY), '["a","b"]');
assert.deepEqual([...persistence._loadFiredReminders()], ['a', 'b']);
values.set(REMINDER_FIRED_KEY, '{broken');
assert.equal(persistence._loadFiredReminders().size, 0);
storage.setItem(persistence._draftKey('a'), '{"content":"","_ts":1}');
assert.deepEqual(persistence._loadDraft('a'), { content: '', _ts: 1 });
persistence._clearDraft('a');
assert.equal(persistence._loadDraft('a'), null);
const failing = createNotesPersistence({ storage: { getItem() { throw Error('blocked'); }, setItem() { throw Error('full'); } } });
assert.equal(failing._loadDraft('a'), null);
assert.doesNotThrow(() => failing._saveFiredReminders(new Set(['a'])));
assert.throws(() => failing.setItem('odysseus-note-draft-a', '{}'), /full/, 'draft caller must observe backup failure');
});
+41
View File
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import * as rules from '../frontend/src/features/notes/domain/recurrence.js';
const baseline = fs.readFileSync(new URL('./fixtures/notesRecurrenceBaseline.mjs', import.meta.url), 'utf8');
const timed = new Set(['_formatDueDate', '_isDueOverdue', '_isDueTodayOrOverdue', '_laterTodayDate', '_tomorrowDate', '_nextWeekDate', '_formatReminderTag', '_snapToRepeat', '_advanceRecurring']);
for (const zone of ['UTC', 'Europe/Lisbon', 'America/New_York', 'Australia/Sydney']) {
test(`recurrence differential oracle in ${zone}`, () => {
const oldZone = process.env.TZ;
process.env.TZ = zone;
try {
const now = Date.parse('2024-03-10T01:59:00');
class FixedDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } }
const original = vm.createContext({ Date: FixedDate });
vm.runInContext(baseline, original);
const compare = (name, args) => {
const before = original[name](...args);
const after = rules[name](...args, ...(timed.has(name) ? [{ now: () => now }] : []));
assert.equal(after instanceof Date ? after.getTime() : after, before instanceof Date ? before.getTime() : before, `${name}(${args})`);
};
const dates = ['2024-01-31T09:17', '2024-02-29T09:17', '2023-02-28T09:17', '2024-03-09T02:30', '2024-11-02T01:30', '2030-07-31T18:42', '1900-01-01T09:00', 'invalid', '2024-03-10'];
const repeats = [null, '', 'none', 'daily', 'weekly', 'monthly', 'yearly', 'monthly_nth_weekday', 'monthly_last_weekday', 'weekly:1', 'weekly:bad', 'monthly:day:31', 'monthly:nth:5:1', 'monthly:last:0', 'monthly:bad', 'unknown'];
for (const date of dates) {
for (const repeat of repeats) {
compare('_advanceRecurring', [date, repeat]);
compare('_normalizeRepeat', [repeat, new Date(date)]);
compare('_formatRepeatLabel', [repeat, new Date(date)]);
}
for (const name of ['_hasTimeComponent', '_formatDueDate', '_isDueOverdue', '_isDueTodayOrOverdue', '_formatReminderTag']) compare(name, [date]);
for (const repeat of ['weekly:1', 'monthly:day:31', 'monthly:nth:5:1', 'monthly:last:0', 'daily', 'monthly:bad']) compare('_snapToRepeat', [new Date(date), repeat]);
}
for (const name of ['_laterTodayDate', '_tomorrowDate', '_nextWeekDate']) compare(name, []);
for (const year of [2023, 2024]) for (let month = 0; month < 12; month++) for (let wd = 0; wd < 7; wd++) {
compare('_nthWeekdayOfMonth', [year, month, wd, 5]);
compare('_lastWeekdayOfMonth', [year, month, wd]);
}
} finally { if (oldZone === undefined) delete process.env.TZ; else process.env.TZ = oldZone; }
});
}
+90
View File
@@ -0,0 +1,90 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
import { createNotesReminders } from '../frontend/src/features/notes/application/reminders.js';
import { createNotesNotifications } from '../frontend/src/features/notes/infrastructure/notifications.js';
import { createNotesPersistence, REMINDER_DISMISSED_AT_KEY } from '../frontend/src/features/notes/infrastructure/storage.js';
function harness(notes = []) {
const now = Date.parse('2024-03-10T09:00'), app = createNotesState();
app.replaceNotes(notes);
const values = new Map(), timeouts = new Map(), intervals = new Map(), fired = [], shown = [], highlighted = [], patches = [];
let nextId = 0, response = Promise.resolve({ ok: true, json: async () => ({ synthesis: 'Synthesis' }) });
const scheduler = {
setTimeout: (fn, ms) => { const id = ++nextId; timeouts.set(id, { fn, ms }); return id; }, clearTimeout: id => timeouts.delete(id),
setInterval: (fn, ms) => { const id = ++nextId; intervals.set(id, { fn, ms }); return id; }, clearInterval: id => intervals.delete(id),
};
const persistence = createNotesPersistence({ storage: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value) } });
const reminders = createNotesReminders({
app, clock: { now: () => now }, scheduler, persistence, dismissedKey: REMINDER_DISMISSED_AT_KEY,
notifications: { show: (...args) => shown.push(args) }, fetchNotes: async () => {},
hasItems: note => ['todo', 'goal', 'checklist'].includes(note.note_type), isNoteFullyDone: () => false,
api: { listNotes: async () => ({ ok: true, json: async () => ({ notes }) }), patchNote: async (...args) => patches.push(args), fireReminder: body => { fired.push(body); return response; } },
effects: { badge: () => {}, highlight: id => highlighted.push(id) },
});
return { now, app, reminders, timeouts, intervals, fired, shown, highlighted, patches, persistence, setResponse: value => { response = value; } };
}
test('startup and loop are idempotent, and panel closure leaves background scheduling alive', async () => {
const h = harness();
h.reminders.start(); h.reminders.start();
assert.deepEqual([...h.timeouts.values()].map(t => t.ms), [3000]);
await [...h.timeouts.values()][0].fn();
h.reminders._startReminderLoop();
assert.deepEqual([...h.intervals.values()].map(t => t.ms), [30000]);
h.app.setOpen(false);
assert.equal(h.intervals.size, 1);
h.reminders.dispose();
assert.equal(h.intervals.size, 0);
h.reminders._startReminderLoop();
assert.equal(h.intervals.size, 0);
});
test('fresh reminders dispatch once, stale reminders mark silently, recurring reminders advance', async () => {
const now = Date.parse('2024-03-10T09:00');
const local = value => { const d = new Date(value); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:30`; };
const h = harness([
{ id: 'fresh', due_date: local(now - 30000), content: 'Body' },
{ id: 'old', due_date: local(now - 180000) },
{ id: 'recurring', due_date: local(now - 180000), repeat: 'daily' },
{ id: 'date', due_date: '2024-03-10' },
{ id: 'archived', archived: true, due_date: local(now - 30000) },
]);
h.reminders._checkReminders(); h.reminders._checkReminders();
await new Promise(setImmediate);
assert.deepEqual(h.fired.map(n => n.note_id), ['fresh']);
assert.deepEqual([...h.persistence._loadFiredReminders()], ['fresh', 'old']);
assert.equal(h.patches.length, 1);
assert.equal(h.app.state.notes[2].due_date, '2024-03-11T08:57');
assert.deepEqual(h.highlighted, ['fresh']);
assert.equal(h.shown[0][1], 'Synthesis');
});
test('slow synthesis and rejected dispatch use the local fallback exactly once', async () => {
const h = harness();
let resolve;
h.setResponse(new Promise(r => { resolve = r; }));
h.reminders._fireReminder({ id: 'a', title: 'Title', content: 'Raw' });
const timer = [...h.timeouts.values()].find(t => t.ms === 1500);
timer.fn();
resolve({ ok: true, json: async () => ({ synthesis: 'Too late' }) });
await new Promise(setImmediate);
assert.deepEqual(h.shown, [['Title', 'Raw', 'a']]);
h.setResponse(Promise.reject(Error('offline')));
h.reminders._fireReminder({ id: 'b', note_type: 'todo', items: [{ text: 'Task', done: false }] });
await new Promise(setImmediate);
assert.equal(h.shown.length, 2);
assert.equal(h.fired[1].body, 'Pending (1):\n- Task');
});
test('notification denial, unsupported browsers and permission errors remain fail-safe', async () => {
const toast = [];
const absent = createNotesNotifications({ browser: {}, toast: title => toast.push(title) });
assert.equal(await absent.ensurePermission(), false);
absent.show('Title', 'Body', 'a');
assert.deepEqual(toast, ['Title']);
const denied = createNotesNotifications({ browser: { Notification: true }, notify: { permission: 'denied' } });
assert.equal(await denied.ensurePermission(), false);
const failed = createNotesNotifications({ browser: { Notification: true }, notify: { permission: 'default', requestPermission: async () => { throw Error('blocked'); } } });
assert.equal(await failed.ensurePermission(), false);
});
+147
View File
@@ -0,0 +1,147 @@
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);
});
+1 -1
View File
@@ -5,7 +5,7 @@ import vm from 'node:vm';
const chatSource = readFileSync(new URL('../static/js/chat.js', import.meta.url), 'utf8');
const sessionSource = readFileSync(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
const notesSource = readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8');
const notesSource = readFileSync(new URL('../static/js/features/notes/application/selection.js', import.meta.url), 'utf8');
function setup() {
const start = chatSource.indexOf(" window.addEventListener('odysseus:notes-context-selected'");
+2 -1
View File
@@ -1,3 +1,4 @@
from tests.helpers.notes_source import notes_source
from pathlib import Path
from tests.helpers.document_source import document_source
@@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_opening_notes_minimizes_open_document():
script = (ROOT / "static/js/notes.js").read_text()
script = notes_source("ui/panel")
assert "window.documentModule?.isPanelOpen?.()" in script
assert "window.documentModule.closePanel('down')" in script
+5 -3
View File
@@ -2,12 +2,14 @@
from pathlib import Path
from tests.helpers.notes_source import notes_source
_REPO = Path(__file__).resolve().parent.parent
def test_notes_image_src_guard_rejects_script_capable_data_images():
src = (_REPO / "static" / "js" / "notes.js").read_text(encoding="utf-8")
src = notes_source("ui/text", "ui/editor", "ui/drawing")
assert "function _safeImgSrc(s)" in src
assert r"^data:image\/(?:png|jpe?g|gif|webp);base64," in src
@@ -15,7 +17,7 @@ def test_notes_image_src_guard_rejects_script_capable_data_images():
def test_notes_linkify_escapes_href_attribute():
src = (_REPO / "static" / "js" / "notes.js").read_text(encoding="utf-8")
src = notes_source("ui/text", "ui/editor", "ui/drawing")
assert "function _attrEsc(s)" in src
assert 'href="${_attrEsc(href)}"' in src
@@ -23,7 +25,7 @@ def test_notes_linkify_escapes_href_attribute():
def test_notes_edit_form_uses_safe_image_src_guard():
src = (_REPO / "static" / "js" / "notes.js").read_text(encoding="utf-8")
src = notes_source("ui/text", "ui/editor", "ui/drawing")
assert "let currentImageUrl = _safeImgSrc(note?.image_url || '');" in src
assert "let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null;" in src
@@ -1,3 +1,4 @@
from tests.helpers.notes_source import notes_source
from pathlib import Path
import re
@@ -10,23 +11,23 @@ ROOT = Path(__file__).resolve().parent.parent
def test_mobile_notes_checklist_rows_remain_tappable():
css = app_css()
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
assert "body.notes-mobile-mode .note-card .note-checkbox {" in css
assert "pointer-events: auto;" in css
assert "touch-action: manipulation;" in css
assert "body.notes-mobile-mode .note-card .note-checkbox,\nbody.notes-mobile-mode .note-card .note-checkbox-rm" not in css
assert "if (_selectMode) return; // let card-level handler take over\n e.preventDefault();" in notes
assert re.search(r"if \(state.selectMode\) return; // let card-level handler take over\n\s+e.preventDefault\(\);", notes)
def test_mobile_edit_checklist_toggle_marks_form_dirty():
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
assert "container.dispatchEvent(new Event('input', { bubbles: true }));" in notes
def test_completed_mobile_todo_uses_finish_action():
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
assert "const _isFinishedTodo = () =>" in notes
assert "const label = _isFinishedTodo() ? 'Finish' : 'Archive';" in notes
@@ -34,10 +35,10 @@ def test_completed_mobile_todo_uses_finish_action():
def test_mobile_long_press_enters_select_mode_for_that_note():
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
assert "function _enterSelectMode(initialId = null)" in notes
assert "_suppressNextNoteTapId = card.dataset.noteId;" in notes
assert "app.setSuppressNextNoteTapId(card.dataset.noteId);" in notes
assert "_enterSelectMode(card.dataset.noteId);" in notes
@@ -60,7 +61,7 @@ def test_notes_mobile_checklist_asset_versions_are_bumped():
def test_drawing_edits_mark_notes_dirty_and_keep_one_gallery_image():
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
upload = (ROOT / "routes" / "upload_routes.py").read_text(encoding="utf-8")
note_routes = (ROOT / "routes" / "note" / "note_routes.py").read_text(encoding="utf-8")
+23
View File
@@ -0,0 +1,23 @@
"""Execute the real Notes ESM graph and DOM with deterministic API responses."""
import os
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_notes_reference_desktop_mobile_and_public_identity(tmp_path):
env = dict(os.environ, NOTES_EVIDENCE_DIR=os.environ.get("NOTES_EVIDENCE_DIR", str(tmp_path)))
result = subprocess.run(
["node", "tests/browser/notesReference.mjs"], cwd=ROOT, env=env,
capture_output=True, text=True, timeout=120,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_notes_complete_graph_imports_first_time_offline():
result = subprocess.run(
["node", "tests/browser/notesOffline.mjs"], cwd=ROOT,
capture_output=True, text=True, timeout=90,
)
assert result.returncode == 0, result.stdout + result.stderr
@@ -0,0 +1,20 @@
"""Run deterministic Notes contracts in the authoritative pytest CI shards."""
import os
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_notes_domain_application_infrastructure_and_compatibility_contracts():
files = [
"notesCharacterization", "notesRecurrence", "notesInfrastructure",
"notesApplication", "notesReminders", "notesWorkflows", "notesDraftAutosave",
"selectedNotesContext", "noteTestOracle",
]
result = subprocess.run(
["node", "--test", *[f"tests/{name}.test.mjs" for name in files]], cwd=ROOT,
env=dict(os.environ, ENDPOINT_URL="http://127.0.0.1:1"),
capture_output=True, text=True, timeout=60,
)
assert result.returncode == 0, result.stdout + result.stderr
+12 -10
View File
@@ -8,32 +8,34 @@ reset is present in openPanel, beside the existing _editingId reset.
import re
from pathlib import Path
SRC = Path("static/js/notes.js").read_text(encoding="utf-8")
from tests.helpers.notes_source import notes_source
SRC = notes_source("ui/panel", "ui/filters", "ui/cardRendering", "ui/cardInteractions", "ui/drawing", "infrastructure/storage", "application/state", "ui/toolbar")
def _open_panel_body():
start = SRC.index("export function openPanel()")
rest = SRC[start + len("export function openPanel()"):]
m = re.search(r"\n(?:export\s+)?(?:async\s+)?function ", rest)
start = SRC.index("function openPanel()")
rest = SRC[start + len("function openPanel()"):]
m = re.search(r"\n\s*(?:export\s+)?(?:async\s+)?function ", rest)
return rest[: m.start()] if m else rest
def test_open_panel_resets_search_query():
body = _open_panel_body()
assert "_searchQuery = ''" in body, body[:400]
assert "app.setSearchQuery('')" in body, body[:400]
# reset must sit with the other open-time state resets, before render
assert body.index("_searchQuery = ''") < body.index("_renderNotes") if "_renderNotes" in body else True
assert body.index("app.setSearchQuery('')") < body.index("_renderNotes") if "_renderNotes" in body else True
def test_module_still_declares_search_query():
assert "let _searchQuery = ''" in SRC
assert "searchQuery: ''" in SRC
def test_notes_label_chips_can_be_collapsed_like_calendar_tags():
assert "NOTES_LABELS_COLLAPSED_KEY = 'odysseus-notes-labels-collapsed'" in SRC
assert "_labelsCollapsed = !_labelsCollapsed" in SRC
assert "localStorage.setItem(NOTES_LABELS_COLLAPSED_KEY" in SRC
assert "toggle.textContent = _labelsCollapsed ? '+ Tags' : '− Tags';" in SRC
assert "app.setLabelsCollapsed(!state.labelsCollapsed)" in SRC
assert "persistence.setItem(NOTES_LABELS_COLLAPSED_KEY" in SRC
assert "toggle.textContent = state.labelsCollapsed ? '+ Tags' : '− Tags';" in SRC
assert "bar.style.display = 'none';" in SRC
+3 -1
View File
@@ -9,7 +9,9 @@ tracked-handler pattern in source.
"""
from pathlib import Path
SRC = Path("static/js/notes.js").read_text(encoding="utf-8")
from tests.helpers.notes_source import notes_source
SRC = notes_source("ui/panel")
def test_select_esc_listener_is_tracked_not_anonymous():
+9 -7
View File
@@ -1,3 +1,4 @@
from tests.helpers.notes_source import notes_source
from pathlib import Path
from tests.helpers.stylesheets import app_css
@@ -17,31 +18,32 @@ def test_notes_sidebar_new_action_matches_library_structure_and_animation():
def test_notes_sidebar_new_action_opens_a_note_without_toggling_panel_closed():
app = (ROOT / "static/app.js").read_text(encoding="utf-8")
notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8")
notes = notes_source("ui/quickAdd", "ui/cardInteractions", "ui/reminderDisplay", "index")
assert "event.stopPropagation()" in app[app.index("const notesNewNoteBtn"):]
assert "notesModule?.newNote?.()" in app
assert "export function newNote()" in notes
assert "function newNote()" in notes
assert "requestAnimationFrame(() => _createNote('note'))" in notes
assert "openNote, newNote, openNotes" in notes
assert "newNote: quickAdd.newNote" in notes
assert "publicNewNote as newNote" in notes
def test_sidebar_new_note_focuses_note_body_instead_of_title():
notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8")
notes = notes_source("ui/quickAdd", "ui/cardInteractions", "ui/reminderDisplay", "index")
assert "form.querySelector('.note-form-content')" in notes
assert "initialField?.focus()" in notes
def test_notes_reminder_dot_stays_before_new_note_action():
notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8")
notes = notes_source("ui/quickAdd", "ui/cardInteractions", "ui/reminderDisplay", "index")
assert "sidebarBtn.querySelector('#notes-new-note-btn')" in notes
assert "sidebarBtn.insertBefore(dot, newNoteBtn || null)" in notes
def test_checklist_preview_starts_at_first_unfinished_item():
notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8")
notes = notes_source("ui/quickAdd", "ui/cardInteractions", "ui/reminderDisplay", "index")
start = notes.index("function _bindCardEvents(body)")
end = notes.index("const tapToEditOrSelect", start)
setup = notes[start:end]
@@ -51,7 +53,7 @@ def test_checklist_preview_starts_at_first_unfinished_item():
def test_checklist_preview_releases_scroll_at_its_boundaries():
notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8")
notes = notes_source("ui/quickAdd", "ui/cardInteractions", "ui/reminderDisplay", "index")
css = app_css()
assert "el.addEventListener('wheel'" in notes