joplock/tests/templates.test.js
igor 974979c688
Some checks failed
Build and push Joplock image / build-and-push (push) Failing after 2m5s
polish backup actions and size display
2026-05-20 23:20:35 +12:00

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('&#128465;'));
});
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=\'{&quot;parentId&quot;:&quot;__all_notes__&quot;}\''));
});
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('&#x60;&#x60;&#x60;') && !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">&#9881;</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)">&#8618;</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 &amp; 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('![alt text](img.png) `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&nbsp;<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>'), '&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;');
assert.equal(escapeHtml("a'b"), 'a&#39;b');
assert.equal(escapeHtml('a&b'), 'a&amp;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('&lt;script&gt;'));
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'));
});