const {test, expect} = require('@playwright/test'); test.beforeEach(async ({page}) => { await page.goto('/index.html'); await page.waitForSelector('#tree', {timeout: 5000}); }); test('send message to chat', async ({ page }) => { await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); await page.keyboard.type('My message'); await page.waitForTimeout(300); // TODO I believe chat is reloaded 2 times for some reason, it blinks, and thus removes previous message // Or wait for timeout before typing message doesn't help hmm await page.keyboard.press('Enter'); await page.waitForSelector('.message'); let content = await page.textContent('.message-content') expect(content).toBe('My message'); }); test('select all in chat input selects input text, not bubbles', async ({page}) => { await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); await page.keyboard.type('First message'); await page.waitForTimeout(300); await page.keyboard.press('Enter'); await page.waitForSelector('.message'); await page.locator('#chat-input').click(); await page.keyboard.type('to be cleared'); await expect(page.locator('#chat-input')).toHaveValue('to be cleared'); const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; await page.keyboard.press(`${modifier}+a`); await page.keyboard.press('Delete'); await expect(page.locator('#chat-input')).toHaveValue(''); await expect(page.locator('.message-content')).toHaveText('First message'); }); test('move to dir creates a new file inside that dir', async ({page}) => { await page.evaluate(() => { window.getTemporaryStorageDirHandle = async function () { const root = await navigator.storage.getDirectory(); await root.getDirectoryHandle('projects', {create: true}); return root; }; }); await page.evaluate(() => init(document.getElementById('editor'))); await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); await page.keyboard.type('MyTask'); await page.waitForTimeout(300); await page.keyboard.press('Enter'); await page.waitForSelector('.message'); await page.hover('.message'); await page.locator('.to-file-btn').first().click({force: true}); await page.waitForSelector('#search', {state: 'visible'}); await page.locator('#search-results li[data-dir="projects"]').click(); await page.waitForSelector('.message', {state: 'detached'}); const exists = await page.evaluate(async () => { const root = await navigator.storage.getDirectory(); const projects = await root.getDirectoryHandle('projects'); try { await projects.getFileHandle('MyTask.md'); return true; } catch { return false; } }); expect(exists).toBe(true); }); test('move to root creates a new file at root', async ({page}) => { await page.evaluate(() => { window.getTemporaryStorageDirHandle = async function () { return await navigator.storage.getDirectory(); }; }); await page.evaluate(() => init(document.getElementById('editor'))); await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); await page.keyboard.type('RootMsg'); await page.waitForTimeout(300); await page.keyboard.press('Enter'); await page.waitForSelector('.message'); await page.hover('.message'); await page.locator('.to-file-btn').first().click({force: true}); await page.waitForSelector('#search', {state: 'visible'}); await page.locator('#search-results li[data-dir=""]').click(); await page.waitForSelector('.message', {state: 'detached'}); const exists = await page.evaluate(async () => { const root = await navigator.storage.getDirectory(); try { await root.getFileHandle('RootMsg.md'); return true; } catch { return false; } }); expect(exists).toBe(true); }); test('move to existing file appends content', async ({page}) => { // Seed once, then return the same root on subsequent calls — otherwise the // app's repeated getRootDirHandle() calls would re-overwrite Notes.md. await page.evaluate(async () => { const root = await navigator.storage.getDirectory(); const fh = await root.getFileHandle('Notes.md', {create: true}); const w = await fh.createWritable(); await w.write('# Notes'); await w.close(); window.getTemporaryStorageDirHandle = async () => navigator.storage.getDirectory(); }); await page.evaluate(() => init(document.getElementById('editor'))); await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); await page.keyboard.type('Append me'); await page.waitForTimeout(300); await page.keyboard.press('Enter'); await page.waitForSelector('.message'); await page.hover('.message'); await page.locator('.to-file-btn').first().click({force: true}); await page.waitForSelector('#search', {state: 'visible'}); await page.locator('#search-results li[data-path="/Notes.md"]').click(); await page.waitForSelector('.message', {state: 'detached'}); await page.click(`#tree .tree-item:has-text('Notes')`); await page.waitForTimeout(200); const content = await page.evaluate(() => document.querySelector('.CodeMirror').CodeMirror.getValue()); expect(content).toContain('# Notes'); expect(content).toContain('Append me'); }); test('move to file does not prepend a timestamp', async ({page}) => { await page.evaluate(async () => { const root = await navigator.storage.getDirectory(); const fh = await root.getFileHandle('Notes.md', {create: true}); const w = await fh.createWritable(); await w.write('# Notes'); await w.close(); window.getTemporaryStorageDirHandle = async () => navigator.storage.getDirectory(); }); await page.evaluate(() => init(document.getElementById('editor'))); await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); await page.keyboard.type('Attention is all you need'); await page.waitForTimeout(300); await page.keyboard.press('Enter'); await page.waitForSelector('.message'); await page.hover('.message'); await page.locator('.to-file-btn').first().click({force: true}); await page.waitForSelector('#search', {state: 'visible'}); await page.locator('#search-results li[data-path="/Notes.md"]').click(); await page.waitForSelector('.message', {state: 'detached'}); await page.click(`#tree .tree-item:has-text('Notes')`); await page.waitForTimeout(200); const content = await page.evaluate(() => document.querySelector('.CodeMirror').CodeMirror.getValue()); expect(content).toContain('Attention is all you need'); // The body must not be prefixed with `HH:MM` - that's reserved for the // chat→journal flow, not move-to-file (web/lib/md.js:addHeaderAndText). expect(content).not.toMatch(/`\d{2}:\d{2}`\s*500k/); }); test('move to recent file does not prepend a timestamp', async ({page}) => { await page.evaluate(() => { window.getTemporaryStorageDirHandle = async function () { const root = await navigator.storage.getDirectory(); await root.getFileHandle('File.md', {create: true}); return root; }; }); await page.evaluate(() => init(document.getElementById('editor'))); await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); await page.keyboard.type('Attention is all you need'); await page.waitForTimeout(300); await page.keyboard.press('Enter'); await page.waitForSelector('.message'); await page.hover('.message'); await page.locator('.action-btn').filter({hasText: 'File'}).click({force: true}); await page.waitForSelector('.message', {state: 'detached'}); await page.click(`#tree .tree-item:has-text('File')`); await page.waitForTimeout(200); const fileContent = await page.evaluate(() => document.querySelector('.CodeMirror').CodeMirror.getValue()); expect(fileContent).toContain('Attention is all you need'); expect(fileContent).not.toMatch(/`\d{2}:\d{2}`\s*500k/); }); test('system dirs (archive, today) are hidden in move-to-file modal', async ({page}) => { await page.evaluate(() => { window.getTemporaryStorageDirHandle = async function () { const root = await navigator.storage.getDirectory(); await root.getDirectoryHandle('archive', {create: true}); await root.getDirectoryHandle('projects', {create: true}); return root; }; }); await page.evaluate(() => init(document.getElementById('editor'))); await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); await page.keyboard.type('Hello'); await page.waitForTimeout(300); await page.keyboard.press('Enter'); await page.waitForSelector('.message'); await page.hover('.message'); await page.locator('.to-file-btn').first().click({force: true}); await page.waitForSelector('#search', {state: 'visible'}); await expect(page.locator('#search-results li[data-dir="projects"]')).toBeVisible(); await expect(page.locator('#search-results li[data-dir="archive"]')).toHaveCount(0); await expect(page.locator('#search-results li[data-dir="today"]')).toHaveCount(0); }); test('send to chat and move to recent file', async ({ page }) => { await page.evaluate(() => { window.getTemporaryStorageDirHandle = async function() { const root = await navigator.storage.getDirectory(); const fileHandle = await root.getFileHandle('File.md', { create: true }); return root; }; }); await page.evaluate(() => { init(document.getElementById("editor")); }); await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); await page.keyboard.type('My message'); await page.waitForTimeout(300); await page.keyboard.press('Enter'); await page.waitForSelector('.message'); let content = await page.textContent('.message-content') expect(content).toBe('My message'); await page.hover('.message'); await page.locator('.action-btn').filter({hasText: 'File'}).click({force: true}); await page.waitForSelector('.message', {state: 'detached'}); await page.click(`#tree .tree-item:has-text('File')`); await page.waitForTimeout(200); const fileContent = await page.evaluate(() => document.querySelector('.CodeMirror').CodeMirror.getValue()); expect(fileContent).toContain('# File'); expect(fileContent).toContain('My message'); }); // Regression: moving a lowercase-starting chat message via the to-file-btn // used to crash because chat.js applied ucfirst() to the text before passing // it to the search modal, while the DOM dataset.text stayed in original case. // `find(el => el.dataset.text === selectedMsgText)` then returned undefined // and modals.js threw "Cannot read properties of undefined (reading 'classList')". test('move-to-file works for messages that start with a lowercase letter', async ({page}) => { await page.evaluate(async () => { const root = await navigator.storage.getDirectory(); const fh = await root.getFileHandle('Notes.md', {create: true}); const w = await fh.createWritable(); await w.write('# Notes'); await w.close(); window.getTemporaryStorageDirHandle = async () => navigator.storage.getDirectory(); }); await page.evaluate(() => init(document.getElementById('editor'))); await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); // Lowercase starting letter is the trigger for the original bug. await page.keyboard.type('lowercase start'); await page.waitForTimeout(300); await page.keyboard.press('Enter'); await page.waitForSelector('.message'); const errors = []; page.on('pageerror', err => errors.push(err.message)); await page.hover('.message'); await page.locator('.to-file-btn').first().click({force: true}); await page.waitForSelector('#search', {state: 'visible'}); await page.locator('#search-results li[data-path="/Notes.md"]').click(); await page.waitForSelector('.message', {state: 'detached'}); // No uncaught exceptions should have been raised during the flow. expect(errors).toEqual([]); await page.click(`#tree .tree-item:has-text('Notes')`); await page.waitForTimeout(200); const content = await page.evaluate(() => document.querySelector('.CodeMirror').CodeMirror.getValue()); // The text written to the file is capitalised (ucfirst applied at the // write step, AFTER the DOM lookup, so it doesn't break find()). expect(content).toContain('Lowercase start'); }); // Regression: a chat message containing `"` used to crash to-file because // escapeHtml() left quotes unescaped, the `data-text="..."` attribute closed // early at the first `"`, and the modal's `dataset.text === selectedMsgText` // lookup returned undefined. test('move-to-file works for messages containing double quotes', async ({page}) => { await page.evaluate(async () => { const root = await navigator.storage.getDirectory(); const fh = await root.getFileHandle('Notes.md', {create: true}); const w = await fh.createWritable(); await w.write('# Notes'); await w.close(); window.getTemporaryStorageDirHandle = async () => navigator.storage.getDirectory(); }); await page.evaluate(() => init(document.getElementById('editor'))); await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); const quoted = 'catches "file changed in vim." Without it'; await page.keyboard.type(quoted); await page.waitForTimeout(300); await page.keyboard.press('Enter'); await page.waitForSelector('.message'); const errors = []; page.on('pageerror', err => errors.push(err.message)); await page.hover('.message'); await page.locator('.to-file-btn').first().click({force: true}); await page.waitForSelector('#search', {state: 'visible'}); await page.locator('#search-results li[data-path="/Notes.md"]').click(); await page.waitForSelector('.message', {state: 'detached'}); expect(errors).toEqual([]); await page.click(`#tree .tree-item:has-text('Notes')`); await page.waitForTimeout(200); const content = await page.evaluate(() => document.querySelector('.CodeMirror').CodeMirror.getValue()); // ucfirst capitalises the first letter at the write step (see the // lowercase-letter regression test above). What matters here is that // the embedded quotes round-trip intact. expect(content).toContain('Catches "file changed in vim." Without it'); }); // Regression: clicking the complete checkbox on a message containing `"` used // to be a no-op - escapeHtml() left the quote unescaped in // `data-text="..."`, so el.dataset.text truncated at the first `"`, the // regex in toggleChatMessage didn't match anything, and the on-disk `- [ ]` // stayed `- [ ]`. test('complete-btn toggles a message containing double quotes', async ({page}) => { await page.evaluate(() => { window.getTemporaryStorageDirHandle = async () => navigator.storage.getDirectory(); }); await page.evaluate(() => init(document.getElementById('editor'))); await page.click(`#tree .tree-item:has-text('chat')`); await page.waitForSelector('#chat'); const quoted = 'ask about "uuid": "019e4eea-32b1-7c08-a000-3a1ecd0a6c07"'; await page.keyboard.type(quoted); await page.waitForTimeout(300); await page.keyboard.press('Enter'); await page.waitForSelector('.message'); const errors = []; page.on('pageerror', err => errors.push(err.message)); await page.hover('.message'); await page.locator('.message .complete-btn').first().click({force: true}); await expect(page.locator('.message.completed')).toHaveCount(1); // Reread Chat.md from disk - the toggle should have rewritten the line // from `- [ ]` to `- [x]`. Before the fix, the regex match in // toggleChatMessage failed (dataset.text was truncated at the first `"`) // and the file was left untouched. await expect.poll(async () => { return await page.evaluate(async () => { const root = await navigator.storage.getDirectory(); const fh = await root.getFileHandle('Chat.md'); return (await fh.getFile()).text(); }); }).toMatch(/^- \[x\] `\d{2}:\d{2}` ask about "uuid": "019e4eea-32b1-7c08-a000-3a1ecd0a6c07"\s*$/m); expect(errors).toEqual([]); });