mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-05-22 19:57:34 +00:00
Some checks failed
Build and push Joplock image / build-and-push (push) Failing after 2m5s
995 lines
48 KiB
JavaScript
995 lines
48 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const vm = require('node:vm');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { adminUserRow, autosaveConflictFragment, autosaveStatusFragment, editorFragment, escapeHtml, folderListItem, folderListFragment, folderNotesPageFragment, historyModalFragment, historySnapshotPreviewFragment, layoutPage, loggedOutPage, mfaPage, recoveryPage, mobileEditorFragment, mobileFoldersFragment, mobileNotesFragment, mobileSearchFragment, navigationFragment, noteListItem, noteListFragment, noteMetaFragment, noteMetaText, noteSyncStateFragment, renderInlineMarkdown, renderMarkdown, searchResultsFragment, settingsPage, stripMarkdownForTitle } = require('../app/templates');
|
|
|
|
test('autosaveConflictFragment wires overwrite and create copy actions', () => {
|
|
const html = autosaveConflictFragment('n1');
|
|
assert.ok(html.includes('hx-put="/fragments/editor/n1"'));
|
|
assert.ok(html.includes('hx-vals=\'{"forceSave":"1"}\''));
|
|
assert.ok(html.includes('hx-vals=\'{"createCopy":"1"}\''));
|
|
assert.ok(html.includes('hx-include="#note-editor-form"'));
|
|
});
|
|
|
|
test('editorFragment shows restore action for trashed note', () => {
|
|
const html = editorFragment({ id: 'n1', title: 'Deleted', body: 'Body', parentId: 'f1', deletedTime: 123, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }]);
|
|
assert.ok(html.includes('hx-post="/fragments/notes/n1/restore"'));
|
|
assert.ok(html.includes('Restore'));
|
|
assert.ok(html.includes('Permanently delete this note?'));
|
|
assert.ok(!html.includes('Move this note to trash?'));
|
|
});
|
|
|
|
test('editorFragment shows trash delete prompt for active note', () => {
|
|
const html = editorFragment({ id: 'n1', title: 'Active', body: 'Body', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }]);
|
|
assert.ok(html.includes('Move this note to trash?'));
|
|
assert.ok(!html.includes('Permanently delete this note?'));
|
|
});
|
|
|
|
test('editorFragment includes date and datetime toolbar buttons', () => {
|
|
const html = editorFragment({ id: 'n1', title: 'Active', body: 'Body', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }]);
|
|
assert.ok(html.includes('title="Insert date"'));
|
|
assert.ok(html.includes('title="Insert date and time"'));
|
|
assert.ok(html.includes('insertStamp(\'date\')'));
|
|
assert.ok(html.includes('insertStamp(\'datetime\')'));
|
|
assert.ok(html.includes('id="markdown-toggle"'));
|
|
assert.ok(html.includes('id="preview-toggle"'));
|
|
assert.ok(html.includes('onclick="setEditorMode(\'markdown\')"'));
|
|
assert.ok(html.includes('onclick="setEditorMode(\'preview\')"'));
|
|
assert.ok(html.includes('title="Rendered Markdown"'));
|
|
});
|
|
|
|
test('editorFragment hides lock toggle for plaintext notes', () => {
|
|
const html = editorFragment({ id: 'n1', title: 'Active', body: 'Body', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000, isEncrypted: false }, [{ id: 'f1', title: 'Folder 1' }]);
|
|
assert.ok(!html.includes('id="lock-toggle-btn"'));
|
|
});
|
|
|
|
test('editorFragment shows lock toggle for encrypted notes', () => {
|
|
const html = editorFragment({ id: 'n1', title: 'Active', body: 'Body', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000, isEncrypted: true }, [{ id: 'f1', title: 'Folder 1' }]);
|
|
assert.ok(html.includes('id="lock-toggle-btn"'));
|
|
assert.ok(html.includes('title="Unlock note"'));
|
|
});
|
|
|
|
test('editorFragment hides plaintext body for vault-protected notes', () => {
|
|
const html = editorFragment({ id: 'n1', title: 'Active', body: 'Top secret', parentId: 'vault-1', deletedTime: 0, createdTime: 1000, updatedTime: 2000, isEncrypted: false, inVault: true, vaultId: 'vault-1', vaultTitle: 'Vault 1' }, [{ id: 'vault-1', title: 'Vault 1' }]);
|
|
assert.ok(html.includes('id="lock-toggle-btn"'));
|
|
assert.ok(html.includes('data-encrypted="1"'));
|
|
assert.ok(html.includes('data-vault-id="vault-1"'));
|
|
assert.ok(html.includes('id="note-body" style="display:none">Top secret</textarea>'));
|
|
assert.ok(html.includes('id="editor-toolbar" style="display:none"'));
|
|
assert.ok(html.includes('id="note-preview"'));
|
|
assert.ok(html.includes('contenteditable="true"'));
|
|
assert.ok(html.includes('style="display:none"'));
|
|
assert.ok(html.includes('id="cm-host" style="display:none"'));
|
|
assert.ok(!html.includes('>Top secret</div>'));
|
|
assert.ok(html.includes('Vault: Vault 1'));
|
|
assert.ok(!html.includes('editor-locked-back'));
|
|
});
|
|
|
|
test('mobileEditorFragment hides plaintext preview for vault-protected notes', () => {
|
|
const html = mobileEditorFragment({ id: 'n1', title: 'Active', body: 'Top secret', parentId: 'vault-1', deletedTime: 0, createdTime: 1000, updatedTime: 2000, isEncrypted: false, inVault: true, vaultId: 'vault-1', vaultTitle: 'Vault 1' }, [{ id: 'vault-1', title: 'Vault 1' }]);
|
|
assert.ok(html.includes('id="editor-locked"'));
|
|
assert.ok(html.includes('id="note-body" style="display:none">Top secret</textarea>'));
|
|
assert.ok(html.includes('id="editor-toolbar" style="display:none"'));
|
|
assert.ok(html.includes('id="note-preview"'));
|
|
assert.ok(html.includes('contenteditable="true"'));
|
|
assert.ok(html.includes('style="display:none"'));
|
|
assert.ok(html.includes('id="cm-host" style="display:none"'));
|
|
assert.ok(!html.includes('>Top secret</div>'));
|
|
});
|
|
|
|
test('app script enforces single-screen mobile invariant via state machine', () => {
|
|
const appJs = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
|
|
// State machine + reducer
|
|
assert.ok(appJs.includes('function setMobileState(patch)'), 'has setMobileState reducer');
|
|
assert.ok(appJs.includes('function renderMobile()'), 'has renderMobile()');
|
|
assert.ok(appJs.includes('function assertSingleActiveScreen()'), 'has invariant assertion');
|
|
// renderMobile + the self-heal path in assertSingleActiveScreen are the ONLY togglers.
|
|
const matches = appJs.match(/classList\.toggle\(['"]mobile-screen-active['"]/g) || [];
|
|
assert.ok(matches.length >= 1 && matches.length <= 2, 'mobile-screen-active toggled only in render/self-heal (got '+matches.length+')');
|
|
// Old fragile API removed
|
|
assert.ok(!appJs.includes('function showMobileScreen('), 'showMobileScreen removed');
|
|
assert.ok(!appJs.includes('function syncMobileFabVisibility('), 'syncMobileFabVisibility folded into renderMobile');
|
|
});
|
|
|
|
test('navigationFragment shows trash folder empty action', () => {
|
|
const html = navigationFragment([{ id: 'de1e7ede1e7ede1e7ede1e7ede1e7ede', title: 'Trash', parentId: '' }], [], '', '');
|
|
assert.ok(html.includes('class="trash-folder-empty btn-icon-sm"'));
|
|
assert.ok(html.includes('openEmptyTrashModal()'));
|
|
assert.ok(html.includes('id="empty-trash-modal"'));
|
|
assert.ok(html.includes('Empty Trash'));
|
|
assert.ok(html.includes('This will permanently delete every note in Trash.'));
|
|
assert.ok(html.includes('onclick="closeEmptyTrashModal()"'));
|
|
assert.ok(html.includes('onsubmit="submitEmptyTrash(event)"'));
|
|
assert.ok(html.includes('🗑'));
|
|
});
|
|
|
|
test('app script exports empty trash modal handlers for inline actions', () => {
|
|
const appJs = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
|
|
assert.ok(appJs.includes('function openEmptyTrashModal()'));
|
|
assert.ok(appJs.includes('function closeEmptyTrashModal()'));
|
|
assert.ok(appJs.includes('function submitEmptyTrash(event)'));
|
|
assert.ok(appJs.includes('window.openEmptyTrashModal=openEmptyTrashModal;'));
|
|
assert.ok(appJs.includes('window.closeEmptyTrashModal=closeEmptyTrashModal;'));
|
|
assert.ok(appJs.includes('window.submitEmptyTrash=submitEmptyTrash;'));
|
|
});
|
|
|
|
test('navigationFragment shows virtual all notes without notebook actions', () => {
|
|
// In lazy mode, pass a counts Map — notes are NOT rendered inline
|
|
const counts = new Map([['__all__', 1], ['f1', 1], ['__trash__', 0]]);
|
|
const html = navigationFragment([
|
|
{ id: '__all_notes__', title: 'All Notes', parentId: '', isVirtualAllNotes: true },
|
|
{ id: 'f1', title: 'Folder 1', parentId: '' },
|
|
], counts, '__all_notes__', 'n1', '', '__all_notes__');
|
|
assert.ok(html.includes('All Notes'));
|
|
assert.ok(!html.includes('openFolderContextMenu(event,\'__all_notes__\''));
|
|
assert.ok(!html.includes('hx-vals=\'{"parentId":"__all_notes__"}\''));
|
|
});
|
|
|
|
test('navigationFragment only marks the selected note in the clicked context as active (folderNotesPageFragment)', () => {
|
|
// Notes are lazy-loaded; test folderNotesPageFragment directly
|
|
const html = folderNotesPageFragment(
|
|
[{ id: 'n1', title: 'Note 1', parentId: 'f1', deletedTime: 0 }],
|
|
'__all_notes__', 'n1', false, 1, 1,
|
|
);
|
|
assert.ok(html.includes('id="note-item-__all_notes__-n1" class="notelist-item active"'));
|
|
const html2 = folderNotesPageFragment(
|
|
[{ id: 'n1', title: 'Note 1', parentId: 'f1', deletedTime: 0 }],
|
|
'f1', '', false, 1, 1,
|
|
);
|
|
assert.ok(html2.includes('id="note-item-f1-n1" class="notelist-item"'));
|
|
assert.ok(!html2.includes('class="notelist-item active"'));
|
|
});
|
|
|
|
test('navigationFragment shows Search Results folder when query is active', () => {
|
|
const html = navigationFragment([
|
|
{ id: 'f1', title: 'Folder 1', parentId: '' },
|
|
{ id: 'f2', title: 'Folder 2', parentId: '' },
|
|
], [
|
|
{ id: 'n1', title: 'Note 1', parentId: 'f1' },
|
|
], '', '', 'note');
|
|
assert.ok(html.includes('Search Results'));
|
|
assert.ok(!html.includes('Folder 1'));
|
|
assert.ok(!html.includes('Folder 2'));
|
|
assert.ok(html.includes('Note 1'));
|
|
});
|
|
|
|
test('navigationFragment does not make empty folders expandable', () => {
|
|
const html = navigationFragment([
|
|
{ id: 'f1', title: 'Folder 1', parentId: '' },
|
|
], [], '', '');
|
|
assert.ok(html.includes('nav-folder-empty'));
|
|
assert.ok(!html.includes('onclick="toggleNavFolder(\'f1\')"'));
|
|
assert.ok(html.includes('nav-folder-toggle-placeholder'));
|
|
});
|
|
|
|
test('navigationFragment includes shared folder context menu and modal', () => {
|
|
const html = navigationFragment([{ id: 'f1', title: 'Folder 1', parentId: '' }], [], '', '');
|
|
assert.ok(html.includes('oncontextmenu="openFolderContextMenu(event,\'f1\',\'Folder 1\')"'));
|
|
assert.ok(html.includes('id="folder-context-menu"'));
|
|
assert.ok(html.includes('Edit notebook'));
|
|
assert.ok(html.includes('Delete notebook'));
|
|
assert.ok(html.includes('id="folder-modal"'));
|
|
assert.ok(html.includes('onsubmit="submitFolderEdit(event)"'));
|
|
});
|
|
|
|
test('renderMarkdown rewrites raw html resource images without self-closing slash', () => {
|
|
const html = renderMarkdown('<img src=":/49a3f012f300473d98a33b97940306b1" alt="x" width="313" height="417">');
|
|
assert.ok(html.includes('src="/resources/49a3f012f300473d98a33b97940306b1"'));
|
|
assert.ok(html.includes('data-resource-id="49a3f012f300473d98a33b97940306b1"'));
|
|
assert.ok(html.includes('class="preview-img"'));
|
|
assert.ok(html.includes('width="313"'));
|
|
assert.ok(html.includes('height="417"'));
|
|
});
|
|
|
|
test('renderMarkdown appends preview-img to raw html image classes', () => {
|
|
const html = renderMarkdown('<img src=":/49a3f012f300473d98a33b97940306b1" alt="x" class="custom" />');
|
|
assert.ok(html.includes('class="custom preview-img"'));
|
|
});
|
|
|
|
test('renderMarkdown rewrites resource links to download URLs', () => {
|
|
const html = renderMarkdown('[Manual](:/49a3f012f300473d98a33b97940306b1)');
|
|
assert.ok(html.includes('href="/resources/49a3f012f300473d98a33b97940306b1?download=1"'));
|
|
assert.ok(html.includes('target="_blank"'));
|
|
assert.ok(html.includes('rel="noopener"'));
|
|
});
|
|
|
|
test('renderMarkdown handles backticks inside fenced code blocks', () => {
|
|
const md = '```\n.-```-.\ntest\n```\n\n```\nblock2\n```';
|
|
const html = renderMarkdown(md);
|
|
const preCount = (html.match(/<pre/g) || []).length;
|
|
assert.strictEqual(preCount, 2, 'should produce two code blocks');
|
|
assert.ok(html.includes('.-```-.'), 'backticks inside code block should be preserved');
|
|
});
|
|
|
|
test('renderMarkdown handles fenced code blocks indented up to 3 spaces (CommonMark)', () => {
|
|
const md = ' ```\n indented fence\n ```';
|
|
const html = renderMarkdown(md);
|
|
assert.ok(html.includes('<pre'), 'indented fence should produce a code block');
|
|
assert.ok(html.includes('indented fence'), 'fence body should be preserved');
|
|
});
|
|
|
|
test('renderMarkdown handles closing fence with trailing whitespace', () => {
|
|
const md = '```\nbody\n``` ';
|
|
const html = renderMarkdown(md);
|
|
assert.ok(html.includes('<pre'), 'closing fence with trailing whitespace should still close the block');
|
|
assert.ok(html.includes('body'), 'fence body should be preserved');
|
|
});
|
|
|
|
test('renderMarkdown handles fenced code blocks nested inside list items', () => {
|
|
const md = '- ```\n Containers docker container rm $(docker container ls -a -q) -f\n ```\n- ```\n Images docker image rm $(docker image ls -a -q) -f\n ```';
|
|
const html = renderMarkdown(md);
|
|
const preCount = (html.match(/<pre/g) || []).length;
|
|
assert.strictEqual(preCount, 2, 'should produce two code blocks (one per list item)');
|
|
assert.ok(html.includes('<ul>'), 'should still wrap items in <ul>');
|
|
const liCount = (html.match(/<li>/g) || []).length;
|
|
assert.strictEqual(liCount, 2, 'should still produce two <li> items');
|
|
assert.ok(html.includes('Containers docker container rm'), 'first code block content preserved');
|
|
assert.ok(html.includes('Images docker image rm'), 'second code block content preserved');
|
|
// The user-reported bug: bare ``` should not leak through as inline code or text
|
|
assert.ok(!html.includes('```') && !html.includes('```'), 'no raw triple-backticks should leak through');
|
|
});
|
|
|
|
test('renderMarkdown handles fenced code block in single list item with language tag', () => {
|
|
const md = '- ```bash\n echo hi\n ```';
|
|
const html = renderMarkdown(md);
|
|
assert.ok(html.includes('<pre'), 'should produce a code block');
|
|
assert.ok(html.includes('class="language-bash"'), 'language tag should be preserved');
|
|
assert.ok(html.includes('echo hi'), 'body preserved');
|
|
assert.ok(html.includes('<ul>') && html.includes('<li>'), 'list structure preserved');
|
|
});
|
|
|
|
test('renderMarkdown renders GFM tables', () => {
|
|
const md = '| Name | Value |\n|------|-------|\n| foo | bar |';
|
|
const html = renderMarkdown(md);
|
|
assert.ok(html.includes('<table>'), 'should produce a table element');
|
|
assert.ok(html.includes('<thead>'), 'should produce a thead');
|
|
assert.ok(html.includes('<tbody>'), 'should produce a tbody');
|
|
assert.ok(html.includes('<th>Name</th>'), 'header cell preserved');
|
|
assert.ok(html.includes('<td>foo</td>'), 'body cell preserved');
|
|
assert.ok(html.includes('<td>bar</td>'), 'body cell preserved');
|
|
});
|
|
|
|
test('renderMarkdown renders indented code blocks with spellcheck attrs', () => {
|
|
const html = renderMarkdown(' indented code here');
|
|
assert.ok(html.includes('<pre spellcheck="false">'), 'pre should have spellcheck=false');
|
|
assert.ok(html.includes('<code spellcheck="false">'), 'code should have spellcheck=false');
|
|
assert.ok(html.includes('indented code here'), 'code content preserved');
|
|
});
|
|
|
|
test('renderMarkdown renders setext headings as h1 and h2', () => {
|
|
const h1 = renderMarkdown('Hello World\n===========');
|
|
assert.ok(h1.includes('<h1>Hello World</h1>'), 'setext === should produce h1');
|
|
|
|
const h2 = renderMarkdown('Subheading\n----------');
|
|
assert.ok(h2.includes('<h2>Subheading</h2>'), 'setext --- should produce h2');
|
|
});
|
|
|
|
test('renderMarkdown renders hard line breaks', () => {
|
|
const html = renderMarkdown('line one \nline two');
|
|
assert.ok(html.includes('<br>'), 'two trailing spaces should produce a <br>');
|
|
assert.ok(html.includes('line one'), 'first line preserved');
|
|
assert.ok(html.includes('line two'), 'second line preserved');
|
|
});
|
|
|
|
test('renderMarkdown renders strikethrough', () => {
|
|
const html = renderMarkdown('~~struck through~~');
|
|
assert.ok(html.includes('<s>struck through</s>'), 'strikethrough should produce <s>');
|
|
});
|
|
|
|
test('renderMarkdown expands reference-style links to inline', () => {
|
|
const html = renderMarkdown('[Example][ref]\n\n[ref]: https://example.com');
|
|
assert.ok(html.includes('href="https://example.com"'), 'reference link href resolved');
|
|
assert.ok(html.includes('>Example</a>'), 'link text preserved');
|
|
});
|
|
|
|
test('logged out layout clears client storage and service worker state', () => {
|
|
const html = layoutPage({ user: null, loginError: '' });
|
|
assert.ok(html.includes('<meta name="theme-color" content="#0b0b0b" />'));
|
|
assert.ok(html.includes('<body class="theme-dark-grey'));
|
|
assert.ok(html.includes('localStorage.removeItem'));
|
|
assert.ok(!html.includes('navigator.serviceWorker.getRegistrations'), 'should not unregister service workers on login page');
|
|
assert.ok(!html.includes('caches.keys()'), 'should not clear caches on login page');
|
|
});
|
|
|
|
|
|
test('logged out page shows cleanup progress and login link', () => {
|
|
const html = loggedOutPage('');
|
|
assert.ok(html.includes('Logging out'));
|
|
assert.ok(html.includes('Clear local storage'));
|
|
assert.ok(!html.includes('Remove service workers'), 'should not show SW removal step');
|
|
assert.ok(!html.includes('Clear cached assets'), 'should not show cache clearing step');
|
|
assert.ok(html.includes('Cleanup complete'));
|
|
assert.ok(html.includes('onclick="toggleLogoutDetail(\'session\')"'));
|
|
assert.ok(html.includes('id="logout-detail-session"'));
|
|
assert.ok(html.includes('Remove local preferences like theme'));
|
|
assert.ok(html.includes('id="logout-login-link"'));
|
|
assert.ok(html.includes('Go to login'));
|
|
assert.ok(html.includes('check.className=\'logout-step-check\''));
|
|
assert.ok(html.includes('check.textContent=\'✓\''));
|
|
assert.ok(html.includes('window.toggleLogoutDetail=function(step)'));
|
|
assert.ok(html.includes('if(loginLink)loginLink.style.display=\'inline-flex\''));
|
|
});
|
|
|
|
test('logged in layout uses logout navigation link', () => {
|
|
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' });
|
|
assert.ok(html.includes('<a href="/settings" class="btn btn-icon status-settings-link" title="Settings">⚙</a>'));
|
|
assert.ok(html.includes('<a href="/logout" class="btn btn-sm btn-secondary logout-link" onclick="return confirmLogout(event)">Logout</a>'));
|
|
assert.ok(html.includes('<a href="/logout" class="mobile-header-btn" title="Logout" onclick="return confirmLogout(event)">↪</a>'));
|
|
assert.ok(html.includes('/app.js'));
|
|
assert.ok(!html.includes('logoutNow(event)'));
|
|
assert.ok(!html.includes('hx-post="/logout"'));
|
|
});
|
|
|
|
test('settings page renders font controls and MFA details', () => {
|
|
const html = settingsPage({ user: { email: 'user@example.com' }, settings: { noteFontSize: 17, codeFontSize: 13, noteMonospace: true, dateFormat: 'DD/MM/YYYY', datetimeFormat: 'DD/MM/YYYY HH:mm', autoLogout: true, autoLogoutMinutes: 30 } });
|
|
assert.ok(html.includes('Joplock Settings'));
|
|
assert.ok(html.includes('id="settings-note-font"'));
|
|
assert.ok(html.includes('id="settings-code-font"'));
|
|
assert.ok(html.includes('id="settings-note-monospace"'));
|
|
assert.ok(html.includes('id="settings-date-format"'));
|
|
assert.ok(html.includes('id="settings-datetime-format"'));
|
|
assert.ok(html.includes('Two-Factor Authentication'));
|
|
assert.ok(html.includes('Use monospace for note text'));
|
|
assert.ok(html.includes('Reopen the last edited note on startup'));
|
|
assert.ok(html.includes('Expire session after inactivity'));
|
|
assert.ok(html.includes('name="autoLogoutMinutes"'));
|
|
assert.ok(html.includes('class="login-eye"'));
|
|
assert.ok(html.includes('saveSetting')); // auto-save function
|
|
});
|
|
|
|
test('settings page renders backup section for admin', () => {
|
|
const html = settingsPage({
|
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
|
settings: {},
|
|
isAdmin: true,
|
|
adminUsers: [],
|
|
dbCompression: {
|
|
pgVersion: 'PostgreSQL 16.2',
|
|
supported: true,
|
|
current: 'pglz',
|
|
available: ['pglz', 'lz4'],
|
|
usage: {
|
|
notes: { current: 'pglz', rows: [{ compression: 'pglz', rowCount: 12, totalBytes: 8192 }] },
|
|
attachments: { current: 'none', rows: [{ compression: 'none', rowCount: 3, totalBytes: 1048576 }] },
|
|
},
|
|
},
|
|
backupEnabled: true,
|
|
backups: [{ name: 'joplock-backup-2026.dump', createdTime: Date.UTC(2026, 4, 18, 14, 22, 31), size: 1234 }],
|
|
});
|
|
assert.ok(html.includes('Backup & Restore'));
|
|
assert.ok(html.includes('Database Compression'));
|
|
assert.ok(html.includes('/admin/db-compression'));
|
|
assert.ok(html.includes('Live Postgres compression usage from the database'));
|
|
assert.ok(html.includes('PostgreSQL 16.2'), 'should show pg version');
|
|
assert.ok(html.includes('<code>pglz</code>'));
|
|
assert.ok(html.includes('<option value="lz4">lz4</option>'));
|
|
assert.ok(html.includes('Notes'));
|
|
assert.ok(html.includes('Attachments'));
|
|
assert.ok(html.includes('Current usage'));
|
|
assert.ok(html.includes('<td>12</td>'));
|
|
assert.ok(html.includes('1.0 MB'));
|
|
assert.ok(html.includes('/admin/backups'));
|
|
assert.ok(html.includes('/admin/backups/joplock-backup-2026.dump/delete'));
|
|
assert.ok(html.includes('/admin/restore'));
|
|
assert.ok(html.includes('/admin/status'));
|
|
assert.ok(html.includes('joplock-backup-2026.dump'));
|
|
assert.ok(html.includes('1.2 KB'));
|
|
assert.ok(html.includes('Delete backup joplock-backup-2026.dump? This cannot be undone.'));
|
|
assert.ok(html.includes('/recovery'));
|
|
assert.ok(html.includes('Fast (gzip:1)'));
|
|
assert.ok(html.includes('Zstd (zstd:3)'));
|
|
assert.ok(html.includes('Uncompressed'));
|
|
});
|
|
|
|
test('settings page renders database compression section without backups configured', () => {
|
|
const html = settingsPage({
|
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
|
settings: {},
|
|
isAdmin: true,
|
|
adminUsers: [],
|
|
dbCompression: {
|
|
pgVersion: 'PostgreSQL 13.18',
|
|
supported: false,
|
|
current: '',
|
|
available: [],
|
|
usage: null,
|
|
},
|
|
backupEnabled: false,
|
|
});
|
|
assert.ok(html.includes('Database Compression'));
|
|
assert.ok(html.includes('PostgreSQL 13.18'), 'should show pg version');
|
|
assert.ok(html.includes('require PostgreSQL 14'), 'should show unsupported message');
|
|
assert.ok(!html.includes('/admin/db-compression'), 'should not show compression form');
|
|
assert.ok(html.includes('Backups are not configured.'));
|
|
});
|
|
|
|
test('stripMarkdownForTitle removes common markdown markers from titles', () => {
|
|
assert.equal(stripMarkdownForTitle('# **Hello** [world](https://example.com)'), 'Hello world');
|
|
assert.equal(stripMarkdownForTitle(' `code`'), 'alt text code');
|
|
assert.equal(stripMarkdownForTitle('a note in ++generals++'), 'a note in generals');
|
|
assert.equal(stripMarkdownForTitle('title<br>'), 'title');
|
|
assert.equal(stripMarkdownForTitle('<div>title</div>'), 'title');
|
|
assert.equal(stripMarkdownForTitle('Filter downstairs <system-reminder>ignore</system-reminder>'), 'Filter downstairs');
|
|
});
|
|
|
|
test('navigation and editor render plain note titles without markdown formatting', () => {
|
|
// Nav no longer renders note items inline — test folderNotesPageFragment for note title stripping
|
|
const navNotesHtml = folderNotesPageFragment([{ id: 'n1', title: '# **Hello**', parentId: 'f1', deletedTime: 0 }], 'f1', 'n1', false, 1, 1);
|
|
const editorHtml = editorFragment({ id: 'n1', title: '# **Hello**', body: 'Body', parentId: 'f1', createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }]);
|
|
assert.ok(navNotesHtml.includes('>Hello<'));
|
|
assert.ok(!navNotesHtml.includes('<strong>Hello</strong>'));
|
|
assert.ok(editorHtml.includes('data-placeholder="Note title">Hello</div>'));
|
|
assert.ok(editorHtml.includes('value="Hello"'));
|
|
});
|
|
|
|
test('logged in layout can render resumed editor content', () => {
|
|
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '<div>nav</div>', editorContent: '<form id="note-editor-form"></form>' });
|
|
assert.ok(html.includes('<form id="note-editor-form"></form>'));
|
|
assert.ok(html.includes('<div class="col-editor" id="editor-panel">'));
|
|
assert.ok(!html.includes('<div class="col-editor" id="editor-panel">\n\t\t\t<div class="editor-empty">Select a note</div>'));
|
|
});
|
|
|
|
test('logged in layout exposes mobile startup resume data', () => {
|
|
const html = layoutPage({
|
|
user: { email: 'user@example.com', fullName: 'User' },
|
|
navContent: '<div>nav</div>',
|
|
mobileEditorContent: mobileEditorFragment({ id: 'n1', title: 'Hello', body: 'Body', parentId: 'f1', createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }], 'f1'),
|
|
mobileStartup: { folderId: '__all_notes__', folderTitle: 'All Notes', noteId: 'n1', noteTitle: 'Hello' },
|
|
});
|
|
assert.ok(html.includes('"noteId":"n1","noteTitle":"Hello"'));
|
|
assert.ok(html.includes('mobileStartup:{"folderId":"__all_notes__","folderTitle":"All Notes","noteId":"n1","noteTitle":"Hello"}'));
|
|
assert.ok(html.includes('<span class="mobile-editor-status" id="mobile-editor-status"></span>'));
|
|
assert.ok(html.includes('id="mobile-editor-search-open"'));
|
|
assert.ok(html.includes('id="mobile-editor-search-input"'));
|
|
assert.ok(html.includes('id="mobile-search-nav-counter"'));
|
|
assert.ok(html.includes('id="mobile-search-prev-btn"'));
|
|
assert.ok(html.includes('id="mobile-search-next-btn"'));
|
|
assert.ok(html.includes('/app.js'));
|
|
assert.ok(html.includes('<div class="mobile-screen-body mobile-editor-body" id="mobile-editor-body">'));
|
|
assert.ok(html.includes('id="mobile-editor-menu-btn"'));
|
|
assert.ok(html.includes('id="mobile-ctx-move"'));
|
|
assert.ok(html.includes('id="mobile-ctx-delete"'));
|
|
assert.ok(html.includes('id="mobile-folder-picker-sheet"'));
|
|
assert.ok(html.includes('id="mobile-folder-picker-list"'));
|
|
assert.ok(html.includes('hx-put="/fragments/editor/n1"'));
|
|
assert.ok(html.includes('mobile-hidden-folder-select'));
|
|
assert.ok(!html.includes('<div class="editor-titlebar">'));
|
|
assert.ok(html.includes('<div class="editor-toolbar" id="editor-toolbar">'));
|
|
// JS functions are in public/app.js
|
|
assert.ok(html.includes('/app.js'));
|
|
assert.ok(!html.includes('...C.searchKeymap...'));
|
|
});
|
|
|
|
test('logged out layout does not show global auth code field', () => {
|
|
const html = layoutPage({ user: null, loginError: '' });
|
|
assert.ok(!html.includes('Global auth code'));
|
|
});
|
|
|
|
test('logged in layout preserves plain square brackets on preview round trip', () => {
|
|
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' });
|
|
// These functions are now in public/app.js, not inline — check app.js is referenced
|
|
assert.ok(html.includes('/app.js'));
|
|
assert.ok(html.includes('_joplockConfig'));
|
|
});
|
|
|
|
test('logged in layout includes extended Joplin theme options', () => {
|
|
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' });
|
|
assert.ok(html.includes('<option value="matrix-blue">Dark Blue</option>'));
|
|
assert.ok(html.includes('<option value="matrix-purple">Dark Purple</option>'));
|
|
assert.ok(html.includes('<option value="matrix-amber">Dark Amber</option>'));
|
|
assert.ok(html.includes('<option value="matrix-orange">Dark Orange</option>'));
|
|
assert.ok(html.includes('<option value="dark-grey">Dark Grey</option>'));
|
|
assert.ok(html.includes('<option value="dark-red">Dark Red</option>'));
|
|
assert.ok(html.includes('<option value="oled-dark">OLED Dark</option>'));
|
|
assert.ok(html.includes('<option value="solarized-light">Solarized Light</option>'));
|
|
assert.ok(html.includes('<option value="solarized-dark">Solarized Dark</option>'));
|
|
assert.ok(html.includes('<option value="nord">Nord</option>'));
|
|
assert.ok(html.includes('<option value="dracula">Dracula</option>'));
|
|
assert.ok(html.includes('<option value="aritim-dark">Aritim Dark</option>'));
|
|
});
|
|
|
|
test('logged in layout uses ordered list command and block transforms in preview toolbar', () => {
|
|
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' });
|
|
// Editor functions are now in public/app.js — verify it's referenced and toolbar HTML is present
|
|
assert.ok(html.includes('/app.js'));
|
|
assert.ok(!html.includes('clean-md-toggle'));
|
|
});
|
|
|
|
test('editorFragment uses openFilePicker for upload toolbar action', () => {
|
|
const html = editorFragment({ id: 'n1', title: 'Active', body: 'Body', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }]);
|
|
assert.ok(html.includes('onclick="openFilePicker()"'));
|
|
assert.ok(!html.includes("document.getElementById('file-upload').click()"));
|
|
});
|
|
|
|
test('logged in layout emits inline config script that parses', () => {
|
|
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '<div></div>' });
|
|
// The last script before </body> is now just the config object
|
|
const match = html.match(/<script>\s*(window\._joplockConfig[\s\S]*?)<\/script>\s*<\/body>/);
|
|
assert.ok(match, 'should have inline config script before </body>');
|
|
assert.doesNotThrow(() => new vm.Script(match[1]));
|
|
assert.ok(match[1].includes('window._joplockConfig'));
|
|
assert.ok(match[1].includes('noteOpenMode'));
|
|
assert.ok(match[1].includes('liveSearch'));
|
|
// Functions are in app.js, not inline
|
|
assert.ok(!html.includes('function openFolderContextMenu(event,id,title)'));
|
|
assert.ok(html.includes('/app.js'));
|
|
});
|
|
|
|
test('styles define ordered list spacing and matrix note text token', () => {
|
|
const css = fs.readFileSync(path.join(__dirname, '../public/styles.css'), 'utf8');
|
|
assert.ok(css.includes('--text: #e8fbe8;'));
|
|
assert.ok(css.includes('.theme-matrix-blue {'));
|
|
assert.ok(css.includes('.theme-matrix-purple {'));
|
|
assert.ok(css.includes('.theme-matrix-amber {'));
|
|
assert.ok(css.includes('.theme-matrix-orange {'));
|
|
assert.ok(css.includes('.editor-preview ul, .editor-preview ol { padding-left: 1.5em; margin: 0.5em 0; }'));
|
|
assert.ok(css.includes('.editor-preview > h1:first-child,'));
|
|
assert.ok(css.includes('.logout-progress {'));
|
|
assert.ok(css.includes('.logout-step.done {'));
|
|
assert.ok(css.includes('.logout-step-check {'));
|
|
assert.ok(css.includes('.logout-detail {'));
|
|
assert.ok(css.includes('.logout-detail.open {'));
|
|
assert.ok(css.includes('body.note-body-monospace,'));
|
|
assert.ok(css.includes('.status-settings-link {'));
|
|
assert.ok(css.includes('.settings-page {'));
|
|
assert.ok(css.includes('.settings-form {'));
|
|
assert.ok(css.includes('.settings-actions {'));
|
|
assert.ok(css.includes('.settings-qr {'));
|
|
assert.ok(css.includes('.btn.active {'));
|
|
assert.ok(css.includes('--font-size-note: 15px;'));
|
|
assert.ok(css.includes('--font-size-code: 12px;'));
|
|
assert.ok(css.includes('font-size: var(--font-size-note);'));
|
|
assert.ok(css.includes('font-size: var(--font-size-code);'));
|
|
assert.ok(css.includes('.mobile-editor-body .editor-folder-select,'));
|
|
assert.ok(css.includes('.mobile-editor-body #mobile-delete-btn {'));
|
|
assert.ok(css.includes('.mobile-hidden-folder-select {'));
|
|
assert.ok(css.includes('.mobile-folder-picker-sheet {'));
|
|
assert.ok(css.includes('.mobile-folder-picker-list {'));
|
|
});
|
|
|
|
test('styles color folders differently from notes', () => {
|
|
const css = fs.readFileSync(path.join(__dirname, '../public/styles.css'), 'utf8');
|
|
assert.ok(css.includes('.nav-folder-title {'));
|
|
assert.ok(css.includes('.sidebar-item-name {'));
|
|
assert.ok(css.includes('.notelist-item-title {'));
|
|
assert.ok(css.includes('color: var(--accent);'));
|
|
assert.ok(css.includes('color: var(--text);'));
|
|
});
|
|
|
|
test('app script snapshots upload insertion targets before opening file picker', () => {
|
|
const appJs = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
|
|
assert.ok(appJs.includes('function openFilePicker(){_uploadInsertTarget=_captureUploadInsertTarget();'));
|
|
assert.ok(appJs.includes('window.openFilePicker=openFilePicker;'));
|
|
assert.ok(appJs.includes('function _captureUploadInsertTarget(){'));
|
|
assert.ok(appJs.includes('function _insertUploadedMarkdown(markdown){'));
|
|
assert.ok(appJs.includes('if(_uploadBatchDepth===0)_uploadInsertTarget=null;'));
|
|
});
|
|
|
|
test('app script uses iOS-safe resource download helper', () => {
|
|
const appJs = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
|
|
assert.ok(appJs.includes('function _isIOSWebKit(){'));
|
|
assert.ok(appJs.includes('function _isStandalonePWA(){'));
|
|
assert.ok(appJs.includes('function _fetchResourceMeta(resourceId){'));
|
|
assert.ok(appJs.includes('function _canPreviewResourceMime(mime){'));
|
|
assert.ok(appJs.includes('function presentResourceActions(resourceId,anchorEl){'));
|
|
assert.ok(appJs.includes('function downloadResource(resourceId,anchorEl){'));
|
|
assert.ok(appJs.includes('function _shouldUseResourceActions(){return _isStandalonePWA()||isDesktopMode()}'));
|
|
assert.ok(appJs.includes('if(_shouldUseResourceActions()){presentResourceActions(id,anchorEl);return}'));
|
|
assert.ok(appJs.includes("if(_isIOSWebKit()&&!isDesktopMode()){window.location.assign(url);return}"));
|
|
assert.ok(appJs.includes('function _triggerResourceDownload(resourceId){'));
|
|
assert.ok(appJs.includes("fetch(url,{method:'HEAD',credentials:'same-origin'})"));
|
|
assert.ok(appJs.includes('downloadResource(resourceId,btn)'));
|
|
assert.ok(appJs.includes('function _fetchResourceBlob(resourceId){'));
|
|
assert.ok(appJs.includes('function _openResourceViewer(blob,mime,filename){'));
|
|
});
|
|
|
|
test('searchResultsFragment renders note items', () => {
|
|
const notes = [{ id: 'n1', title: 'My Note', body: '', bodyPreview: '', parentId: 'f1', deletedTime: 0 }];
|
|
const html = searchResultsFragment(notes);
|
|
assert.ok(html.includes('My Note'));
|
|
assert.ok(!html.includes('notelist-load-more'), 'no Load more when hasMore=false');
|
|
});
|
|
|
|
test('searchResultsFragment shows Load more when hasMore=true', () => {
|
|
const notes = [{ id: 'n1', title: 'My Note', body: '', bodyPreview: '', parentId: 'f1', deletedTime: 0 }];
|
|
const html = searchResultsFragment(notes, true, 50, 'hello world');
|
|
assert.ok(html.includes('notelist-load-more'));
|
|
assert.ok(html.includes('/fragments/search?q=hello%20world&offset=50'));
|
|
assert.ok(html.includes('hx-target="#notelist-items"'));
|
|
});
|
|
|
|
test('searchResultsFragment returns empty hint when no notes', () => {
|
|
const html = searchResultsFragment([]);
|
|
assert.ok(html.includes('No results'));
|
|
assert.ok(!html.includes('notelist-load-more'));
|
|
});
|
|
|
|
test('mobileSearchFragment renders note items', () => {
|
|
const notes = [{ id: 'n1', title: 'My Note', body: '', bodyPreview: '', parentId: 'f1', deletedTime: 0 }];
|
|
const html = mobileSearchFragment(notes);
|
|
assert.ok(html.includes('My Note'));
|
|
assert.ok(!html.includes('notelist-load-more'), 'no Load more when hasMore=false');
|
|
});
|
|
|
|
test('mobileSearchFragment shows lock for notes inside vault notebooks', () => {
|
|
const notes = [{ id: 'n1', title: 'Vault Note', body: '', bodyPreview: '', parentId: 'vault-1', deletedTime: 0, inVault: true, isEncrypted: false }];
|
|
const html = mobileSearchFragment(notes);
|
|
assert.ok(html.includes('note-lock-icon'));
|
|
assert.ok(html.includes('data-note-id="n1"'));
|
|
assert.ok(html.includes('data-vault-id="vault-1"'));
|
|
assert.ok(!html.includes('data-encrypted="1"'));
|
|
});
|
|
|
|
test('mobileSearchFragment shows Load more when hasMore=true', () => {
|
|
const notes = [{ id: 'n1', title: 'My Note', body: '', bodyPreview: '', parentId: 'f1', deletedTime: 0 }];
|
|
const html = mobileSearchFragment(notes, true, 50, 'cats');
|
|
assert.ok(html.includes('notelist-load-more'));
|
|
assert.ok(html.includes('/fragments/mobile/search?q=cats&offset=50'));
|
|
assert.ok(html.includes('hx-target="#mobile-search-results"'));
|
|
});
|
|
|
|
test('mobileSearchFragment returns empty hint when no notes', () => {
|
|
const html = mobileSearchFragment([]);
|
|
assert.ok(html.includes('No results found'));
|
|
assert.ok(!html.includes('notelist-load-more'));
|
|
});
|
|
|
|
// --- escapeHtml ---
|
|
|
|
test('escapeHtml escapes special characters', () => {
|
|
assert.equal(escapeHtml('<script>alert("x")</script>'), '<script>alert("x")</script>');
|
|
assert.equal(escapeHtml("a'b"), 'a'b');
|
|
assert.equal(escapeHtml('a&b'), 'a&b');
|
|
});
|
|
|
|
test('escapeHtml coerces non-string values', () => {
|
|
assert.equal(escapeHtml(42), '42');
|
|
assert.equal(escapeHtml(null), 'null');
|
|
assert.equal(escapeHtml(undefined), 'undefined');
|
|
});
|
|
|
|
// --- folderListItem ---
|
|
|
|
test('folderListItem renders folder button with htmx attributes', () => {
|
|
const html = folderListItem({ id: 'f1', title: 'Work', noteCount: 5 }, 'f1');
|
|
assert.ok(html.includes('class="sidebar-item active"'));
|
|
assert.ok(html.includes('data-folder-id="f1"'));
|
|
assert.ok(html.includes('hx-get="/fragments/notes?folderId=f1"'));
|
|
assert.ok(html.includes('Work'));
|
|
assert.ok(html.includes('>5<'));
|
|
});
|
|
|
|
test('folderListItem not active when different folder selected', () => {
|
|
const html = folderListItem({ id: 'f1', title: 'Work' }, 'f2');
|
|
assert.ok(!html.includes('class="sidebar-item active"'));
|
|
});
|
|
|
|
test('folderListItem shows Untitled for empty title', () => {
|
|
const html = folderListItem({ id: 'f1', title: '' }, '');
|
|
assert.ok(html.includes('Untitled'));
|
|
});
|
|
|
|
// --- folderListFragment ---
|
|
|
|
test('folderListFragment renders all folders', () => {
|
|
const html = folderListFragment([
|
|
{ id: 'f1', title: 'A' },
|
|
{ id: 'f2', title: 'B' },
|
|
], 'f1');
|
|
assert.ok(html.includes('data-folder-id="f1"'));
|
|
assert.ok(html.includes('data-folder-id="f2"'));
|
|
});
|
|
|
|
test('folderListFragment shows empty hint when no folders', () => {
|
|
const html = folderListFragment([], '');
|
|
assert.ok(html.includes('No notebooks yet'));
|
|
});
|
|
|
|
// --- noteListItem ---
|
|
|
|
test('noteListItem renders note button with editor link', () => {
|
|
const html = noteListItem({ id: 'n1', title: 'My Note' }, 'n1', 'f1');
|
|
assert.ok(html.includes('class="notelist-item active"'));
|
|
assert.ok(html.includes('data-note-id="n1"'));
|
|
assert.ok(html.includes('hx-get="/fragments/editor/n1?currentFolderId=f1"'));
|
|
assert.ok(html.includes('My Note'));
|
|
});
|
|
|
|
test('noteListItem not active when different note selected', () => {
|
|
const html = noteListItem({ id: 'n1', title: 'Note' }, 'n2', 'f1');
|
|
assert.ok(!html.includes('class="notelist-item active"'));
|
|
});
|
|
|
|
test('noteListItem strips markdown from title', () => {
|
|
const html = noteListItem({ id: 'n1', title: '# **Bold Title**' }, '', '');
|
|
assert.ok(html.includes('Bold Title'));
|
|
assert.ok(!html.includes('**'));
|
|
});
|
|
|
|
test('noteListItem shows lock for notes inside vault notebooks', () => {
|
|
const html = noteListItem({ id: 'n1', title: 'Vault Note', parentId: 'vault-1', inVault: true, isEncrypted: false }, '', 'vault-1');
|
|
assert.ok(html.includes('note-lock-icon'));
|
|
assert.ok(html.includes('data-vault-id="vault-1"'));
|
|
assert.ok(!html.includes('data-encrypted="1"'));
|
|
});
|
|
|
|
// --- noteListFragment ---
|
|
|
|
test('noteListFragment renders header with new note button and search', () => {
|
|
const html = noteListFragment([{ id: 'n1', title: 'Note' }], 'n1', 'f1');
|
|
assert.ok(html.includes('+ New note'));
|
|
assert.ok(html.includes('hx-post="/fragments/notes"'));
|
|
assert.ok(html.includes('notelist-search'));
|
|
assert.ok(html.includes('Note'));
|
|
});
|
|
|
|
test('noteListFragment shows empty hint when no notes', () => {
|
|
const html = noteListFragment([], '', 'f1');
|
|
assert.ok(html.includes('No notes'));
|
|
});
|
|
|
|
test('noteListFragment omits new note button without folderId', () => {
|
|
const html = noteListFragment([], '', '');
|
|
assert.ok(!html.includes('+ New note'));
|
|
});
|
|
|
|
// --- noteSyncStateFragment ---
|
|
|
|
test('noteSyncStateFragment includes updatedTime hidden field', () => {
|
|
const html = noteSyncStateFragment({ updatedTime: 12345 });
|
|
assert.ok(html.includes('name="baseUpdatedTime" value="12345"'));
|
|
assert.ok(html.includes('name="forceSave"'));
|
|
assert.ok(html.includes('name="createCopy"'));
|
|
});
|
|
|
|
// --- noteMetaFragment ---
|
|
|
|
test('noteMetaFragment includes data attributes for times', () => {
|
|
const html = noteMetaFragment({ createdTime: 100, updatedTime: 200 });
|
|
assert.ok(html.includes('data-created-time="100"'));
|
|
assert.ok(html.includes('data-updated-time="200"'));
|
|
assert.ok(html.includes('Created 01-Jan-70 | Edited 01-Jan-70'));
|
|
});
|
|
|
|
test('noteMetaText returns empty string when no timestamps exist', () => {
|
|
assert.equal(noteMetaText({ createdTime: 0, updatedTime: 0 }), '');
|
|
});
|
|
|
|
test('noteMetaFragment supports custom element ids', () => {
|
|
const html = noteMetaFragment({ createdTime: 100, updatedTime: 200 }, 'status-note-meta');
|
|
assert.ok(html.includes('id="status-note-meta"'));
|
|
});
|
|
|
|
test('layoutPage renders status bar note meta with dedicated id', () => {
|
|
const html = layoutPage({
|
|
user: { email: 'a@b.com', fullName: '' },
|
|
navContent: '<div>Nav</div>',
|
|
editorContent: '<div>Editor</div>',
|
|
settings: {},
|
|
});
|
|
assert.ok(html.includes('id="status-note-meta"'));
|
|
assert.ok(!html.includes('id="note-meta" class="note-meta" data-created-time="0" data-updated-time="0"'));
|
|
});
|
|
|
|
// --- autosaveStatusFragment ---
|
|
|
|
test('autosaveStatusFragment returns Saved span', () => {
|
|
const html = autosaveStatusFragment();
|
|
assert.ok(html.includes('autosave-ok'));
|
|
assert.ok(html.includes('Saved'));
|
|
});
|
|
|
|
// --- historyModalFragment ---
|
|
|
|
test('historyModalFragment renders snapshots list', () => {
|
|
const snapshots = [
|
|
{ id: 's1', savedTime: Date.now(), title: 'First' },
|
|
{ id: 's2', savedTime: Date.now() - 60000, title: 'Second' },
|
|
];
|
|
const html = historyModalFragment('n1', snapshots);
|
|
assert.ok(html.includes('data-snapshot-id="s1"'));
|
|
assert.ok(html.includes('data-snapshot-id="s2"'));
|
|
assert.ok(html.includes('history-item-active'));
|
|
assert.ok(html.includes('selectHistorySnapshot'));
|
|
assert.ok(html.includes('restoreHistorySnapshot'));
|
|
assert.ok(html.includes('closeHistoryModal'));
|
|
assert.ok(html.includes('hx-get="/fragments/history-snapshot/s1"'));
|
|
});
|
|
|
|
test('historyModalFragment shows empty state', () => {
|
|
const html = historyModalFragment('n1', []);
|
|
assert.ok(html.includes('No saved snapshots'));
|
|
assert.ok(!html.includes('restoreHistorySnapshot'));
|
|
});
|
|
|
|
// --- historySnapshotPreviewFragment ---
|
|
|
|
test('historySnapshotPreviewFragment renders body preview', () => {
|
|
const html = historySnapshotPreviewFragment({ body: 'Hello world' });
|
|
assert.ok(html.includes('Hello world'));
|
|
assert.ok(html.includes('history-snapshot-body'));
|
|
});
|
|
|
|
test('historySnapshotPreviewFragment truncates long body', () => {
|
|
const body = 'x'.repeat(4000);
|
|
const html = historySnapshotPreviewFragment({ body });
|
|
assert.ok(html.includes('…'));
|
|
});
|
|
|
|
// --- renderInlineMarkdown ---
|
|
|
|
test('renderInlineMarkdown renders bold, italic, strikethrough, underline, code', () => {
|
|
assert.equal(renderInlineMarkdown('**bold**'), '<strong>bold</strong>');
|
|
assert.equal(renderInlineMarkdown('*italic*'), '<em>italic</em>');
|
|
assert.equal(renderInlineMarkdown('~~strike~~'), '<del>strike</del>');
|
|
assert.equal(renderInlineMarkdown('++under++'), '<u>under</u>');
|
|
assert.equal(renderInlineMarkdown('`code`'), '<code spellcheck="false">code</code>');
|
|
});
|
|
|
|
test('renderInlineMarkdown returns empty for falsy input', () => {
|
|
assert.equal(renderInlineMarkdown(''), '');
|
|
assert.equal(renderInlineMarkdown(null), '');
|
|
assert.equal(renderInlineMarkdown(undefined), '');
|
|
});
|
|
|
|
// --- adminUserRow ---
|
|
|
|
test('adminUserRow renders enabled user with actions', () => {
|
|
const html = adminUserRow({ id: 'u1', email: 'a@b.com', full_name: 'Alice', enabled: true, created_time: 1700000000000 }, 'u2');
|
|
assert.ok(html.includes('a@b.com'));
|
|
assert.ok(html.includes('Alice'));
|
|
assert.ok(html.includes('badge-ok'));
|
|
assert.ok(html.includes('Enabled'));
|
|
assert.ok(html.includes('Disable User'));
|
|
assert.ok(html.includes('Delete User'));
|
|
assert.ok(html.includes('/admin/users/u1/password'));
|
|
assert.ok(html.includes('/admin/users/u1/disable'));
|
|
assert.ok(html.includes('/admin/users/u1/delete'));
|
|
});
|
|
|
|
test('adminUserRow hides disable/delete for self', () => {
|
|
const html = adminUserRow({ id: 'u1', email: 'a@b.com', enabled: true, created_time: 0 }, 'u1');
|
|
assert.ok(html.includes('your admin account'));
|
|
assert.ok(!html.includes('Disable User'));
|
|
assert.ok(!html.includes('Delete User'));
|
|
});
|
|
|
|
test('adminUserRow shows MFA badge when totp enabled', () => {
|
|
const html = adminUserRow({ id: 'u1', email: 'a@b.com', enabled: true, created_time: 0, totpEnabled: true, totpSeed: 'ABC', totpQr: 'data:qr' }, 'u2');
|
|
assert.ok(html.includes('badge-mfa'));
|
|
assert.ok(html.includes('MFA'));
|
|
assert.ok(html.includes('Disable MFA'));
|
|
assert.ok(html.includes('/admin/users/u1/mfa/disable'));
|
|
});
|
|
|
|
test('adminUserRow shows enable MFA when totp not enabled', () => {
|
|
const html = adminUserRow({ id: 'u1', email: 'a@b.com', enabled: true, created_time: 0, totpEnabled: false }, 'u2');
|
|
assert.ok(html.includes('Enable MFA'));
|
|
assert.ok(html.includes('/admin/users/u1/mfa/enable'));
|
|
});
|
|
|
|
test('adminUserRow shows disabled badge and enable button', () => {
|
|
const html = adminUserRow({ id: 'u1', email: 'a@b.com', enabled: false, created_time: 0 }, 'u2');
|
|
assert.ok(html.includes('badge-off'));
|
|
assert.ok(html.includes('Disabled'));
|
|
assert.ok(html.includes('Enable User'));
|
|
assert.ok(html.includes('/admin/users/u1/enable'));
|
|
});
|
|
|
|
// --- mfaPage ---
|
|
|
|
test('mfaPage renders MFA challenge form', () => {
|
|
const html = mfaPage();
|
|
assert.ok(html.includes('Two-Factor Authentication'));
|
|
assert.ok(html.includes('action="/login/mfa"'));
|
|
assert.ok(html.includes('name="totp"'));
|
|
assert.ok(html.includes('pattern="[0-9]{6}"'));
|
|
assert.ok(html.includes('Back to login'));
|
|
});
|
|
|
|
test('mfaPage shows error when provided', () => {
|
|
const html = mfaPage({ error: 'Invalid code' });
|
|
assert.ok(html.includes('Invalid code'));
|
|
});
|
|
|
|
test('mfaPage escapes error HTML', () => {
|
|
const html = mfaPage({ error: '<script>alert(1)</script>' });
|
|
assert.ok(html.includes('<script>'));
|
|
assert.ok(!html.includes('<script>alert'));
|
|
});
|
|
|
|
test('recoveryPage renders login and backup controls', () => {
|
|
const loggedOut = recoveryPage({ recoveryEnabled: true, isAuthenticated: false });
|
|
assert.ok(loggedOut.includes('Recovery Login'));
|
|
assert.ok(loggedOut.includes('Enter recovery mode'));
|
|
const loggedIn = recoveryPage({
|
|
recoveryEnabled: true,
|
|
isAuthenticated: true,
|
|
backupDir: '/backups',
|
|
backups: [{ name: 'joplock-backup-2026.dump', createdTime: Date.UTC(2026, 4, 18, 14, 22, 31), size: 1234 }],
|
|
});
|
|
assert.ok(loggedIn.includes('Restore Database'));
|
|
assert.ok(loggedIn.includes('/recovery/backups/joplock-backup-2026.dump/download'));
|
|
assert.ok(loggedIn.includes('/recovery/status'));
|
|
assert.ok(loggedIn.includes('Type RESTORE'));
|
|
assert.ok(loggedIn.includes('1.2 KB'));
|
|
assert.ok(loggedIn.includes('Zstd (zstd:3)'));
|
|
});
|
|
|
|
test('backup polling only reloads after running job transitions to terminal state', () => {
|
|
const settingsHtml = settingsPage({ user: { id: 'admin-1', email: 'admin@example.com' }, settings: {}, isAdmin: true, adminUsers: [], backupEnabled: true });
|
|
assert.ok(settingsHtml.includes("var lastState='idle'"));
|
|
assert.ok(settingsHtml.includes("if(lastState==='running'&&(state==='completed'||state==='failed')&&!reloaded)"));
|
|
const recoveryHtml = recoveryPage({ recoveryEnabled: true, isAuthenticated: true, backupDir: '/backups' });
|
|
assert.ok(recoveryHtml.includes("var lastState='idle'"));
|
|
assert.ok(recoveryHtml.includes("if(lastState==='running'&&(state==='completed'||state==='failed')&&!reloaded)"));
|
|
});
|
|
|
|
// --- mobileFoldersFragment ---
|
|
|
|
test('mobileFoldersFragment renders All Notes and folder rows with Map counts', () => {
|
|
const counts = new Map([['__all__', 5], ['f1', 3], ['__trash__', 1]]);
|
|
const folders = [{ id: 'f1', title: 'Work' }];
|
|
const html = mobileFoldersFragment(folders, counts);
|
|
assert.ok(html.includes('All Notes'));
|
|
assert.ok(html.includes('>5<'));
|
|
assert.ok(html.includes('Work'));
|
|
assert.ok(html.includes('>3<'));
|
|
assert.ok(html.includes('mobilePushNotes'));
|
|
assert.ok(html.includes('mobileNewNoteInFolder'));
|
|
});
|
|
|
|
test('mobileFoldersFragment shows vault lock button for vault notebooks', () => {
|
|
const html = mobileFoldersFragment([{ id: 'vault-1', title: 'Vault 1', isVault: true }], new Map([['__all__', 0], ['vault-1', 2]]));
|
|
assert.ok(html.includes('data-folder-id="vault-1"'));
|
|
assert.ok(html.includes('mobile-vault-folder-lock'));
|
|
assert.ok(html.includes('toggleVaultLock(\'vault-1\')'));
|
|
});
|
|
|
|
test('mobileFoldersFragment shows empty state when no folders', () => {
|
|
const html = mobileFoldersFragment([], new Map([['__all__', 0]]));
|
|
assert.ok(html.includes('No notebooks yet'));
|
|
});
|
|
|
|
test('mobileFoldersFragment filters out trash folder', () => {
|
|
const folders = [
|
|
{ id: 'f1', title: 'Work' },
|
|
{ id: 'de1e7ede1e7ede1e7ede1e7ede1e7ede', title: 'Trash' },
|
|
];
|
|
const html = mobileFoldersFragment(folders, new Map([['__all__', 0]]));
|
|
assert.ok(html.includes('Work'));
|
|
assert.ok(!html.includes('>Trash<'));
|
|
});
|
|
|
|
// --- mobileNotesFragment ---
|
|
|
|
test('mobileNotesFragment renders notes with editor links', () => {
|
|
const notes = [{ id: 'n1', title: 'Note 1' }, { id: 'n2', title: 'Note 2' }];
|
|
const html = mobileNotesFragment(notes, 'f1', 'Work');
|
|
assert.ok(html.includes('Note 1'));
|
|
assert.ok(html.includes('Note 2'));
|
|
assert.ok(html.includes('mobilePushEditor'));
|
|
});
|
|
|
|
test('mobileNotesFragment shows lock for notes inside vault notebooks', () => {
|
|
const notes = [{ id: 'n1', title: 'Vault Note', parentId: 'vault-1', inVault: true, isEncrypted: false }];
|
|
const html = mobileNotesFragment(notes, 'vault-1', 'Vault');
|
|
assert.ok(html.includes('note-lock-icon'));
|
|
assert.ok(html.includes('data-note-id="n1"'));
|
|
assert.ok(html.includes('data-vault-id="vault-1"'));
|
|
assert.ok(!html.includes('data-encrypted="1"'));
|
|
});
|
|
|
|
test('mobileNotesFragment shows empty state', () => {
|
|
const html = mobileNotesFragment([], 'f1', 'Work');
|
|
assert.ok(html.includes('No notes yet'));
|
|
});
|
|
|
|
test('mobileNotesFragment shows Load more when hasMore', () => {
|
|
const notes = [{ id: 'n1', title: 'Note' }];
|
|
const html = mobileNotesFragment(notes, 'f1', 'Work', true, 50);
|
|
assert.ok(html.includes('Load more'));
|
|
assert.ok(html.includes('/fragments/mobile/notes?folderId=f1&offset=50'));
|
|
});
|