mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 11:52:25 +02:00
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:
co-authored by
Léo
parent
9f8eae72fd
commit
f1a47f3812
@@ -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(); }
|
||||
@@ -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(); }
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>') } });
|
||||
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('"\'<>`'), '"'<>`');
|
||||
assert.match(helpers._linkify('<script> https://example.org/test'), /<script>/);
|
||||
assert.doesNotMatch(helpers._linkify('<script> https://example.org/test'), /<script>/i);
|
||||
});
|
||||
@@ -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'");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user