joplock/tests/templates.test.js
igor cc0511c007 fix(mobile): phones in landscape keep the mobile layout and clear the notch
A phone in landscape is 812-956px wide, past the 768px breakpoint, so it
got the desktop layout (sidebar and all) in ~390px of height, and every
rotation flipped the shell and reloaded the page.

The mobile shell now also covers a touch device whose PHYSICAL screen
has a short side <= 500px (JS: _isPhoneScreen; CSS: device-width /
device-height clauses on the four former max-width:768px blocks). It
must not use the window height: a soft keyboard shrinks the window (iOS
home-screen apps even shrink innerHeight), which would flip an iPad to
the phone layout and reload it while typing; tests cover that case.

- .mobile-app, the FAB, its menu and the context sheet keep clear of
  env(safe-area-inset-left/right) (landscape notch / Dynamic Island;
  there were no left/right insets anywhere)
- short windows tighten the title bar and toolbar; with the keyboard up
  in landscape the editor's title bar steps aside (not the search header)
- guide: the 'Tablet behavior' paragraph was wrong; replaced with the
  real rule
- joplin-items spec: bound the in-page fetch and the reloads so a stall
  names its step (that test hung to the 90s limit in ~3 of ~25 full runs;
  never reproduced in 11 consecutive attempts)

Unit tests (real iPhone/iPad/Pixel numbers) and Playwright tests for the
landscape phone, the squashed iPad and a short desktop window. Full
mobile and desktop suites pass.
2026-10-13 16:02:56 +13:00

1283 lines
67 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 hides delete + restore for non-owner recipient (canWrite)', () => {
const html = editorFragment(
{ id: 'n1', title: 'Shared', body: 'Body', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000, ownerId: 'owner-1', shareId: 's1' },
[{ id: 'f1', title: 'Folder 1' }], 'f1', 'viewer-1', true,
);
assert.ok(html.includes('data-is-owner="0"'));
assert.ok(!html.includes('title="Delete"'));
assert.ok(!html.includes('hx-delete="/fragments/notes/n1"'));
});
test('editorFragment hides delete + restore for non-owner trashed note', () => {
const html = editorFragment(
{ id: 'n1', title: 'Shared', body: 'Body', parentId: 'f1', deletedTime: 123, createdTime: 1000, updatedTime: 2000, ownerId: 'owner-1', shareId: 's1' },
[{ id: 'f1', title: 'Folder 1' }], 'f1', 'viewer-1', true,
);
assert.ok(html.includes('data-is-owner="0"'));
assert.ok(!html.includes('Restore'));
assert.ok(!html.includes('Permanently delete this note?'));
});
test('editorFragment shows delete for owner of shared note', () => {
const html = editorFragment(
{ id: 'n1', title: 'Shared', body: 'Body', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000, ownerId: 'owner-1', shareId: 's1' },
[{ id: 'f1', title: 'Folder 1' }], 'f1', 'owner-1', true,
);
assert.ok(html.includes('data-is-owner="1"'));
assert.ok(html.includes('title="Delete"'));
assert.ok(html.includes('hx-delete="/fragments/notes/n1"'));
});
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('tinyMCEInsertDate()'));
assert.ok(html.includes('tinyMCEInsertDateTime()'));
assert.ok(html.includes('id="tinymce-slot"'));
});
test('editorFragment toolbar wires every action handler', () => {
const html = editorFragment({ id: 'n1', title: 'Active', body: 'Body', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }]);
const expectedHandlers = [
"tinyMCEFormat('bold')",
"tinyMCEFormat('italic')",
"tinyMCEFormat('underline')",
"tinyMCEFormat('strikethrough')",
"tinyMCEFormatBlock('h1')",
"tinyMCEFormatBlock('h2')",
"tinyMCEFormatBlock('h3')",
"tinyMCEFormat('InsertUnorderedList')",
"tinyMCEFormat('InsertOrderedList')",
'tinyMCEInsertCheckbox()',
"tinyMCEFormat('code')",
"tinyMCEFormat('mceCodeSample')",
"tinyMCEFormatBlock('blockquote')",
"tinyMCEFormat('InsertHorizontalRule')",
'tinyMCEInsertDate()',
'tinyMCEInsertDateTime()',
"tinyMCEFormat('removeformat')",
"tinyMCEFormat('mceLink')",
"tinyMCEFormat('mceImage')",
'openUploadModal()',
"openHistoryModal('n1')",
];
for (const handler of expectedHandlers) {
assert.ok(html.includes(`onclick=\"${handler}\"`), `missing toolbar handler: ${handler}`);
}
});
// ---- TinyMCE save-pipeline regression tests ----
// These guard the structural requirements for autosave to work.
// Each test corresponds to a regression that was previously broken.
test('editorFragment note-body textarea has name="body" required for formHash', () => {
// formHash() iterates form.elements and reads el.name/el.value.
// If name="body" is missing the hash never changes and saves are silently skipped.
const html = editorFragment({ id: 'n1', title: 'T', body: 'hello', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'F' }]);
assert.ok(html.includes('name="body"'), 'textarea must have name="body" for formHash');
assert.ok(html.includes('id="note-body"'), 'textarea must have id="note-body" for getTA()');
});
test('editorFragment form has hx-put and hx-trigger required for htmx autosave', () => {
// htmx.trigger(form, "joplock:save") fires the PUT only if hx-put and hx-trigger are present.
const html = editorFragment({ id: 'abc123', title: 'T', body: 'B', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'F' }]);
assert.ok(html.includes('hx-put="/fragments/editor/abc123"'), 'form must have hx-put for save');
assert.ok(html.includes('hx-trigger="joplock:save"'), 'form must have hx-trigger for save');
assert.ok(html.includes('hx-target="#autosave-status"'), 'save response must target autosave-status');
});
test('editorFragment autosave-status span present for save state display', () => {
// setSaveState() sets innerHTML of #autosave-status.
// If element is absent, Edited/Saved indicators never appear.
const html = editorFragment({ id: 'n1', title: 'T', body: 'B', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'F' }]);
assert.ok(html.includes('id="autosave-status"'), '#autosave-status must exist for save state display');
});
test('editorFragment mode toggle buttons are in titlebar, not toolbar', () => {
// MD/preview buttons must be in the titlebar row (.editor-titlebar) so they
// appear next to Saved/Undo, not inside the formatting toolbar.
const html = editorFragment({ id: 'n1', title: 'T', body: 'B', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'F' }]);
const titlebarsStart = html.indexOf('<div class="editor-titlebar">');
const toolbarStart = html.indexOf('<div class="editor-toolbar"');
assert.ok(titlebarsStart !== -1, 'editor-titlebar must exist');
assert.ok(toolbarStart !== -1, 'editor-toolbar must exist');
// titlebar ends where toolbar begins
const titlebarSection = html.slice(titlebarsStart, toolbarStart);
const toolbarSection = html.slice(toolbarStart);
assert.ok(titlebarSection.includes('id="markdown-toggle"'), 'markdown-toggle must be in titlebar');
assert.ok(titlebarSection.includes('id="preview-toggle"'), 'preview-toggle must be in titlebar');
assert.ok(!toolbarSection.includes('id="markdown-toggle"'), 'markdown-toggle must NOT be in toolbar');
assert.ok(!toolbarSection.includes('id="preview-toggle"'), 'preview-toggle must NOT be in toolbar');
});
test('editorFragment mode toggle buttons call setEditorMode', () => {
const html = editorFragment({ id: 'n1', title: 'T', body: 'B', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'F' }]);
assert.ok(html.includes("setEditorMode('markdown')"), 'MD button must call setEditorMode(\'markdown\')');
assert.ok(html.includes("setEditorMode('rich')"), 'preview button must call setEditorMode(\'rich\')');
});
test('editorFragment has cm-host for markdown mode and no old insertStamp/note-preview patterns', () => {
// CodeMirror 6 markdown mode mounts into #cm-host. The old contenteditable
// #note-preview must stay gone (superseded by TinyMCE for rendered mode).
const html = editorFragment({ id: 'n1', title: 'T', body: 'B', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'F' }]);
assert.ok(!html.includes("insertStamp("), 'toolbar must not use old insertStamp()');
assert.ok(html.includes('id="cm-host"'), 'fragment must include cm-host mount element for CM6 markdown mode');
assert.ok(!html.includes('id="note-preview"'), 'fragment must not include old note-preview contenteditable element');
});
test('editorFragment does NOT include #tinymce-editor (it lives in persistent shell host)', () => {
// Regression: If #tinymce-editor is inside the fragment, htmx swaps destroy
// the iframe on every note switch. Must stay in loggedInLayout's #tinymce-host.
const html = editorFragment({ id: 'n1', title: 'T', body: 'B', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'F' }]);
assert.ok(!html.includes('id="tinymce-editor"'), '#tinymce-editor must not be in the swapped fragment');
assert.ok(html.includes('id="tinymce-slot"'), '#tinymce-slot positioning anchor must be in the fragment');
});
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="tinymce-slot"'));
assert.ok(html.includes('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('editorFragment disables folder select for vault-protected (locked) notes', () => {
const html = editorFragment({ id: 'n1', title: 'Active', body: 'ciphertext', parentId: 'vault-1', deletedTime: 0, createdTime: 1000, updatedTime: 2000, isEncrypted: true, inVault: true, vaultId: 'vault-1', vaultTitle: 'Vault 1' }, [{ id: 'vault-1', title: 'Vault 1' }, { id: 'f2', title: 'Plain' }]);
assert.ok(/<select[^>]*id="editor-folder-select"[^>]*\bdisabled\b/.test(html), 'folder select should be disabled for vault-protected notes');
assert.ok(html.includes('title="Unlock the note to move it"'));
});
test('editorFragment does not disable folder select for plain notes', () => {
const html = editorFragment({ id: 'n1', title: 'Plain', body: 'body', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }]);
assert.ok(!/<select[^>]*id="editor-folder-select"[^>]*\bdisabled\b/.test(html), 'folder select should be enabled for plain notes');
assert.ok(html.includes('title="Move to folder"'));
});
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="tinymce-slot"'));
assert.ok(html.includes('style="display:none"'));
assert.ok(!html.includes('>Top secret</div>'));
});
test('mobileEditorFragment keeps #editor-sync-state (baseUpdatedTime) for conflict + freshness checks', () => {
// The titlebar-stripping transform used to drop the sync-state span, which
// removed baseUpdatedTime from the mobile form: mobile saves then skipped
// the server conflict guard (silent last-write-wins) and checkNoteFreshness
// early-returned (base = 0), so the desktop<->mobile switch never detected
// concurrent changes.
const html = mobileEditorFragment({ id: 'n1'.padEnd(32, '2'), title: 'T', body: 'b', parentId: 'f1'.padEnd(32, '3'), deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1'.padEnd(32, '3'), title: 'F' }]);
assert.ok(html.includes('id="editor-sync-state"'), 'mobile fragment must keep #editor-sync-state');
assert.ok(html.includes('name="baseUpdatedTime" value="2000"'), 'mobile fragment must carry baseUpdatedTime');
});
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 without superfluous attrs', () => {
const html = renderMarkdown(' indented code here');
assert.ok(html.includes('<pre><code>'), 'pre and code should be present');
assert.ok(html.includes('indented code here'), 'code content preserved');
assert.ok(!html.includes('spellcheck='), 'pre/code should not have spellcheck attr');
});
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-font-family"'));
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('Monospace (Cascadia Mono)'));
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('onclick="switchTab(\'ai\')"'));
assert.ok(html.includes('id="tab-ai"'));
assert.ok(html.includes('onclick="switchTab(\'expander\')"'));
assert.ok(html.includes('id="tab-expander"'));
assert.ok(html.includes('id="text-expanders-list"'));
assert.ok(html.includes('Changes are saved automatically'));
assert.ok(html.includes('_saveExpandersSoon'));
assert.ok(html.includes('AI autocomplete'));
assert.ok(html.includes('Active AI profile'));
assert.ok(!html.includes('saveTextExpanders'));
assert.ok(html.includes('Autocomplete triggers'));
assert.ok(html.includes('Configure AI autocomplete triggers in the Expander tab'));
assert.ok(!html.includes('id="settings-prose-manual-trigger"'));
assert.ok(html.includes('id="ai-profiles-list"'));
assert.ok(html.includes('id="ai-active-select"'));
assert.ok(!html.includes('id="settings-autocomplete-enabled"'));
assert.ok(!html.includes('id="settings-prose-delay"'));
assert.ok(html.includes('id="settings-prose-sentences"'));
const aiPanel = html.slice(html.indexOf('id="tab-ai"'), html.indexOf('<!-- Tab: Profile -->'));
assert.ok(aiPanel.includes('addAiProfile'));
assert.ok(aiPanel.includes('Note AI instructions'));
assert.ok(aiPanel.includes('AI Provider Profiles'));
assert.ok(aiPanel.includes('saveAiProfiles'));
assert.ok(html.includes('testAiProfile'));
assert.ok(html.includes('data-field="temperature"'));
assert.ok(html.includes('Temperature'));
assert.ok(html.includes('[joplock] AI provider test failed'));
assert.ok(html.includes('class="login-eye"'));
assert.ok(html.includes('saveSetting')); // auto-save function
});
test('editor fragment omits autocomplete toggle button', () => {
const html = editorFragment({ id: 'n1', title: 'Title', body: 'Body', parentId: 'f1' }, [{ id: 'f1', title: 'Folder' }]);
assert.ok(!html.includes('id="autocomplete-toggle"'));
assert.ok(!html.includes('&#129302;'));
});
test('logged in layout omits mobile autocomplete toggle button', () => {
const html = layoutPage({ user: { id: 'u1', email: 'a@example.com' }, navContent: '', editorContent: '', settings: {} });
assert.ok(!html.includes('id="mobile-autocomplete-toggle"'));
assert.ok(!html.includes('onclick="toggleAutocomplete()"'));
assert.ok(!html.includes('&#129302;'));
const appJs = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
assert.ok(appJs.includes("{key:'Ctrl-Space',run:function(){requestManualProseCompletion();return true}}"));
assert.ok(appJs.includes('[joplock] AI provider error'));
assert.ok(appJs.includes('[joplock] prose autocomplete request context'));
assert.ok(appJs.includes('[joplock] prose autocomplete provider context'));
assert.ok(appJs.includes('var _textExpanders=Array.isArray(_cfg.textExpanders)'));
assert.ok(appJs.includes("fetch('/api/web/client-log'"));
assert.ok(appJs.includes("_clientLog('expander.detect'"));
assert.ok(appJs.includes('function detectTextExpanderFromBuffer(buffer){'));
assert.ok(appJs.includes('function consumePendingTextExpansion(source){'));
assert.ok(appJs.includes('function runTextExpanderAction(entry,source){'));
assert.ok(appJs.includes('profileId=opts.profileId'));
assert.ok(appJs.includes('function maybeTriggerManualProseFromCM(cm, upd){'));
assert.ok(!appJs.includes('function getRenderManualProseTriggerState(){'));
assert.ok(!appJs.includes('function getActiveManualTriggerOption(){'));
assert.ok(!appJs.includes('function getMatchedManualTriggerSuffix(text){'));
assert.ok(!appJs.includes('[joplock] manual autocomplete trigger check'));
assert.ok(!appJs.includes('[joplock] manual autocomplete trigger detected'));
assert.ok(!appJs.includes('[joplock] manual autocomplete trigger blocked'));
assert.ok(!appJs.includes('function maybeTriggerManualProseFromPV(e){'));
assert.ok(!appJs.includes('function maybeTriggerManualProseFromCodeMirrorDom(){'));
assert.ok(!appJs.includes("_cmView.contentDOM.addEventListener('input',maybeTriggerManualProseFromCodeMirrorDom);"));
assert.ok(!appJs.includes('launchManualProseFromCM'));
assert.ok(!appJs.includes('function getSelectionTextPosition(){'));
assert.ok(!appJs.includes('function isCaretAfterSpace()'));
assert.ok(!appJs.includes('maybeTriggerManualProseFromPV'));
assert.ok(!appJs.includes("tx.isUserEvent('input.type')"));
assert.ok(appJs.includes('requestManualProseCompletion({allowWithoutCtrlSpace:true'));
assert.ok(!appJs.includes('proseAutocompleteManualTrigger'));
assert.ok(!appJs.includes('double-q'));
assert.ok(appJs.includes('function visualViewportBounds()'));
assert.ok(appJs.includes('function placeRenderAutocompletePopup(popup, coords)'));
assert.ok(appJs.includes('if (!coords || !Number.isFinite(coords.top)'));
assert.ok(appJs.includes('var viewportTop=vv.top+margin;'));
assert.ok(appJs.includes('popup.style.maxHeight=Math.max(80,vv.height-(margin*2))'));
assert.ok(appJs.includes('if(_prevRenderedScreen!==null&&_prevRenderedScreen!==_state.screen)hideRenderAutocompletePopup();'));
assert.ok(!appJs.includes('cm2.coordsAtPos(s.from)||cm2.dom.getBoundingClientRect()'));
});
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 openUploadModal 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="openUploadModal()"'));
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>', settings: { noteOpenMode: 'markdown' } });
const match = html.match(/<script>\s*(window\._joplockConfig[\s\S]*?)<\/script>/);
assert.ok(match, 'should have inline config script');
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('"markdown"'), 'markdown preference must be injected into _joplockConfig');
assert.ok(match[1].includes('liveSearch'));
assert.ok(!match[1].includes('proseAutocompleteManualTrigger'));
assert.ok(!match[1].includes('proseAutocompleteManualTriggerOptions'));
// Config MUST precede app.js so `_cfg.noteOpenMode` is set when app.js parses.
const cfgIdx = html.indexOf('window._joplockConfig');
const appIdx = html.indexOf('/app.js');
assert.ok(cfgIdx !== -1 && appIdx !== -1 && cfgIdx < appIdx, '_joplockConfig must be defined before app.js loads');
// 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('.ai-profiles-bar {'));
assert.ok(css.includes('.ai-profile-field .settings-field-hint,'));
assert.ok(css.includes('overflow-wrap: anywhere;'));
assert.ok(css.includes('@media (max-width: 768px), (pointer: coarse)'));
assert.ok(css.includes('.settings-tabs {\n\t\tdisplay: grid;'));
assert.ok(css.includes('grid-template-columns: 1fr;'));
assert.ok(css.includes('.ai-profiles-bar {\n\t\tdisplay: grid;'));
assert.ok(css.includes('.ai-profile-header {\n\t\tdisplay: grid;'));
assert.ok(css.includes('.settings-tab.active {\n\t\tborder-color: var(--accent);'));
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('double-click opens image/attachment in the in-app lightbox (desktop)', () => {
const appJs = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
// Lightbox entry point: viewable -> in-app viewer, else download.
assert.ok(appJs.includes('function _openResourceLightbox(resourceId){'));
assert.ok(appJs.includes('if(meta&&!_canPreviewResourceMime(meta.mime)){_triggerResourceDownload(id);return}'));
assert.ok(appJs.includes('_fetchResourceBlob(id).then(function(r){_openResourceViewer(r.blob,r.mime,r.filename)})'));
// TinyMCE (desktop rendered mode) dblclick wiring on img/attachment link.
assert.ok(appJs.includes("editor.on('dblclick',function(e){"));
assert.ok(appJs.includes("var el=target.closest('img[data-resource-id],a[data-resource-id]');"));
// Preview/contenteditable dblclick wiring, desktop-only, no single-click open.
assert.ok(appJs.includes("pv.addEventListener('dblclick',function(e){if(!isDesktopMode())return;var el=e.target.closest('img.preview-img[data-resource-id],a[data-resource-id]');"));
assert.ok(!appJs.includes('e.preventDefault();_openResourceView(resourceId)}'), 'desktop single-click image open removed');
// Esc closes the lightbox first; lightbox counts as an open modal.
assert.ok(appJs.includes("var resViewer=document.getElementById('resource-viewer');if(resViewer&&!resViewer.hidden){_log('esc:resource-viewer visible, closing');_closeResourceViewer();return}"));
assert.ok(appJs.includes("'new-folder-modal','resource-viewer'"));
// Overlay owns its own Escape + focus so it closes even when focus was in the
// TinyMCE iframe (whose key events never reach the document handler).
assert.ok(appJs.includes("overlay.addEventListener('keydown',function(e){if(e.key==='Escape'){e.preventDefault();e.stopPropagation();_closeResourceViewer()}});"));
});
test('mode switch reconciles away the spurious "Edited" from the content round-trip', () => {
const appJs = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
// The reconcile helper exists and is invoked after switching to markdown.
assert.ok(appJs.includes('function _reconcileSaveStateAfterModeSwitch(){'));
assert.ok(appJs.includes('_reconcileSaveStateAfterModeSwitch();'));
// It only resets to "Saved" when the note is NOT actually dirty, so a real
// pending change (e.g. an upload right before the switch) still saves.
assert.ok(appJs.includes("if(typeof _activeEditorIsDirty==='function'?!_activeEditorIsDirty():formHash(form)===_savedHash){"));
assert.ok(appJs.includes('setSaveState(\'<span class="autosave-ok">Saved</span>\',\'Saved\');'));
// The form listeners are NOT globally gated (that would suppress real saves).
assert.ok(
appJs.includes("form.addEventListener('input',function(e){if(_isLockedOverlayEventTarget(e.target))return;")
&& appJs.includes(";markEdited();scheduleSave()})")
);
});
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>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'));
});
test('mobileNotesFragment marks recipient rows non-owner', () => {
const notes = [{ id: 'n1', title: 'Shared', ownerId: 'owner-1' }];
const html = mobileNotesFragment(notes, 'f1', 'Work', false, 0, 'viewer-1');
assert.ok(html.includes('data-is-owner="0"'));
});
test('mobileNotesFragment marks owner rows owner', () => {
const notes = [{ id: 'n1', title: 'Own', ownerId: 'owner-1' }];
const html = mobileNotesFragment(notes, 'f1', 'Work', false, 0, 'owner-1');
assert.ok(html.includes('data-is-owner="1"'));
});
test('mobileNotesFragment treats unowned rows as owner', () => {
const notes = [{ id: 'n1', title: 'Legacy' }];
const html = mobileNotesFragment(notes, 'f1', 'Work', false, 0, 'viewer-1');
assert.ok(html.includes('data-is-owner="1"'));
});