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'));
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'));
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'));
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'));
});
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('hx-post="/fragments/trash/empty"'));
assert.ok(html.includes('Empty trash permanently?'));
assert.ok(html.includes('🗑'));
});
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('');
assert.ok(html.includes('src="/resources/49a3f012f300473d98a33b97940306b1"'));
assert.ok(html.includes('width="313"'));
assert.ok(html.includes('height="417"'));
});
test('renderMarkdown opens resource links in another tab', () => {
const html = renderMarkdown('[Manual](:/49a3f012f300473d98a33b97940306b1)');
assert.ok(html.includes('href="/resources/49a3f012f300473d98a33b97940306b1"'));
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(/
{
const md = ' ```\n indented fence\n ```';
const html = renderMarkdown(md);
assert.ok(html.includes(' {
const md = '```\nbody\n``` ';
const html = renderMarkdown(md);
assert.ok(html.includes(' {
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(/'), 'should still wrap items in
') && html.includes('| Name | '), 'header cell preserved'); assert.ok(html.includes('foo | '), 'body cell preserved'); assert.ok(html.includes('bar | '), 'body cell preserved'); }); test('renderMarkdown renders indented code blocks with spellcheck attrs', () => { const html = renderMarkdown(' indented code here'); assert.ok(html.includes('
|---|