Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

v0.3.1: INDIRECT formula, Playwright tests, xlsx import fixes (#59)

scott 32a6b937 3099f538

+2062 -118
+3
.gitignore
··· 5 5 *.db-shm 6 6 .env 7 7 .claude/worktrees/ 8 + test-results/ 9 + playwright-report/ 10 + blob-report/
-116
PRODUCT.md
··· 894 894 **Week 3 (Formula Power):** #112 (color-coded refs), #91 (array spill) -- transform the formula editing experience and unlock the next wave of functions. 895 895 896 896 **Week 4 (Platform):** #52 (command palette), #21 (toolbar cleanup) -- polish the interaction layer that every user touches. 897 - 898 - --- 899 - 900 - ## 11. Gap Analysis: Daily Driver Readiness 901 - 902 - What would need to be true to use Tools as the ONLY docs+sheets tool, replacing Google Docs and Google Sheets entirely? This section identifies the gaps honestly. 903 - 904 - ### Data Loss Prevention 905 - 906 - **Current state:** Good, with caveats. 907 - 908 - - Snapshots are saved on a 500ms debounce after every edit, plus every 10 seconds via periodic timer, plus on `beforeunload` (tab close) and `visibilitychange` (tab switch). 909 - - The Yjs CRDT document is the source of truth. If the WebSocket disconnects, local edits are preserved in the Yjs Doc in memory. They sync when the connection resumes. 910 - - Version history keeps up to 50 snapshots per document on the server. 911 - 912 - **Gaps:** 913 - - **Browser crash during edit.** If the browser process crashes (not a clean tab close), `beforeunload` does not fire. The last saved snapshot on the server may be up to 10 seconds old. Edits between the last save and the crash are lost. Mitigation: IndexedDB persistence (#84) would save the Yjs state to disk on every change, surviving a crash. 914 - - **localStorage is not durable.** Encryption keys stored in localStorage can be lost if the user clears browser data. There is no key backup mechanism. If the key is lost and the user does not have the URL saved, the document is permanently unrecoverable. 915 - - **No undo across sessions.** The Yjs UndoManager tracks undo history in memory. If the user closes the tab and reopens, undo history is gone. Only version history provides cross-session recovery. 916 - 917 - **Verdict:** Adequate for normal use. Not crash-proof. IndexedDB persistence (#84) is the key improvement. 918 - 919 - ### Reliability 920 - 921 - **Current state:** Reasonable for a self-hosted tool. 922 - 923 - - WebSocket reconnects with 2-4 second randomized backoff on disconnect. 924 - - When reconnecting, the client sends its state vector and the peer (or snapshot) provides missing updates. No data is lost during disconnection. 925 - - Single-client scenario (no peers) works fully offline after initial load. The "synced" flag is set immediately if no peers are detected. 926 - 927 - **Gaps:** 928 - - **Server restart loses WebSocket rooms.** If the server restarts, all WebSocket connections drop. Clients reconnect and re-sync from snapshots, but there is a brief interruption. In-flight encrypted messages that were not snapshot-persisted are lost (up to 500ms of edits). 929 - - **No health monitoring.** The `/health` endpoint exists but there is no alerting, no uptime monitoring, no automatic restart on crash. This is a deployment concern, not an application concern, but it matters for daily-driver reliability. 930 - - **Snapshot conflicts.** If two clients save snapshots simultaneously, the last write wins. Yjs state merges correctly on load, but there is a theoretical window where a client loads a snapshot that is missing another client's most recent changes. Those changes are recovered on the next WebSocket sync, but the user might see a brief "time travel" if they reload during this window. 931 - 932 - **Verdict:** Reliable for 1-3 concurrent users on a stable network. Not yet battle-tested for high-concurrency or unreliable networks. 933 - 934 - ### Performance 935 - 936 - **Current state:** Good for typical use, with known scaling limits. 937 - 938 - - The sheets grid renders all cells on every `renderGrid()` call (100 rows x 26 cols = 2,600 cells). This is fast enough for default grids but would degrade with 1,000+ rows. 939 - - The virtual scrolling module exists but is not wired up (#146). Activating it would allow 10,000+ row sheets. 940 - - Formula evaluation caches results by formula text. Dependent formulas are evaluated recursively. There is no topological sort (though `RecalcEngine` appears to have been added for some formulas), and circular references crash the browser (#145). 941 - - `updateSelectionVisuals()` queries the DOM for each cell in the selection range. A 50-column selection triggers 50+ `querySelector` calls. Noticeable lag at large selections. 942 - - TipTap/ProseMirror handles document editing performance well for documents up to ~100 pages. Very long documents (500+ pages) may become sluggish due to ProseMirror's document traversal. 943 - 944 - **Gaps:** 945 - - **No lazy loading for the landing page.** If a user has 500+ documents, the landing page fetches all of them, decrypts all names, and renders the full list. Pagination or virtual list rendering would help. 946 - - **Chart rendering blocks the main thread.** Chart.js renders synchronously on a canvas. Large datasets in charts can cause a visible frame drop. 947 - - **Full grid re-render on Yjs change.** Any Yjs update (from any peer) triggers `scheduleRenderGrid()` which does a `requestAnimationFrame(renderGrid)`. This rebuilds the entire grid HTML. A targeted cell update (only re-render changed cells) would be more efficient. 948 - 949 - **Verdict:** Fine for sheets up to ~500 rows and docs up to ~50 pages. Wire up virtual scrolling (#146) and fix the circular reference crash (#145) to unlock larger workloads. 950 - 951 - ### Missing Basics (Compared to Google Sheets/Docs) 952 - 953 - These are things a Google Sheets/Docs user would notice within the first 30 minutes: 954 - 955 - | Feature | Google Has It | Tools Status | 956 - |---------|:---:|---| 957 - | Insert row/column in the middle | Yes | No -- can only append (#113) | 958 - | Paste from clipboard | Yes | Broken (#144 -- crashes) | 959 - | Drag-and-drop images | Yes | No -- URL-only (#81) | 960 - | Find and replace in sheets | Yes | No (only in docs) | 961 - | Conditional formatting presets (color scales, data bars) | Yes | No -- rules only (#120) | 962 - | Cell comments/notes with author | Yes | Partial -- notes exist but no author tracking | 963 - | Sparklines in cells | Yes | No (#87) | 964 - | IMPORTRANGE / cross-document data | Yes | No (#72) | 965 - | Print dialog with options | Yes | Docs only -- sheets print is basic (#115) | 966 - | Download as CSV | Yes | Exists (implemented) | 967 - | Download as .xlsx | Yes | No (#109) | 968 - | Revision history diff view | Yes | No -- versions exist but no visual diff (#49) | 969 - | Mobile app / PWA | Yes | No PWA yet (#54, #83, #84) | 970 - | Multiple undo levels with history | Yes | Yjs UndoManager works but no visual undo history | 971 - | Data validation with custom error messages | Yes | Partial -- validation exists, messages are generic | 972 - | Hyperlinks in cells | Yes | No -- cells are plain text | 973 - 974 - ### Trust Assessment 975 - 976 - **Would I trust Tools with tax documents?** 977 - 978 - Yes, with caveats: 979 - - **Encryption is sound.** AES-256-GCM with random IVs via the Web Crypto API is industry-standard. The key-in-fragment approach is clever and verifiable. 980 - - **Server is zero-knowledge.** Auditable by reading server.js (322 lines). The server never calls decrypt, never stores keys, never parses document content. 981 - - **Self-hosted eliminates third-party trust.** Running your own instance means no one else touches your data. 982 - 983 - But: 984 - - **Key management is fragile.** One cleared localStorage away from permanent data loss. No backup mechanism, no recovery phrase, no key escrow. 985 - - **No audit log.** Cannot prove who accessed the document or when. 986 - - **Server-served JavaScript.** If someone compromises the server, they could serve modified JS that exfiltrates keys. Self-hosting mitigates this, but most users will trust the deployed instance. 987 - 988 - **Verdict:** Trustworthy for privacy-sensitive documents. Not yet trustworthy for "only copy of critical financial records" without an external backup strategy. 989 - 990 - ### Summary: What's Needed for Daily Driver 991 - 992 - **Must-fix (blocking daily use):** 993 - 1. Fix paste crash (#144) -- basic operations must not crash 994 - 2. Fix circular reference crash (#145) -- formulas must not freeze the browser 995 - 3. Insert row/column in the middle (#113) -- fundamental spreadsheet operation 996 - 4. Wire up virtual scrolling (#146) -- needed for any real-world sheet 997 - 998 - **High-value improvements:** 999 - 5. IndexedDB persistence (#84) -- crash recovery and true offline 1000 - 6. Command palette (#52) -- fast navigation between documents 1001 - 7. Row/column context menu actions (#149) -- right-click must work 1002 - 8. Image drag-and-drop (#81) -- basic docs expectation 1003 - 9. .xlsx export (#109) -- interoperability with the outside world 1004 - 10. Key backup/export mechanism -- prevent catastrophic key loss 1005 - 1006 - **Nice-to-have for parity:** 1007 - 11. Find and replace in sheets 1008 - 12. Revision history diff view (#49) 1009 - 13. PWA with offline support (#83, #84) 1010 - 14. Hyperlinks in sheet cells 1011 - 1012 - The honest assessment: Tools is approximately **70% of the way to daily-driver status** for a technical user who values privacy. The remaining 30% is dominated by missing basics (#113, #144, #145), data durability (IndexedDB, key backup), and interoperability (.xlsx export, PWA).
+128
e2e/collaboration.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewDoc, createNewSheet, dismissUsernamePrompt } from './helpers'; 3 + 4 + test.describe('Collaboration Indicators', () => { 5 + test('docs: save indicator shows Saved after typing', async ({ page }) => { 6 + await createNewDoc(page); 7 + const editor = page.locator('.tiptap'); 8 + await editor.click(); 9 + await page.keyboard.type('Save test content'); 10 + 11 + // Wait for save indicator to show "Saved" 12 + await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 15000 }); 13 + 14 + // Save dot should have the saved class 15 + await expect(page.locator('.save-dot--saved')).toBeVisible(); 16 + }); 17 + 18 + test('docs: E2EE indicator is visible', async ({ page }) => { 19 + await createNewDoc(page); 20 + 21 + // E2EE indicator should be visible with the encrypted dot 22 + await expect(page.locator('.status-dot.encrypted')).toBeVisible(); 23 + await expect(page.locator('.status-indicator')).toContainText('E2EE'); 24 + }); 25 + 26 + test('docs: status indicator shows connection state', async ({ page }) => { 27 + await createNewDoc(page); 28 + 29 + // Status text should eventually show "Connected" or similar 30 + const statusText = page.locator('#status-text'); 31 + await expect(statusText).not.toHaveText('', { timeout: 10000 }); 32 + }); 33 + 34 + test('docs: version history panel opens via button', async ({ page }) => { 35 + await createNewDoc(page); 36 + 37 + // Type content and wait for save 38 + const editor = page.locator('.tiptap'); 39 + await editor.click(); 40 + await page.keyboard.type('Version history content'); 41 + await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 15000 }); 42 + 43 + // Open version history 44 + await page.click('#btn-history'); 45 + await expect(page.locator('#version-sidebar')).toBeVisible({ timeout: 5000 }); 46 + 47 + // Should have version list or empty state 48 + const versionContent = page.locator('#version-list'); 49 + await expect(versionContent).toBeVisible(); 50 + 51 + // Close 52 + await page.click('#version-sidebar-close'); 53 + await expect(page.locator('#version-sidebar')).not.toBeVisible(); 54 + }); 55 + 56 + test('sheets: save indicator shows Saved after editing cell', async ({ page }) => { 57 + await createNewSheet(page); 58 + 59 + await page.locator('td[data-id="A1"]').click(); 60 + await page.keyboard.type('Sheet save test'); 61 + await page.keyboard.press('Enter'); 62 + 63 + await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 15000 }); 64 + }); 65 + 66 + test('sheets: E2EE indicator is visible', async ({ page }) => { 67 + await createNewSheet(page); 68 + 69 + await expect(page.locator('.status-dot.encrypted')).toBeVisible(); 70 + await expect(page.locator('.status-indicator')).toContainText('E2EE'); 71 + }); 72 + 73 + test('sheets: version history opens via button', async ({ page }) => { 74 + await createNewSheet(page); 75 + 76 + // Add some data 77 + await page.locator('td[data-id="A1"]').click(); 78 + await page.keyboard.type('Data'); 79 + await page.keyboard.press('Enter'); 80 + await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 15000 }); 81 + 82 + // Open version history 83 + await page.click('#btn-history'); 84 + 85 + // Version panel should appear 86 + const versionPanel = page.locator('.version-sidebar, .version-panel, #version-sidebar, [class*="version"]'); 87 + await expect(versionPanel.first()).toBeVisible({ timeout: 5000 }); 88 + }); 89 + 90 + test('docs: share dialog shows link and copy button', async ({ page }) => { 91 + await createNewDoc(page); 92 + 93 + await page.click('#btn-share'); 94 + await expect(page.locator('#share-dialog')).toBeVisible(); 95 + 96 + // Link input should contain the document URL with key 97 + const linkValue = await page.locator('#share-link-input').inputValue(); 98 + expect(linkValue).toContain('/docs/'); 99 + expect(linkValue).toContain('#'); 100 + 101 + // Copy button should be visible 102 + await expect(page.locator('#share-copy-link')).toBeVisible(); 103 + 104 + // Share mode select should be available 105 + await expect(page.locator('#share-mode-select')).toBeVisible(); 106 + }); 107 + 108 + test('sheets: share dialog shows link', async ({ page }) => { 109 + await createNewSheet(page); 110 + 111 + await page.click('#btn-share'); 112 + await expect(page.locator('#share-dialog')).toBeVisible(); 113 + 114 + const linkValue = await page.locator('#share-link-input').inputValue(); 115 + expect(linkValue).toContain('/sheets/'); 116 + expect(linkValue).toContain('#'); 117 + }); 118 + 119 + test('docs: keyboard shortcuts dialog opens', async ({ page }) => { 120 + await createNewDoc(page); 121 + 122 + await page.click('#btn-shortcuts'); 123 + 124 + // Some kind of shortcuts overlay/modal should appear 125 + const shortcutsEl = page.locator('.shortcuts-modal, .modal, [class*="shortcut"]'); 126 + await expect(shortcutsEl.first()).toBeVisible({ timeout: 5000 }); 127 + }); 128 + });
+167
e2e/docs-editing.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewDoc } from './helpers'; 3 + 4 + test.describe('Docs - Editing', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewDoc(page); 7 + }); 8 + 9 + test('editor loads and is editable', async ({ page }) => { 10 + const editor = page.locator('.tiptap'); 11 + await expect(editor).toBeVisible(); 12 + await expect(editor).toHaveAttribute('contenteditable', 'true'); 13 + }); 14 + 15 + test('type text and verify it appears', async ({ page }) => { 16 + const editor = page.locator('.tiptap'); 17 + await editor.click(); 18 + await page.keyboard.type('Hello, World!'); 19 + await expect(editor).toContainText('Hello, World!'); 20 + }); 21 + 22 + test('bold text with Cmd+B', async ({ page }) => { 23 + const editor = page.locator('.tiptap'); 24 + await editor.click(); 25 + await page.keyboard.type('normal '); 26 + await page.keyboard.press('Meta+b'); 27 + await page.keyboard.type('bold text'); 28 + await page.keyboard.press('Meta+b'); 29 + 30 + // Verify bold element exists 31 + await expect(editor.locator('strong')).toContainText('bold text'); 32 + }); 33 + 34 + test('italic text with Cmd+I', async ({ page }) => { 35 + const editor = page.locator('.tiptap'); 36 + await editor.click(); 37 + await page.keyboard.press('Meta+i'); 38 + await page.keyboard.type('italic text'); 39 + await page.keyboard.press('Meta+i'); 40 + 41 + await expect(editor.locator('em')).toContainText('italic text'); 42 + }); 43 + 44 + test('underline text with Cmd+U', async ({ page }) => { 45 + const editor = page.locator('.tiptap'); 46 + await editor.click(); 47 + await page.keyboard.press('Meta+u'); 48 + await page.keyboard.type('underlined'); 49 + await page.keyboard.press('Meta+u'); 50 + 51 + await expect(editor.locator('u')).toContainText('underlined'); 52 + }); 53 + 54 + test('create heading with # + space', async ({ page }) => { 55 + const editor = page.locator('.tiptap'); 56 + await editor.click(); 57 + await page.keyboard.type('# Heading One'); 58 + 59 + // TipTap autoformat converts "# " to h1 60 + await expect(editor.locator('h1')).toContainText('Heading One'); 61 + }); 62 + 63 + test('create h2 heading with ## + space', async ({ page }) => { 64 + const editor = page.locator('.tiptap'); 65 + await editor.click(); 66 + await page.keyboard.type('## Heading Two'); 67 + 68 + await expect(editor.locator('h2')).toContainText('Heading Two'); 69 + }); 70 + 71 + test('create bullet list with - + space', async ({ page }) => { 72 + const editor = page.locator('.tiptap'); 73 + await editor.click(); 74 + await page.keyboard.type('- First item'); 75 + await page.keyboard.press('Enter'); 76 + await page.keyboard.type('Second item'); 77 + 78 + // Verify list was created 79 + const listItems = editor.locator('ul li'); 80 + await expect(listItems).toHaveCount(2); 81 + await expect(listItems.first()).toContainText('First item'); 82 + await expect(listItems.last()).toContainText('Second item'); 83 + }); 84 + 85 + test('create numbered list with 1. + space', async ({ page }) => { 86 + const editor = page.locator('.tiptap'); 87 + await editor.click(); 88 + await page.keyboard.type('1. First item'); 89 + await page.keyboard.press('Enter'); 90 + await page.keyboard.type('Second item'); 91 + 92 + const listItems = editor.locator('ol li'); 93 + await expect(listItems).toHaveCount(2); 94 + await expect(listItems.first()).toContainText('First item'); 95 + }); 96 + 97 + test('insert horizontal rule with ---', async ({ page }) => { 98 + const editor = page.locator('.tiptap'); 99 + await editor.click(); 100 + await page.keyboard.type('Above the line'); 101 + await page.keyboard.press('Enter'); 102 + await page.keyboard.type('---'); 103 + 104 + // TipTap should render an hr element 105 + await expect(editor.locator('hr')).toBeVisible(); 106 + }); 107 + 108 + test('undo with Cmd+Z', async ({ page }) => { 109 + const editor = page.locator('.tiptap'); 110 + await editor.click(); 111 + await page.keyboard.type('Hello'); 112 + await expect(editor).toContainText('Hello'); 113 + 114 + // Undo the typing 115 + await page.keyboard.press('Meta+z'); 116 + await page.keyboard.press('Meta+z'); 117 + await page.keyboard.press('Meta+z'); 118 + await page.keyboard.press('Meta+z'); 119 + await page.keyboard.press('Meta+z'); 120 + 121 + // Content should be empty or at least not contain the full text 122 + const text = await editor.textContent(); 123 + expect(text?.trim()).not.toBe('Hello'); 124 + }); 125 + 126 + test('redo with Cmd+Shift+Z', async ({ page }) => { 127 + const editor = page.locator('.tiptap'); 128 + await editor.click(); 129 + await page.keyboard.type('Hello'); 130 + 131 + // Undo 132 + for (let i = 0; i < 5; i++) { 133 + await page.keyboard.press('Meta+z'); 134 + } 135 + 136 + // Redo 137 + for (let i = 0; i < 5; i++) { 138 + await page.keyboard.press('Meta+Shift+z'); 139 + } 140 + 141 + await expect(editor).toContainText('Hello'); 142 + }); 143 + 144 + test('bold toolbar button works', async ({ page }) => { 145 + const editor = page.locator('.tiptap'); 146 + await editor.click(); 147 + await page.keyboard.type('select me'); 148 + 149 + // Select all text 150 + await page.keyboard.press('Meta+a'); 151 + 152 + // Click bold button in toolbar 153 + await page.click('#tb-bold'); 154 + 155 + await expect(editor.locator('strong')).toContainText('select me'); 156 + }); 157 + 158 + test('italic toolbar button works', async ({ page }) => { 159 + const editor = page.locator('.tiptap'); 160 + await editor.click(); 161 + await page.keyboard.type('italicize me'); 162 + await page.keyboard.press('Meta+a'); 163 + await page.click('#tb-italic'); 164 + 165 + await expect(editor.locator('em')).toContainText('italicize me'); 166 + }); 167 + });
+180
e2e/docs-features.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewDoc } from './helpers'; 3 + 4 + test.describe('Docs - Advanced Features', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewDoc(page); 7 + }); 8 + 9 + test('slash command menu appears when typing /', async ({ page }) => { 10 + const editor = page.locator('.tiptap'); 11 + await editor.click(); 12 + await page.keyboard.type('/'); 13 + 14 + // Slash command menu should appear 15 + await expect(page.locator('.slash-menu')).toBeVisible({ timeout: 5000 }); 16 + 17 + // Should have menu items 18 + await expect(page.locator('.slash-menu-item').first()).toBeVisible(); 19 + 20 + // Dismiss with Escape 21 + await page.keyboard.press('Escape'); 22 + await expect(page.locator('.slash-menu')).not.toBeVisible(); 23 + }); 24 + 25 + test('slash command inserts heading', async ({ page }) => { 26 + const editor = page.locator('.tiptap'); 27 + await editor.click(); 28 + await page.keyboard.type('/'); 29 + await expect(page.locator('.slash-menu')).toBeVisible({ timeout: 5000 }); 30 + 31 + // Type to filter for heading 32 + await page.keyboard.type('heading'); 33 + // Click the first heading option 34 + await page.locator('.slash-menu-item').first().click(); 35 + 36 + // Should have created a heading element 37 + const headings = editor.locator('h1, h2, h3'); 38 + await expect(headings.first()).toBeVisible(); 39 + }); 40 + 41 + test('find and replace opens with Cmd+F', async ({ page }) => { 42 + const editor = page.locator('.tiptap'); 43 + await editor.click(); 44 + await page.keyboard.type('Find this text in the document'); 45 + 46 + // Open find 47 + await page.keyboard.press('Meta+f'); 48 + const searchBar = page.locator('.search-bar, .find-replace-bar, [class*="search"]').first(); 49 + await expect(searchBar).toBeVisible({ timeout: 5000 }); 50 + }); 51 + 52 + test('zen mode toggles with Cmd+Shift+F', async ({ page }) => { 53 + // Type some content first 54 + const editor = page.locator('.tiptap'); 55 + await editor.click(); 56 + await page.keyboard.type('Zen mode content'); 57 + 58 + // Toggle zen mode 59 + await page.keyboard.press('Meta+Shift+f'); 60 + 61 + // Body or app should have zen class 62 + await expect(page.locator('.zen-mode, body.zen-mode, .app-shell.zen-mode')).toBeVisible({ timeout: 5000 }); 63 + 64 + // Exit button should be visible 65 + await expect(page.locator('#zen-exit')).toBeVisible(); 66 + 67 + // Toggle back 68 + await page.keyboard.press('Meta+Shift+f'); 69 + await expect(page.locator('#zen-exit')).not.toBeVisible(); 70 + }); 71 + 72 + test('markdown toggle with Cmd+Shift+M', async ({ page }) => { 73 + const editor = page.locator('.tiptap'); 74 + await editor.click(); 75 + await page.keyboard.type('# Hello Markdown'); 76 + 77 + // Toggle to markdown view 78 + await page.keyboard.press('Meta+Shift+m'); 79 + 80 + // Markdown source textarea should be visible 81 + const mdSource = page.locator('#markdown-source'); 82 + await expect(mdSource).toBeVisible({ timeout: 5000 }); 83 + 84 + // It should contain markdown text 85 + const mdValue = await mdSource.inputValue(); 86 + expect(mdValue).toContain('Hello Markdown'); 87 + 88 + // Toggle back 89 + await page.keyboard.press('Meta+Shift+m'); 90 + await expect(mdSource).not.toBeVisible(); 91 + }); 92 + 93 + test('word count updates in footer', async ({ page }) => { 94 + const editor = page.locator('.tiptap'); 95 + await editor.click(); 96 + 97 + // Initially should show 0 words 98 + await expect(page.locator('#word-count')).toContainText('0 words'); 99 + 100 + // Type some text 101 + await page.keyboard.type('one two three four five'); 102 + 103 + // Word count should update 104 + await expect(page.locator('#word-count')).toContainText('5 words', { timeout: 5000 }); 105 + }); 106 + 107 + test('character count updates in footer', async ({ page }) => { 108 + const editor = page.locator('.tiptap'); 109 + await editor.click(); 110 + await page.keyboard.type('Hello'); 111 + 112 + await expect(page.locator('#char-count')).toContainText('5 characters', { timeout: 5000 }); 113 + }); 114 + 115 + test('outline sidebar shows headings', async ({ page }) => { 116 + const editor = page.locator('.tiptap'); 117 + await editor.click(); 118 + 119 + // Create some headings 120 + await page.keyboard.type('# First Heading'); 121 + await page.keyboard.press('Enter'); 122 + await page.keyboard.type('Some paragraph text'); 123 + await page.keyboard.press('Enter'); 124 + await page.keyboard.type('## Second Heading'); 125 + 126 + // Open outline sidebar 127 + await page.click('#btn-outline'); 128 + 129 + const sidebar = page.locator('#outline-sidebar'); 130 + await expect(sidebar).toBeVisible({ timeout: 5000 }); 131 + 132 + // Should list the headings 133 + const items = sidebar.locator('.outline-item, .outline-link, [class*="outline"]'); 134 + // At least the headings should appear (wait for rendering) 135 + await expect(items.first()).toBeVisible({ timeout: 5000 }); 136 + }); 137 + 138 + test('heading select in toolbar changes paragraph to heading', async ({ page }) => { 139 + const editor = page.locator('.tiptap'); 140 + await editor.click(); 141 + await page.keyboard.type('Make me a heading'); 142 + await page.keyboard.press('Meta+a'); 143 + 144 + // Change heading level via toolbar select 145 + await page.selectOption('#tb-heading', '1'); 146 + await expect(editor.locator('h1')).toContainText('Make me a heading'); 147 + }); 148 + 149 + test('version history panel opens with Cmd+Shift+H', async ({ page }) => { 150 + // Type something to create content 151 + const editor = page.locator('.tiptap'); 152 + await editor.click(); 153 + await page.keyboard.type('Version test content'); 154 + 155 + // Wait for save 156 + await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 10000 }); 157 + 158 + // Open version history via button (Cmd+Shift+H might not be bound, use button) 159 + await page.click('#btn-history'); 160 + const versionSidebar = page.locator('#version-sidebar'); 161 + await expect(versionSidebar).toBeVisible({ timeout: 5000 }); 162 + 163 + // Close it 164 + await page.click('#version-sidebar-close'); 165 + await expect(versionSidebar).not.toBeVisible(); 166 + }); 167 + 168 + test('share dialog opens', async ({ page }) => { 169 + await page.click('#btn-share'); 170 + const shareDialog = page.locator('#share-dialog'); 171 + await expect(shareDialog).toBeVisible({ timeout: 5000 }); 172 + 173 + // Should have a sharing link input 174 + await expect(page.locator('#share-link-input')).toBeVisible(); 175 + 176 + // Close 177 + await page.click('#share-dialog-close'); 178 + await expect(shareDialog).not.toBeVisible(); 179 + }); 180 + });
+110
e2e/helpers.ts
··· 1 + /** 2 + * Shared helpers for e2e tests. 3 + * 4 + * The app uses E2EE with keys derived from the URL fragment. 5 + * For testing we create documents via the landing page flow 6 + * so keys are generated and stored in localStorage automatically. 7 + */ 8 + import { type Page, expect } from '@playwright/test'; 9 + 10 + /** 11 + * Dismiss the username prompt if it appears. 12 + * Sets a username in localStorage so subsequent navigations skip it. 13 + */ 14 + export async function dismissUsernamePrompt(page: Page): Promise<void> { 15 + await page.evaluate(() => { 16 + if (!localStorage.getItem('tools-username')) { 17 + localStorage.setItem('tools-username', 'TestUser'); 18 + } 19 + }); 20 + } 21 + 22 + /** 23 + * Navigate to landing page and ensure it is ready. 24 + */ 25 + export async function goToLanding(page: Page): Promise<void> { 26 + await page.goto('/'); 27 + await dismissUsernamePrompt(page); 28 + // Reload after setting username to avoid the modal 29 + await page.reload(); 30 + await page.waitForSelector('.landing-header'); 31 + } 32 + 33 + /** 34 + * Create a new document via the landing page and wait for the editor to load. 35 + * Returns the full URL of the new document. 36 + */ 37 + export async function createNewDoc(page: Page): Promise<string> { 38 + await goToLanding(page); 39 + await page.click('#new-doc'); 40 + // Wait for TipTap editor to mount 41 + await page.waitForSelector('.tiptap', { timeout: 15000 }); 42 + return page.url(); 43 + } 44 + 45 + /** 46 + * Create a new spreadsheet via the landing page and wait for the grid to load. 47 + * Returns the full URL of the new spreadsheet. 48 + */ 49 + export async function createNewSheet(page: Page): Promise<string> { 50 + await goToLanding(page); 51 + await page.click('#new-sheet'); 52 + // Wait for the sheet grid to render rows 53 + await page.waitForSelector('#sheet-grid tbody tr td[data-id]', { timeout: 15000 }); 54 + return page.url(); 55 + } 56 + 57 + /** 58 + * Click a cell in the spreadsheet by its cell ID (e.g. "A1", "B3"). 59 + */ 60 + export async function clickCell(page: Page, cellId: string): Promise<void> { 61 + await page.click(`td[data-id="${cellId}"]`); 62 + } 63 + 64 + /** 65 + * Double-click a cell to start editing. 66 + */ 67 + export async function dblClickCell(page: Page, cellId: string): Promise<void> { 68 + await page.dblclick(`td[data-id="${cellId}"]`); 69 + } 70 + 71 + /** 72 + * Type a value into a cell. Assumes the cell is already selected (single-click). 73 + * Typing a printable character auto-starts editing in sheets. 74 + */ 75 + export async function typeInCell(page: Page, cellId: string, value: string): Promise<void> { 76 + await clickCell(page, cellId); 77 + // Typing a character starts editing; we type the full value then press Enter 78 + await page.keyboard.type(value); 79 + await page.keyboard.press('Enter'); 80 + } 81 + 82 + /** 83 + * Get the displayed text of a cell. 84 + */ 85 + export async function getCellText(page: Page, cellId: string): Promise<string> { 86 + const cell = page.locator(`td[data-id="${cellId}"] .cell-display`); 87 + return (await cell.textContent()) ?? ''; 88 + } 89 + 90 + /** 91 + * Get the value shown in the formula bar for the currently selected cell. 92 + */ 93 + export async function getFormulaBarValue(page: Page): Promise<string> { 94 + return await page.inputValue('#formula-input'); 95 + } 96 + 97 + /** 98 + * Use the modifier key appropriate for the current platform (Meta on Mac, Control elsewhere). 99 + */ 100 + export function mod(page: Page): string { 101 + // Playwright Desktop Chrome on macOS uses Meta 102 + return 'Meta'; 103 + } 104 + 105 + /** 106 + * Wait for the save indicator to show "Saved" (green dot). 107 + */ 108 + export async function waitForSaved(page: Page): Promise<void> { 109 + await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 10000 }); 110 + }
+141
e2e/landing.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { goToLanding, dismissUsernamePrompt } from './helpers'; 3 + 4 + test.describe('Landing Page', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await goToLanding(page); 7 + }); 8 + 9 + test('shows app name and E2EE badge', async ({ page }) => { 10 + await expect(page.locator('.brand-name')).toHaveText('Tools'); 11 + await expect(page.locator('.brand-badge')).toHaveText('E2EE'); 12 + }); 13 + 14 + test('shows tagline', async ({ page }) => { 15 + await expect(page.locator('.brand-tagline')).toContainText('end-to-end encrypted', { ignoreCase: true }); 16 + }); 17 + 18 + test('create new document navigates to docs editor', async ({ page }) => { 19 + await page.click('#new-doc'); 20 + await page.waitForURL(/\/docs\/[^/]+#/); 21 + // Editor should mount 22 + await expect(page.locator('.tiptap')).toBeVisible({ timeout: 15000 }); 23 + }); 24 + 25 + test('create new spreadsheet navigates to sheets editor', async ({ page }) => { 26 + await page.click('#new-sheet'); 27 + await page.waitForURL(/\/sheets\/[^/]+#/); 28 + // Grid should render 29 + await expect(page.locator('#sheet-grid')).toBeVisible({ timeout: 15000 }); 30 + }); 31 + 32 + test('new document appears in document list after returning to landing', async ({ page }) => { 33 + // Create a doc 34 + await page.click('#new-doc'); 35 + await page.waitForURL(/\/docs\//); 36 + await page.waitForSelector('.tiptap', { timeout: 15000 }); 37 + 38 + // Go back to landing 39 + await page.goto('/'); 40 + await page.waitForSelector('.doc-section'); 41 + 42 + // Document list should have at least one item 43 + await expect(page.locator('.doc-item').first()).toBeVisible({ timeout: 10000 }); 44 + await expect(page.locator('.doc-item-name').first()).toContainText('Untitled Document'); 45 + }); 46 + 47 + test('rename document via title input in editor', async ({ page }) => { 48 + // Create a doc and rename it 49 + await page.click('#new-doc'); 50 + await page.waitForSelector('.tiptap', { timeout: 15000 }); 51 + const titleInput = page.locator('#doc-title'); 52 + await titleInput.fill('My Test Document'); 53 + await titleInput.press('Enter'); 54 + 55 + // Wait for save 56 + await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 10000 }); 57 + 58 + // Go back to landing and verify the name appears 59 + await page.goto('/'); 60 + await page.waitForSelector('.doc-section'); 61 + await expect(page.locator('.doc-item-name').first()).toContainText('My Test Document', { timeout: 10000 }); 62 + }); 63 + 64 + test('trash and restore document', async ({ page }) => { 65 + // Create a doc first 66 + await page.click('#new-doc'); 67 + await page.waitForURL(/\/docs\//); 68 + await page.waitForSelector('.tiptap', { timeout: 15000 }); 69 + 70 + // Go back to landing 71 + await page.goto('/'); 72 + await page.waitForSelector('.doc-section'); 73 + 74 + // Wait for at least one doc to show 75 + const deleteBtn = page.locator('.doc-item-delete').first(); 76 + await expect(deleteBtn).toBeVisible({ timeout: 10000 }); 77 + 78 + // Trash it 79 + await deleteBtn.click(); 80 + 81 + // Trash section should appear 82 + await expect(page.locator('#trash-section')).toBeVisible(); 83 + 84 + // Expand trash 85 + await page.click('#trash-toggle'); 86 + 87 + // Restore the document 88 + const restoreBtn = page.locator('.trash-restore').first(); 89 + await expect(restoreBtn).toBeVisible({ timeout: 5000 }); 90 + await restoreBtn.click(); 91 + 92 + // Document should reappear in the main list 93 + await expect(page.locator('.doc-item').first()).toBeVisible({ timeout: 10000 }); 94 + }); 95 + 96 + test('command palette opens with Cmd+K', async ({ page }) => { 97 + await page.keyboard.press('Meta+k'); 98 + await expect(page.locator('.command-palette')).toBeVisible({ timeout: 5000 }); 99 + 100 + // Should show actions 101 + await expect(page.locator('.command-palette-item').first()).toBeVisible(); 102 + 103 + // Close with Escape 104 + await page.keyboard.press('Escape'); 105 + await expect(page.locator('.command-palette')).not.toBeVisible(); 106 + }); 107 + 108 + test('search filters documents', async ({ page }) => { 109 + // Create a doc with a known name 110 + await page.click('#new-doc'); 111 + await page.waitForSelector('.tiptap', { timeout: 15000 }); 112 + await page.locator('#doc-title').fill('SearchableDoc'); 113 + await page.locator('#doc-title').press('Enter'); 114 + await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 10000 }); 115 + 116 + // Go back 117 + await page.goto('/'); 118 + await page.waitForSelector('.doc-section'); 119 + await expect(page.locator('.doc-item').first()).toBeVisible({ timeout: 10000 }); 120 + 121 + // Search for the doc 122 + await page.fill('#search-input', 'SearchableDoc'); 123 + await expect(page.locator('.doc-item-name')).toContainText(['SearchableDoc']); 124 + 125 + // Search for something that doesn't exist 126 + await page.fill('#search-input', 'zzznonexistent'); 127 + await expect(page.locator('#no-results')).toBeVisible(); 128 + }); 129 + 130 + test('theme toggle switches between light and dark', async ({ page }) => { 131 + // Click theme toggle 132 + await page.click('#theme-toggle'); 133 + const theme1 = await page.locator('html').getAttribute('data-theme'); 134 + expect(theme1).toBeTruthy(); 135 + 136 + // Click again to toggle back 137 + await page.click('#theme-toggle'); 138 + const theme2 = await page.locator('html').getAttribute('data-theme'); 139 + expect(theme2).not.toEqual(theme1); 140 + }); 141 + });
+122
e2e/sheets-basic.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewSheet, clickCell, typeInCell, getCellText, getFormulaBarValue } from './helpers'; 3 + 4 + test.describe('Sheets - Basic', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewSheet(page); 7 + }); 8 + 9 + test('grid renders with row and column headers', async ({ page }) => { 10 + // Column headers (A, B, C...) 11 + await expect(page.locator('thead th[data-col="1"]')).toContainText('A'); 12 + await expect(page.locator('thead th[data-col="2"]')).toContainText('B'); 13 + await expect(page.locator('thead th[data-col="3"]')).toContainText('C'); 14 + 15 + // Row headers (1, 2, 3...) 16 + await expect(page.locator('th.row-header[data-row="1"]')).toContainText('1'); 17 + await expect(page.locator('th.row-header[data-row="2"]')).toContainText('2'); 18 + await expect(page.locator('th.row-header[data-row="3"]')).toContainText('3'); 19 + }); 20 + 21 + test('click cell to select it', async ({ page }) => { 22 + await clickCell(page, 'B2'); 23 + 24 + // Cell address input should show B2 25 + await expect(page.locator('#cell-address')).toHaveValue('B2'); 26 + 27 + // Cell should have selected class 28 + await expect(page.locator('td[data-id="B2"]')).toHaveClass(/selected/); 29 + }); 30 + 31 + test('type value and press Enter', async ({ page }) => { 32 + await typeInCell(page, 'A1', 'Hello'); 33 + 34 + // Cell should display the value 35 + const text = await getCellText(page, 'A1'); 36 + expect(text).toBe('Hello'); 37 + }); 38 + 39 + test('formula bar shows cell content when selected', async ({ page }) => { 40 + await typeInCell(page, 'A1', 'Test Value'); 41 + 42 + // Click the cell to select it 43 + await clickCell(page, 'A1'); 44 + 45 + // Formula bar should show the value 46 + const barValue = await getFormulaBarValue(page); 47 + expect(barValue).toBe('Test Value'); 48 + }); 49 + 50 + test('navigate with arrow keys', async ({ page }) => { 51 + // Start at A1 52 + await clickCell(page, 'A1'); 53 + await expect(page.locator('#cell-address')).toHaveValue('A1'); 54 + 55 + // Right arrow -> B1 56 + await page.keyboard.press('ArrowRight'); 57 + await expect(page.locator('#cell-address')).toHaveValue('B1'); 58 + 59 + // Down arrow -> B2 60 + await page.keyboard.press('ArrowDown'); 61 + await expect(page.locator('#cell-address')).toHaveValue('B2'); 62 + 63 + // Left arrow -> A2 64 + await page.keyboard.press('ArrowLeft'); 65 + await expect(page.locator('#cell-address')).toHaveValue('A2'); 66 + 67 + // Up arrow -> A1 68 + await page.keyboard.press('ArrowUp'); 69 + await expect(page.locator('#cell-address')).toHaveValue('A1'); 70 + }); 71 + 72 + test('Tab moves right, Enter moves down', async ({ page }) => { 73 + await clickCell(page, 'A1'); 74 + await page.keyboard.type('One'); 75 + await page.keyboard.press('Tab'); 76 + 77 + // Should be at B1 now 78 + await expect(page.locator('#cell-address')).toHaveValue('B1'); 79 + 80 + await page.keyboard.type('Two'); 81 + await page.keyboard.press('Enter'); 82 + 83 + // Enter moves down from the last Tab column -> B2 84 + await expect(page.locator('#cell-address')).toHaveValue('B2'); 85 + }); 86 + 87 + test('double-click cell starts editing', async ({ page }) => { 88 + await typeInCell(page, 'A1', 'Existing'); 89 + 90 + // Double-click to edit 91 + await page.dblclick('td[data-id="A1"]'); 92 + 93 + // An input editor should appear inside the cell 94 + const cellEditor = page.locator('td[data-id="A1"] .cell-editor'); 95 + await expect(cellEditor).toBeVisible({ timeout: 5000 }); 96 + expect(await cellEditor.inputValue()).toBe('Existing'); 97 + }); 98 + 99 + test('cell address input shows correct address', async ({ page }) => { 100 + await clickCell(page, 'C5'); 101 + await expect(page.locator('#cell-address')).toHaveValue('C5'); 102 + 103 + await clickCell(page, 'Z1'); 104 + await expect(page.locator('#cell-address')).toHaveValue('Z1'); 105 + }); 106 + 107 + test('type numeric value', async ({ page }) => { 108 + await typeInCell(page, 'A1', '42'); 109 + const text = await getCellText(page, 'A1'); 110 + expect(text).toBe('42'); 111 + }); 112 + 113 + test('multiple cells can hold different values', async ({ page }) => { 114 + await typeInCell(page, 'A1', 'First'); 115 + await typeInCell(page, 'B1', 'Second'); 116 + await typeInCell(page, 'A2', 'Third'); 117 + 118 + expect(await getCellText(page, 'A1')).toBe('First'); 119 + expect(await getCellText(page, 'B1')).toBe('Second'); 120 + expect(await getCellText(page, 'A2')).toBe('Third'); 121 + }); 122 + });
+116
e2e/sheets-clipboard.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewSheet, clickCell, typeInCell, getCellText } from './helpers'; 3 + 4 + test.describe('Sheets - Clipboard', () => { 5 + test.beforeEach(async ({ page, context }) => { 6 + // Grant clipboard permissions 7 + await context.grantPermissions(['clipboard-read', 'clipboard-write']); 8 + await createNewSheet(page); 9 + }); 10 + 11 + test('copy cell and paste to another cell', async ({ page }) => { 12 + await typeInCell(page, 'A1', 'CopyMe'); 13 + 14 + // Select A1 and copy 15 + await clickCell(page, 'A1'); 16 + await page.keyboard.press('Meta+c'); 17 + 18 + // Select B1 and paste 19 + await clickCell(page, 'B1'); 20 + await page.keyboard.press('Meta+v'); 21 + 22 + // B1 should now have the same value 23 + await expect(page.locator('td[data-id="B1"] .cell-display')).toContainText('CopyMe', { timeout: 5000 }); 24 + 25 + // A1 should still have its value (copy, not cut) 26 + expect(await getCellText(page, 'A1')).toBe('CopyMe'); 27 + }); 28 + 29 + test('cut cell clears original', async ({ page }) => { 30 + await typeInCell(page, 'A1', 'CutMe'); 31 + 32 + // Select A1 and cut 33 + await clickCell(page, 'A1'); 34 + await page.keyboard.press('Meta+x'); 35 + 36 + // Select B1 and paste 37 + await clickCell(page, 'B1'); 38 + await page.keyboard.press('Meta+v'); 39 + 40 + // B1 should have the value 41 + await expect(page.locator('td[data-id="B1"] .cell-display')).toContainText('CutMe', { timeout: 5000 }); 42 + 43 + // A1 should be empty (it was cut) 44 + const a1Text = await getCellText(page, 'A1'); 45 + expect(a1Text).toBe(''); 46 + }); 47 + 48 + test('copy multiple cells and paste', async ({ page }) => { 49 + await typeInCell(page, 'A1', 'One'); 50 + await typeInCell(page, 'A2', 'Two'); 51 + await typeInCell(page, 'A3', 'Three'); 52 + 53 + // Select range A1:A3 54 + await clickCell(page, 'A1'); 55 + await page.keyboard.down('Shift'); 56 + await clickCell(page, 'A3'); 57 + await page.keyboard.up('Shift'); 58 + 59 + // Copy 60 + await page.keyboard.press('Meta+c'); 61 + 62 + // Paste to B1 63 + await clickCell(page, 'B1'); 64 + await page.keyboard.press('Meta+v'); 65 + 66 + // Verify all values pasted 67 + await expect(page.locator('td[data-id="B1"] .cell-display')).toContainText('One', { timeout: 5000 }); 68 + expect(await getCellText(page, 'B2')).toBe('Two'); 69 + expect(await getCellText(page, 'B3')).toBe('Three'); 70 + }); 71 + 72 + test('copy cell with formula pastes computed value or adjusted formula', async ({ page }) => { 73 + await typeInCell(page, 'A1', '10'); 74 + await typeInCell(page, 'A2', '=A1*2'); 75 + 76 + // A2 should show 20 77 + expect(await getCellText(page, 'A2')).toBe('20'); 78 + 79 + // Copy A2 80 + await clickCell(page, 'A2'); 81 + await page.keyboard.press('Meta+c'); 82 + 83 + // Paste to B2 84 + await clickCell(page, 'B2'); 85 + await page.keyboard.press('Meta+v'); 86 + 87 + // B2 should have a value (either the formula adjusted or the value pasted) 88 + const b2Text = await getCellText(page, 'B2'); 89 + expect(b2Text).toBeTruthy(); 90 + }); 91 + 92 + test('delete key clears selected cell', async ({ page }) => { 93 + await typeInCell(page, 'A1', 'DeleteMe'); 94 + expect(await getCellText(page, 'A1')).toBe('DeleteMe'); 95 + 96 + await clickCell(page, 'A1'); 97 + await page.keyboard.press('Delete'); 98 + 99 + // Cell should be empty 100 + const text = await getCellText(page, 'A1'); 101 + expect(text).toBe(''); 102 + }); 103 + 104 + test('backspace clears selected cell', async ({ page }) => { 105 + await typeInCell(page, 'A1', 'ClearMe'); 106 + await clickCell(page, 'A1'); 107 + await page.keyboard.press('Backspace'); 108 + 109 + // Should start editing with empty content (Backspace enters edit mode) 110 + // Press Escape or Enter to confirm 111 + await page.keyboard.press('Escape'); 112 + 113 + // Cell may still have content if Backspace enters edit mode 114 + // Just verify the action doesn't crash 115 + }); 116 + });
+236
e2e/sheets-features.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewSheet, clickCell, typeInCell, getCellText } from './helpers'; 3 + 4 + test.describe('Sheets - Advanced Features', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewSheet(page); 7 + }); 8 + 9 + test('multi-sheet tabs: create new sheet and switch between tabs', async ({ page }) => { 10 + // Should start with "Sheet 1" tab 11 + await expect(page.locator('.sheet-tab').first()).toContainText('Sheet 1'); 12 + 13 + // Add a new sheet 14 + await page.click('#add-sheet'); 15 + 16 + // Should now have two tabs 17 + const tabs = page.locator('.sheet-tab'); 18 + await expect(tabs).toHaveCount(2); 19 + await expect(tabs.last()).toContainText('Sheet 2'); 20 + 21 + // Type something in Sheet 2 22 + await typeInCell(page, 'A1', 'Sheet 2 Data'); 23 + expect(await getCellText(page, 'A1')).toBe('Sheet 2 Data'); 24 + 25 + // Switch back to Sheet 1 26 + await tabs.first().click(); 27 + 28 + // A1 should be empty on Sheet 1 29 + const text = await getCellText(page, 'A1'); 30 + expect(text).toBe(''); 31 + 32 + // Switch back to Sheet 2 and verify data persisted 33 + await tabs.last().click(); 34 + expect(await getCellText(page, 'A1')).toBe('Sheet 2 Data'); 35 + }); 36 + 37 + test('column resize by dragging column border', async ({ page }) => { 38 + // Get initial width of column A 39 + const colHeader = page.locator('thead th[data-col="1"]'); 40 + const initialBox = await colHeader.boundingBox(); 41 + expect(initialBox).toBeTruthy(); 42 + 43 + // Find the resize handle 44 + const handle = page.locator('.col-resize-handle[data-resize-col="1"]'); 45 + await expect(handle).toBeVisible(); 46 + 47 + // Drag the handle to resize 48 + const handleBox = await handle.boundingBox(); 49 + expect(handleBox).toBeTruthy(); 50 + 51 + await page.mouse.move(handleBox!.x + handleBox!.width / 2, handleBox!.y + handleBox!.height / 2); 52 + await page.mouse.down(); 53 + await page.mouse.move(handleBox!.x + 50, handleBox!.y + handleBox!.height / 2, { steps: 5 }); 54 + await page.mouse.up(); 55 + 56 + // Column should be wider now 57 + const newBox = await colHeader.boundingBox(); 58 + expect(newBox!.width).toBeGreaterThan(initialBox!.width); 59 + }); 60 + 61 + test('right-click context menu appears on cell', async ({ page }) => { 62 + await clickCell(page, 'A1'); 63 + await page.click('td[data-id="A1"]', { button: 'right' }); 64 + 65 + // Context menu should appear 66 + const contextMenu = page.locator('.context-menu'); 67 + await expect(contextMenu).toBeVisible({ timeout: 5000 }); 68 + 69 + // Should have menu items 70 + await expect(contextMenu.locator('.context-menu-item, [role="menuitem"]').first()).toBeVisible(); 71 + 72 + // Dismiss by clicking elsewhere 73 + await page.keyboard.press('Escape'); 74 + }); 75 + 76 + test('cell merge via toolbar button', async ({ page }) => { 77 + // Select a range A1:B2 78 + await clickCell(page, 'A1'); 79 + await page.keyboard.down('Shift'); 80 + await clickCell(page, 'B2'); 81 + await page.keyboard.up('Shift'); 82 + 83 + // Click merge button 84 + await page.click('#tb-merge'); 85 + 86 + // The merged cell should have colspan/rowspan attributes 87 + const mergedTd = page.locator('td[data-id="A1"]'); 88 + const colspan = await mergedTd.getAttribute('colspan'); 89 + const rowspan = await mergedTd.getAttribute('rowspan'); 90 + expect(colspan).toBe('2'); 91 + expect(rowspan).toBe('2'); 92 + }); 93 + 94 + test('freeze rows via overflow menu', async ({ page }) => { 95 + // Select row 2 96 + await clickCell(page, 'A2'); 97 + 98 + // Open overflow menu 99 + await page.click('#overflow-toggle'); 100 + await expect(page.locator('.toolbar-overflow-menu')).toBeVisible(); 101 + 102 + // Click freeze rows 103 + await page.click('#tb-freeze-rows'); 104 + 105 + // There should be frozen row styling on row 1 106 + await expect(page.locator('td.frozen-row').first()).toBeVisible({ timeout: 5000 }); 107 + }); 108 + 109 + test('freeze columns via overflow menu', async ({ page }) => { 110 + // Select column B (col 2) 111 + await clickCell(page, 'B1'); 112 + 113 + // Open overflow menu 114 + await page.click('#overflow-toggle'); 115 + await expect(page.locator('.toolbar-overflow-menu')).toBeVisible(); 116 + 117 + // Click freeze cols 118 + await page.click('#tb-freeze-cols'); 119 + 120 + // Column A should be frozen 121 + await expect(page.locator('td.frozen-col').first()).toBeVisible({ timeout: 5000 }); 122 + }); 123 + 124 + test('conditional formatting dialog opens', async ({ page }) => { 125 + await clickCell(page, 'A1'); 126 + 127 + // Open overflow menu 128 + await page.click('#overflow-toggle'); 129 + await expect(page.locator('.toolbar-overflow-menu')).toBeVisible(); 130 + 131 + // Click conditional formatting 132 + await page.click('#tb-cf'); 133 + 134 + // A dialog/modal should appear 135 + const cfDialog = page.locator('.cf-dialog, .modal, [class*="conditional"]'); 136 + await expect(cfDialog.first()).toBeVisible({ timeout: 5000 }); 137 + }); 138 + 139 + test('data validation dialog opens', async ({ page }) => { 140 + await clickCell(page, 'A1'); 141 + 142 + // Open overflow menu 143 + await page.click('#overflow-toggle'); 144 + await expect(page.locator('.toolbar-overflow-menu')).toBeVisible(); 145 + 146 + // Click data validation 147 + await page.click('#tb-validation'); 148 + 149 + // A dialog/modal should appear 150 + const validationDialog = page.locator('.validation-dialog, .modal, [class*="validation"]'); 151 + await expect(validationDialog.first()).toBeVisible({ timeout: 5000 }); 152 + }); 153 + 154 + test('chart button opens chart creation', async ({ page }) => { 155 + // Fill some data for chart 156 + await typeInCell(page, 'A1', 'Q1'); 157 + await typeInCell(page, 'A2', 'Q2'); 158 + await typeInCell(page, 'A3', 'Q3'); 159 + await typeInCell(page, 'B1', '100'); 160 + await typeInCell(page, 'B2', '200'); 161 + await typeInCell(page, 'B3', '150'); 162 + 163 + // Select the data range 164 + await clickCell(page, 'A1'); 165 + await page.keyboard.down('Shift'); 166 + await clickCell(page, 'B3'); 167 + await page.keyboard.up('Shift'); 168 + 169 + // Click chart button 170 + await page.click('#tb-chart'); 171 + 172 + // Chart dialog or chart element should appear 173 + const chartDialog = page.locator('.chart-dialog, .modal, [class*="chart"]'); 174 + await expect(chartDialog.first()).toBeVisible({ timeout: 5000 }); 175 + }); 176 + 177 + test('sort ascending via toolbar button', async ({ page }) => { 178 + await typeInCell(page, 'A1', 'Charlie'); 179 + await typeInCell(page, 'A2', 'Alice'); 180 + await typeInCell(page, 'A3', 'Bob'); 181 + 182 + // Select column A 183 + await clickCell(page, 'A1'); 184 + 185 + // Click sort ascending 186 + await page.click('#tb-sort-asc'); 187 + 188 + // After sort, A1 should be Alice 189 + expect(await getCellText(page, 'A1')).toBe('Alice'); 190 + expect(await getCellText(page, 'A2')).toBe('Bob'); 191 + expect(await getCellText(page, 'A3')).toBe('Charlie'); 192 + }); 193 + 194 + test('sort descending via toolbar button', async ({ page }) => { 195 + await typeInCell(page, 'A1', 'Alice'); 196 + await typeInCell(page, 'A2', 'Charlie'); 197 + await typeInCell(page, 'A3', 'Bob'); 198 + 199 + await clickCell(page, 'A1'); 200 + await page.click('#tb-sort-desc'); 201 + 202 + expect(await getCellText(page, 'A1')).toBe('Charlie'); 203 + expect(await getCellText(page, 'A2')).toBe('Bob'); 204 + expect(await getCellText(page, 'A3')).toBe('Alice'); 205 + }); 206 + 207 + test('striped rows toggle via overflow menu', async ({ page }) => { 208 + // Open overflow menu 209 + await page.click('#overflow-toggle'); 210 + await expect(page.locator('.toolbar-overflow-menu')).toBeVisible(); 211 + 212 + // Click striped rows 213 + await page.click('#tb-striped'); 214 + 215 + // Even rows should have striped class 216 + await expect(page.locator('td.striped-row').first()).toBeVisible({ timeout: 5000 }); 217 + }); 218 + 219 + test('status bar shows selection stats', async ({ page }) => { 220 + await typeInCell(page, 'A1', '10'); 221 + await typeInCell(page, 'A2', '20'); 222 + await typeInCell(page, 'A3', '30'); 223 + 224 + // Select range A1:A3 225 + await clickCell(page, 'A1'); 226 + await page.keyboard.down('Shift'); 227 + await clickCell(page, 'A3'); 228 + await page.keyboard.up('Shift'); 229 + 230 + // Status bar should show stats (sum, average, count) 231 + const statusBar = page.locator('#status-bar'); 232 + await expect(statusBar).toBeVisible({ timeout: 5000 }); 233 + const statsText = await page.locator('#status-bar-stats').textContent(); 234 + expect(statsText).toContain('60'); // sum 235 + }); 236 + });
+149
e2e/sheets-formatting.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewSheet, clickCell, typeInCell, getCellText } from './helpers'; 3 + 4 + test.describe('Sheets - Cell Formatting', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewSheet(page); 7 + }); 8 + 9 + test('apply bold via toolbar button', async ({ page }) => { 10 + await typeInCell(page, 'A1', 'Bold Text'); 11 + await clickCell(page, 'A1'); 12 + 13 + await page.click('#tb-bold'); 14 + 15 + // Cell display should have bold style 16 + const cellDisplay = page.locator('td[data-id="A1"] .cell-display'); 17 + await expect(cellDisplay).toHaveCSS('font-weight', '600'); 18 + }); 19 + 20 + test('apply italic via toolbar button', async ({ page }) => { 21 + await typeInCell(page, 'A1', 'Italic Text'); 22 + await clickCell(page, 'A1'); 23 + 24 + await page.click('#tb-italic'); 25 + 26 + const cellDisplay = page.locator('td[data-id="A1"] .cell-display'); 27 + await expect(cellDisplay).toHaveCSS('font-style', 'italic'); 28 + }); 29 + 30 + test('apply bold via keyboard shortcut', async ({ page }) => { 31 + await typeInCell(page, 'A1', 'KB Bold'); 32 + await clickCell(page, 'A1'); 33 + 34 + await page.keyboard.press('Meta+b'); 35 + 36 + const cellDisplay = page.locator('td[data-id="A1"] .cell-display'); 37 + await expect(cellDisplay).toHaveCSS('font-weight', '600'); 38 + }); 39 + 40 + test('change text color via color picker', async ({ page }) => { 41 + await typeInCell(page, 'A1', 'Colored'); 42 + await clickCell(page, 'A1'); 43 + 44 + // Set text color via the color input 45 + await page.locator('#tb-text-color').evaluate((el: HTMLInputElement) => { 46 + // Programmatically set color and dispatch event 47 + el.value = '#ff0000'; 48 + el.dispatchEvent(new Event('input', { bubbles: true })); 49 + }); 50 + 51 + // Verify the cell display has the color applied 52 + const cellDisplay = page.locator('td[data-id="A1"] .cell-display'); 53 + const color = await cellDisplay.evaluate(el => getComputedStyle(el).color); 54 + // Should be red (rgb(255, 0, 0)) 55 + expect(color).toMatch(/rgb\(255,\s*0,\s*0\)/); 56 + }); 57 + 58 + test('change background color via color picker', async ({ page }) => { 59 + await typeInCell(page, 'A1', 'Highlighted'); 60 + await clickCell(page, 'A1'); 61 + 62 + await page.locator('#tb-bg-color').evaluate((el: HTMLInputElement) => { 63 + el.value = '#ffff00'; 64 + el.dispatchEvent(new Event('input', { bubbles: true })); 65 + }); 66 + 67 + const cellDisplay = page.locator('td[data-id="A1"] .cell-display'); 68 + const bg = await cellDisplay.evaluate(el => getComputedStyle(el).backgroundColor); 69 + // Should be yellow 70 + expect(bg).toMatch(/rgb\(255,\s*255,\s*0\)/); 71 + }); 72 + 73 + test('apply currency format shows dollar sign', async ({ page }) => { 74 + await typeInCell(page, 'A1', '1234.56'); 75 + await clickCell(page, 'A1'); 76 + 77 + // Select currency format from the dropdown 78 + await page.selectOption('#tb-format', 'currency'); 79 + 80 + const text = await getCellText(page, 'A1'); 81 + expect(text).toContain('$'); 82 + }); 83 + 84 + test('apply percent format shows percent sign', async ({ page }) => { 85 + await typeInCell(page, 'A1', '0.75'); 86 + await clickCell(page, 'A1'); 87 + 88 + await page.selectOption('#tb-format', 'percent'); 89 + 90 + const text = await getCellText(page, 'A1'); 91 + expect(text).toContain('%'); 92 + }); 93 + 94 + test('change font size', async ({ page }) => { 95 + await typeInCell(page, 'A1', 'Big Text'); 96 + await clickCell(page, 'A1'); 97 + 98 + await page.selectOption('#tb-font-size', '20'); 99 + 100 + const cellDisplay = page.locator('td[data-id="A1"] .cell-display'); 101 + const fontSize = await cellDisplay.evaluate(el => getComputedStyle(el).fontSize); 102 + // 20pt should be larger than default 103 + expect(parseFloat(fontSize)).toBeGreaterThan(14); 104 + }); 105 + 106 + test('change font family', async ({ page }) => { 107 + await typeInCell(page, 'A1', 'Serif Text'); 108 + await clickCell(page, 'A1'); 109 + 110 + await page.selectOption('#tb-font-family', 'serif'); 111 + 112 + const cellDisplay = page.locator('td[data-id="A1"] .cell-display'); 113 + const fontFamily = await cellDisplay.evaluate(el => getComputedStyle(el).fontFamily); 114 + expect(fontFamily).toMatch(/serif/i); 115 + }); 116 + 117 + test('underline via toolbar button', async ({ page }) => { 118 + await typeInCell(page, 'A1', 'Underlined'); 119 + await clickCell(page, 'A1'); 120 + 121 + await page.click('#tb-underline'); 122 + 123 + const cellDisplay = page.locator('td[data-id="A1"] .cell-display'); 124 + const decoration = await cellDisplay.evaluate(el => getComputedStyle(el).textDecorationLine); 125 + expect(decoration).toContain('underline'); 126 + }); 127 + 128 + test('strikethrough via toolbar button', async ({ page }) => { 129 + await typeInCell(page, 'A1', 'Struck'); 130 + await clickCell(page, 'A1'); 131 + 132 + await page.click('#tb-strikethrough'); 133 + 134 + const cellDisplay = page.locator('td[data-id="A1"] .cell-display'); 135 + const decoration = await cellDisplay.evaluate(el => getComputedStyle(el).textDecorationLine); 136 + expect(decoration).toContain('line-through'); 137 + }); 138 + 139 + test('number format shows plain number', async ({ page }) => { 140 + await typeInCell(page, 'A1', '1234'); 141 + await clickCell(page, 'A1'); 142 + 143 + await page.selectOption('#tb-format', 'number'); 144 + 145 + const text = await getCellText(page, 'A1'); 146 + // Should be formatted as a number (may include commas) 147 + expect(text).toMatch(/1,?234/); 148 + }); 149 + });
+114
e2e/sheets-formulas.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewSheet, clickCell, typeInCell, getCellText, getFormulaBarValue } from './helpers'; 3 + 4 + test.describe('Sheets - Formulas', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewSheet(page); 7 + }); 8 + 9 + test('simple arithmetic =1+1 displays 2', async ({ page }) => { 10 + await typeInCell(page, 'A1', '=1+1'); 11 + const text = await getCellText(page, 'A1'); 12 + expect(text).toBe('2'); 13 + }); 14 + 15 + test('=SUM(A1:A3) computes correct sum', async ({ page }) => { 16 + await typeInCell(page, 'A1', '10'); 17 + await typeInCell(page, 'A2', '20'); 18 + await typeInCell(page, 'A3', '30'); 19 + await typeInCell(page, 'B1', '=SUM(A1:A3)'); 20 + 21 + const text = await getCellText(page, 'B1'); 22 + expect(text).toBe('60'); 23 + }); 24 + 25 + test('=IF conditional returns correct value', async ({ page }) => { 26 + await typeInCell(page, 'A1', '10'); 27 + await typeInCell(page, 'B1', '=IF(A1>5,"yes","no")'); 28 + 29 + expect(await getCellText(page, 'B1')).toBe('yes'); 30 + 31 + // Change A1 to trigger "no" 32 + await typeInCell(page, 'A1', '3'); 33 + // B1 should recalculate 34 + expect(await getCellText(page, 'B1')).toBe('no'); 35 + }); 36 + 37 + test('formula bar shows formula when cell with formula is selected', async ({ page }) => { 38 + await typeInCell(page, 'A1', '=2*3'); 39 + // Cell displays computed value 40 + expect(await getCellText(page, 'A1')).toBe('6'); 41 + 42 + // Select the cell 43 + await clickCell(page, 'A1'); 44 + // Formula bar should show the formula, not the value 45 + const barValue = await getFormulaBarValue(page); 46 + expect(barValue).toBe('=2*3'); 47 + }); 48 + 49 + test('error display for invalid formula', async ({ page }) => { 50 + await typeInCell(page, 'A1', '=NOTAFUNCTION(1)'); 51 + 52 + const text = await getCellText(page, 'A1'); 53 + // Should display an error indicator 54 + expect(text).toMatch(/ERR|#NAME|#ERROR|Error/i); 55 + }); 56 + 57 + test('=AVERAGE computes correctly', async ({ page }) => { 58 + await typeInCell(page, 'A1', '10'); 59 + await typeInCell(page, 'A2', '20'); 60 + await typeInCell(page, 'A3', '30'); 61 + await typeInCell(page, 'B1', '=AVERAGE(A1:A3)'); 62 + 63 + expect(await getCellText(page, 'B1')).toBe('20'); 64 + }); 65 + 66 + test('=MAX and =MIN work', async ({ page }) => { 67 + await typeInCell(page, 'A1', '5'); 68 + await typeInCell(page, 'A2', '15'); 69 + await typeInCell(page, 'A3', '10'); 70 + await typeInCell(page, 'B1', '=MAX(A1:A3)'); 71 + await typeInCell(page, 'B2', '=MIN(A1:A3)'); 72 + 73 + expect(await getCellText(page, 'B1')).toBe('15'); 74 + expect(await getCellText(page, 'B2')).toBe('5'); 75 + }); 76 + 77 + test('=COUNT counts numeric cells', async ({ page }) => { 78 + await typeInCell(page, 'A1', '5'); 79 + await typeInCell(page, 'A2', 'text'); 80 + await typeInCell(page, 'A3', '10'); 81 + await typeInCell(page, 'B1', '=COUNT(A1:A3)'); 82 + 83 + expect(await getCellText(page, 'B1')).toBe('2'); 84 + }); 85 + 86 + test('cell reference updates when dependency changes', async ({ page }) => { 87 + await typeInCell(page, 'A1', '100'); 88 + await typeInCell(page, 'B1', '=A1*2'); 89 + 90 + expect(await getCellText(page, 'B1')).toBe('200'); 91 + 92 + // Update A1 93 + await typeInCell(page, 'A1', '50'); 94 + 95 + // B1 should recalculate 96 + expect(await getCellText(page, 'B1')).toBe('100'); 97 + }); 98 + 99 + test('string concatenation with &', async ({ page }) => { 100 + await typeInCell(page, 'A1', 'Hello'); 101 + await typeInCell(page, 'A2', 'World'); 102 + await typeInCell(page, 'B1', '=A1&" "&A2'); 103 + 104 + expect(await getCellText(page, 'B1')).toBe('Hello World'); 105 + }); 106 + 107 + test('nested formulas work', async ({ page }) => { 108 + await typeInCell(page, 'A1', '10'); 109 + await typeInCell(page, 'A2', '20'); 110 + await typeInCell(page, 'B1', '=SUM(A1:A2)*2'); 111 + 112 + expect(await getCellText(page, 'B1')).toBe('60'); 113 + }); 114 + });
+130
e2e/sheets-import.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewSheet, clickCell, getCellText } from './helpers'; 3 + import path from 'path'; 4 + 5 + test.describe('Sheets - Import/Export', () => { 6 + test.beforeEach(async ({ page }) => { 7 + await createNewSheet(page); 8 + }); 9 + 10 + test('import CSV via toolbar menu', async ({ page }) => { 11 + test.slow(); // Import operations need extra time 12 + 13 + // Create a CSV file in memory and import it via the file input 14 + // Open overflow menu and click import 15 + await page.click('#overflow-toggle'); 16 + await expect(page.locator('.toolbar-overflow-menu')).toBeVisible(); 17 + 18 + // Set up a file chooser listener before clicking import 19 + const fileChooserPromise = page.waitForEvent('filechooser'); 20 + await page.click('#tb-import'); 21 + const fileChooser = await fileChooserPromise; 22 + 23 + // Create a temporary CSV file 24 + const csvContent = 'Name,Value,Category\nAlice,100,A\nBob,200,B\nCharlie,300,C'; 25 + 26 + // Use a buffer to simulate the CSV file 27 + await fileChooser.setFiles({ 28 + name: 'test-data.csv', 29 + mimeType: 'text/csv', 30 + buffer: Buffer.from(csvContent), 31 + }); 32 + 33 + // Wait for import to complete - cells should be populated 34 + await expect(page.locator('td[data-id="A1"] .cell-display')).toContainText('Name', { timeout: 10000 }); 35 + expect(await getCellText(page, 'B1')).toBe('Value'); 36 + expect(await getCellText(page, 'C1')).toBe('Category'); 37 + expect(await getCellText(page, 'A2')).toBe('Alice'); 38 + expect(await getCellText(page, 'B2')).toBe('100'); 39 + expect(await getCellText(page, 'A4')).toBe('Charlie'); 40 + }); 41 + 42 + test('import XLSX via toolbar menu', async ({ page }) => { 43 + test.slow(); 44 + 45 + // Build a minimal XLSX in memory using ExcelJS (available as dependency) 46 + // We'll create the buffer using page.evaluate since ExcelJS is a server dep 47 + const xlsxBuffer = await page.evaluate(async () => { 48 + // We can't use ExcelJS in browser, so we'll skip this approach 49 + // and test with a manually constructed minimal xlsx 50 + return null; 51 + }); 52 + 53 + // Instead, test that the file chooser opens and accepts xlsx files 54 + await page.click('#overflow-toggle'); 55 + await expect(page.locator('.toolbar-overflow-menu')).toBeVisible(); 56 + 57 + const fileChooserPromise = page.waitForEvent('filechooser'); 58 + await page.click('#tb-import'); 59 + const fileChooser = await fileChooserPromise; 60 + 61 + // Verify the file chooser was triggered (the import flow works) 62 + expect(fileChooser).toBeTruthy(); 63 + }); 64 + 65 + test('export CSV triggers download', async ({ page }) => { 66 + // Add some data first 67 + await page.locator('td[data-id="A1"]').click(); 68 + await page.keyboard.type('Header1'); 69 + await page.keyboard.press('Tab'); 70 + await page.keyboard.type('Header2'); 71 + await page.keyboard.press('Enter'); 72 + await page.locator('td[data-id="A2"]').click(); 73 + await page.keyboard.type('Val1'); 74 + await page.keyboard.press('Tab'); 75 + await page.keyboard.type('Val2'); 76 + await page.keyboard.press('Enter'); 77 + 78 + // Set up download listener 79 + const downloadPromise = page.waitForEvent('download'); 80 + 81 + // Open overflow menu and export CSV 82 + await page.click('#overflow-toggle'); 83 + await expect(page.locator('.toolbar-overflow-menu')).toBeVisible(); 84 + await page.click('#tb-export-csv'); 85 + 86 + const download = await downloadPromise; 87 + expect(download.suggestedFilename()).toMatch(/\.csv$/); 88 + }); 89 + 90 + test('export XLSX triggers download', async ({ page }) => { 91 + // Add some data 92 + await page.locator('td[data-id="A1"]').click(); 93 + await page.keyboard.type('Data'); 94 + await page.keyboard.press('Enter'); 95 + 96 + const downloadPromise = page.waitForEvent('download'); 97 + 98 + await page.click('#overflow-toggle'); 99 + await expect(page.locator('.toolbar-overflow-menu')).toBeVisible(); 100 + await page.click('#tb-export-xlsx'); 101 + 102 + const download = await downloadPromise; 103 + expect(download.suggestedFilename()).toMatch(/\.xlsx$/); 104 + }); 105 + 106 + test('imported CSV data appears in correct cells', async ({ page }) => { 107 + test.slow(); 108 + 109 + await page.click('#overflow-toggle'); 110 + await expect(page.locator('.toolbar-overflow-menu')).toBeVisible(); 111 + 112 + const fileChooserPromise = page.waitForEvent('filechooser'); 113 + await page.click('#tb-import'); 114 + const fileChooser = await fileChooserPromise; 115 + 116 + // CSV with tab separation 117 + const tsvContent = 'A\tB\tC\n1\t2\t3\n4\t5\t6'; 118 + await fileChooser.setFiles({ 119 + name: 'test-data.tsv', 120 + mimeType: 'text/tab-separated-values', 121 + buffer: Buffer.from(tsvContent), 122 + }); 123 + 124 + // Check that data loaded into the correct cells 125 + await expect(page.locator('td[data-id="A1"] .cell-display')).toContainText('A', { timeout: 10000 }); 126 + expect(await getCellText(page, 'B1')).toBe('B'); 127 + expect(await getCellText(page, 'C2')).toBe('3'); 128 + expect(await getCellText(page, 'A3')).toBe('4'); 129 + }); 130 + });
+66 -2
package-lock.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.2.0", 3 + "version": "0.3.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tools", 9 - "version": "0.2.0", 9 + "version": "0.3.0", 10 10 "dependencies": { 11 11 "@tiptap/core": "^2.11.0", 12 12 "@tiptap/extension-collaboration": "^2.11.0", ··· 47 47 "yjs": "^13.6.20" 48 48 }, 49 49 "devDependencies": { 50 + "@playwright/test": "^1.58.2", 50 51 "@types/better-sqlite3": "^7.6.13", 51 52 "@types/compression": "^1.8.1", 52 53 "@types/express": "^5.0.6", ··· 786 787 "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", 787 788 "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", 788 789 "license": "BSD-2-Clause" 790 + }, 791 + "node_modules/@playwright/test": { 792 + "version": "1.58.2", 793 + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", 794 + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", 795 + "dev": true, 796 + "license": "Apache-2.0", 797 + "dependencies": { 798 + "playwright": "1.58.2" 799 + }, 800 + "bin": { 801 + "playwright": "cli.js" 802 + }, 803 + "engines": { 804 + "node": ">=18" 805 + } 789 806 }, 790 807 "node_modules/@remirror/core-constants": { 791 808 "version": "3.0.0", ··· 4351 4368 }, 4352 4369 "funding": { 4353 4370 "url": "https://github.com/sponsors/jonschlinkert" 4371 + } 4372 + }, 4373 + "node_modules/playwright": { 4374 + "version": "1.58.2", 4375 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", 4376 + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", 4377 + "dev": true, 4378 + "license": "Apache-2.0", 4379 + "dependencies": { 4380 + "playwright-core": "1.58.2" 4381 + }, 4382 + "bin": { 4383 + "playwright": "cli.js" 4384 + }, 4385 + "engines": { 4386 + "node": ">=18" 4387 + }, 4388 + "optionalDependencies": { 4389 + "fsevents": "2.3.2" 4390 + } 4391 + }, 4392 + "node_modules/playwright-core": { 4393 + "version": "1.58.2", 4394 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", 4395 + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", 4396 + "dev": true, 4397 + "license": "Apache-2.0", 4398 + "bin": { 4399 + "playwright-core": "cli.js" 4400 + }, 4401 + "engines": { 4402 + "node": ">=18" 4403 + } 4404 + }, 4405 + "node_modules/playwright/node_modules/fsevents": { 4406 + "version": "2.3.2", 4407 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 4408 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 4409 + "dev": true, 4410 + "hasInstallScript": true, 4411 + "license": "MIT", 4412 + "optional": true, 4413 + "os": [ 4414 + "darwin" 4415 + ], 4416 + "engines": { 4417 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 4354 4418 } 4355 4419 }, 4356 4420 "node_modules/postcss": {
+2
package.json
··· 9 9 "start": "tsx server/index.ts", 10 10 "preview": "npm run build && tsx server/index.ts", 11 11 "test": "vitest run", 12 + "e2e": "playwright test", 12 13 "typecheck": "tsc --noEmit" 13 14 }, 14 15 "dependencies": { ··· 51 52 "yjs": "^13.6.20" 52 53 }, 53 54 "devDependencies": { 55 + "@playwright/test": "^1.58.2", 54 56 "@types/better-sqlite3": "^7.6.13", 55 57 "@types/compression": "^1.8.1", 56 58 "@types/express": "^5.0.6",
+32
playwright.config.ts
··· 1 + import { defineConfig, devices } from '@playwright/test'; 2 + 3 + export default defineConfig({ 4 + testDir: './e2e', 5 + fullyParallel: false, 6 + forbidOnly: !!process.env.CI, 7 + retries: process.env.CI ? 1 : 0, 8 + workers: 1, 9 + reporter: process.env.CI ? 'github' : 'list', 10 + timeout: 30_000, 11 + expect: { 12 + timeout: 10_000, 13 + }, 14 + use: { 15 + baseURL: 'http://localhost:5173', 16 + trace: 'on-first-retry', 17 + screenshot: 'only-on-failure', 18 + video: 'off', 19 + }, 20 + projects: [ 21 + { 22 + name: 'chromium', 23 + use: { ...devices['Desktop Chrome'] }, 24 + }, 25 + ], 26 + webServer: { 27 + command: 'npm run dev', 28 + url: 'http://localhost:5173', 29 + reuseExistingServer: !process.env.CI, 30 + timeout: 30_000, 31 + }, 32 + });
+4
src/sheets/formula-autocomplete.ts
··· 71 71 { name: 'XLOOKUP', signature: 'XLOOKUP(lookup_value, lookup_array, return_array, [if_not_found], [match_mode], [search_mode])' }, 72 72 { name: 'INDEX', signature: 'INDEX(array, row_num, [col_num])' }, 73 73 { name: 'MATCH', signature: 'MATCH(lookup_value, lookup_array, [match_type])' }, 74 + { name: 'INDIRECT', signature: 'INDIRECT(ref_text)' }, 75 + { name: 'ADDRESS', signature: 'ADDRESS(row_num, col_num, [abs_num])' }, 76 + { name: 'ROW', signature: 'ROW(reference)' }, 77 + { name: 'COLUMN', signature: 'COLUMN(reference)' }, 74 78 75 79 // Conditional 76 80 { name: 'SUMIF', signature: 'SUMIF(range, criteria, [sum_range])' },
+28
src/sheets/formula-tooltip.ts
··· 377 377 { name: 'average_range', desc: 'The range to average (default: same as range)', required: false }, 378 378 ], 379 379 }, 380 + 381 + // --- Reference --- 382 + INDIRECT: { 383 + desc: 'Returns the value of a cell specified by a text string reference', 384 + params: [ 385 + { name: 'ref_text', desc: 'A cell reference as a text string (e.g. "A1", "Sheet2!B3")', required: true }, 386 + ], 387 + }, 388 + ADDRESS: { 389 + desc: 'Returns a cell address as text given row and column numbers', 390 + params: [ 391 + { name: 'row_num', desc: 'The row number of the cell reference', required: true }, 392 + { name: 'col_num', desc: 'The column number of the cell reference', required: true }, 393 + { name: 'abs_num', desc: '1=absolute (default), 2=abs row, 3=abs col, 4=relative', required: false }, 394 + ], 395 + }, 396 + ROW: { 397 + desc: 'Returns the row number of a cell reference', 398 + params: [ 399 + { name: 'reference', desc: 'The cell reference to get the row number from', required: true }, 400 + ], 401 + }, 402 + COLUMN: { 403 + desc: 'Returns the column number of a cell reference', 404 + params: [ 405 + { name: 'reference', desc: 'The cell reference to get the column number from', required: true }, 406 + ], 407 + }, 380 408 }; 381 409 382 410 /**
+87
src/sheets/formulas.ts
··· 351 351 if (t.value === 'LET') { 352 352 return this.parseLet(); 353 353 } 354 + // Special handling for INDIRECT — needs access to getCellValue and crossSheetResolver 355 + if (t.value === 'INDIRECT') { 356 + return this.parseIndirect(); 357 + } 358 + // Special handling for ROW/COLUMN — needs raw cell ref, not its value 359 + if (t.value === 'ROW' || t.value === 'COLUMN') { 360 + return this.parseRowColumn(t.value as 'ROW' | 'COLUMN'); 361 + } 354 362 this.expect(TokenType.LPAREN); 355 363 const args = []; 356 364 if (this.peek().type !== TokenType.RPAREN) { ··· 504 512 } 505 513 } 506 514 515 + // Parse INDIRECT(ref_text) — evaluates its argument as a string, then resolves as a cell reference 516 + parseIndirect(): unknown { 517 + this.expect(TokenType.LPAREN); 518 + const refText = String(this.expression()); 519 + this.expect(TokenType.RPAREN); 520 + 521 + if (!refText) return '#REF!'; 522 + 523 + // Uppercase the cell ref portion (sheet names are case-sensitive) 524 + // Check for cross-sheet ref: contains '!' 525 + const bangIdx = refText.indexOf('!'); 526 + if (bangIdx !== -1) { 527 + let sheetName: string; 528 + let cellRefStr: string; 529 + 530 + // Handle quoted sheet names: 'Sheet Name'!A1 531 + if (refText.startsWith("'")) { 532 + const closeQuote = refText.indexOf("'", 1); 533 + if (closeQuote === -1 || refText[closeQuote + 1] !== '!') return '#REF!'; 534 + sheetName = refText.slice(1, closeQuote); 535 + cellRefStr = refText.slice(closeQuote + 2).toUpperCase(); 536 + } else { 537 + sheetName = refText.slice(0, bangIdx); 538 + cellRefStr = refText.slice(bangIdx + 1).toUpperCase(); 539 + } 540 + 541 + // Validate the cell ref portion 542 + const parsed = parseRef(cellRefStr); 543 + if (!parsed) return '#REF!'; 544 + 545 + if (!this.crossSheetResolver) return '#REF!'; 546 + if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 547 + return this.crossSheetResolver.getSheetCellValue(sheetName, cellRefStr); 548 + } 549 + 550 + // Simple same-sheet ref — strip $ signs and uppercase 551 + const cleaned = refText.toUpperCase().replace(/\$/g, ''); 552 + const parsed = parseRef(cleaned); 553 + if (!parsed) return '#REF!'; 554 + return this.getCellValue(cleaned); 555 + } 556 + 557 + // Parse ROW(ref) / COLUMN(ref) — needs the raw cell reference, not its value 558 + parseRowColumn(fn: 'ROW' | 'COLUMN'): unknown { 559 + this.expect(TokenType.LPAREN); 560 + const t = this.peek(); 561 + if (t.type === TokenType.CELL_REF) { 562 + this.advance(); 563 + this.expect(TokenType.RPAREN); 564 + const ref = parseRef(t.value as string); 565 + if (!ref) return '#REF!'; 566 + return fn === 'ROW' ? ref.row : ref.col; 567 + } 568 + // If not a direct cell ref, evaluate the expression (fallback) 569 + const val = this.expression(); 570 + this.expect(TokenType.RPAREN); 571 + // Try to parse the evaluated value as a ref string 572 + const refStr = String(val).toUpperCase().replace(/\$/g, ''); 573 + const ref = parseRef(refStr); 574 + if (!ref) return '#REF!'; 575 + return fn === 'ROW' ? ref.row : ref.col; 576 + } 577 + 507 578 resolveRange(startRef: string, endRef: string): RangeArray { 508 579 const start = parseRef(startRef); 509 580 const end = parseRef(endRef); ··· 677 748 const range = Array.isArray(args[1]) ? args[1] : [args[1]]; 678 749 const idx = range.findIndex(v => v === needle || String(v) === String(needle)); 679 750 return idx === -1 ? '#N/A' : idx + 1; 751 + } 752 + 753 + case 'ADDRESS': { 754 + // ADDRESS(row_num, col_num, [abs_num]) 755 + // abs_num: 1=absolute (default), 2=abs row/rel col, 3=rel row/abs col, 4=relative 756 + const rowNum = toNum(args[0]); 757 + const colNum = toNum(args[1]); 758 + const absNum = args[2] !== undefined ? toNum(args[2]) : 1; 759 + const colLetter = colToLetter(colNum); 760 + switch (absNum) { 761 + case 1: return '$' + colLetter + '$' + rowNum; 762 + case 2: return colLetter + '$' + rowNum; 763 + case 3: return '$' + colLetter + rowNum; 764 + case 4: return colLetter + '' + rowNum; 765 + default: return '$' + colLetter + '$' + rowNum; 766 + } 680 767 } 681 768 682 769 case 'SUMIF': {
+247
tests/indirect.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { evaluate, colToLetter } from '../src/sheets/formulas.js'; 3 + 4 + // Helper: evaluate with a simple cell map 5 + function evalWith(formula: string, cells: Record<string, unknown> = {}) { 6 + return evaluate(formula, (ref) => cells[ref] ?? ''); 7 + } 8 + 9 + // Helper: evaluate with cross-sheet support 10 + function evalCrossSheet( 11 + formula: string, 12 + sheetsData: Record<string, Record<string, unknown>> = {}, 13 + currentSheet = 'Sheet1' 14 + ) { 15 + const resolver = { 16 + getSheetCellValue(sheetName: string, cellRef: string) { 17 + const sheet = sheetsData[sheetName]; 18 + if (!sheet) return '#REF!'; 19 + return sheet[cellRef] ?? ''; 20 + }, 21 + sheetExists(name: string) { 22 + return name in sheetsData; 23 + }, 24 + }; 25 + return evaluate( 26 + formula, 27 + (ref) => { 28 + const sheet = sheetsData[currentSheet]; 29 + if (!sheet) return ''; 30 + return sheet[ref] ?? ''; 31 + }, 32 + resolver 33 + ); 34 + } 35 + 36 + // ============================================================ 37 + // INDIRECT 38 + // ============================================================ 39 + 40 + describe('INDIRECT — simple cell references', () => { 41 + it('resolves a simple cell ref string', () => { 42 + expect(evalWith('INDIRECT("A1")', { A1: 42 })).toBe(42); 43 + }); 44 + 45 + it('resolves a string cell ref with text value', () => { 46 + expect(evalWith('INDIRECT("B3")', { B3: 'hello' })).toBe('hello'); 47 + }); 48 + 49 + it('resolves a multi-letter column ref', () => { 50 + expect(evalWith('INDIRECT("AA1")', { AA1: 99 })).toBe(99); 51 + }); 52 + 53 + it('returns empty string for empty cell', () => { 54 + expect(evalWith('INDIRECT("C5")', {})).toBe(''); 55 + }); 56 + 57 + it('returns #REF! for invalid reference string', () => { 58 + expect(evalWith('INDIRECT("not_a_ref")', {})).toBe('#REF!'); 59 + }); 60 + 61 + it('returns #REF! for empty string argument', () => { 62 + expect(evalWith('INDIRECT("")', {})).toBe('#REF!'); 63 + }); 64 + }); 65 + 66 + describe('INDIRECT — dynamic references via concatenation', () => { 67 + it('builds a ref from string concat: "B"&A1', () => { 68 + // A1 contains the row number 5, so INDIRECT("B"&A1) => B5 69 + expect(evalWith('INDIRECT("B"&A1)', { A1: 5, B5: 'found' })).toBe('found'); 70 + }); 71 + 72 + it('builds a ref from numeric concat: "A"&(1+2)', () => { 73 + expect(evalWith('INDIRECT("A"&(1+2))', { A3: 77 })).toBe(77); 74 + }); 75 + 76 + it('builds a ref from cell containing column letter', () => { 77 + // A1 contains "C", A2 contains 3 => "C3" 78 + expect(evalWith('INDIRECT(A1&A2)', { A1: 'C', A2: 3, C3: 'yes' })).toBe('yes'); 79 + }); 80 + }); 81 + 82 + describe('INDIRECT — cross-sheet references', () => { 83 + it('resolves a cross-sheet ref: "Sheet2!A1"', () => { 84 + const sheets = { 85 + Sheet1: {}, 86 + Sheet2: { A1: 'from sheet2' }, 87 + }; 88 + expect(evalCrossSheet('INDIRECT("Sheet2!A1")', sheets)).toBe('from sheet2'); 89 + }); 90 + 91 + it('resolves a dynamic cross-sheet ref: "SheetName!B"&A3', () => { 92 + const sheets = { 93 + Sheet1: { A3: 7 }, 94 + AccountTracking: { B7: 500 }, 95 + }; 96 + expect(evalCrossSheet('INDIRECT("AccountTracking!B"&A3)', sheets)).toBe(500); 97 + }); 98 + 99 + it('resolves cross-sheet ref with quoted sheet name containing spaces', () => { 100 + const sheets = { 101 + Sheet1: {}, 102 + 'My Data': { C2: 'spaced' }, 103 + }; 104 + expect(evalCrossSheet("INDIRECT(\"'My Data'!C2\")", sheets)).toBe('spaced'); 105 + }); 106 + 107 + it('returns #REF! when cross-sheet resolver is not available', () => { 108 + // No resolver passed 109 + const result = evaluate('INDIRECT("Sheet2!A1")', (ref) => ''); 110 + expect(result).toBe('#REF!'); 111 + }); 112 + 113 + it('returns #REF! when referenced sheet does not exist', () => { 114 + const sheets = { 115 + Sheet1: {}, 116 + }; 117 + expect(evalCrossSheet('INDIRECT("NonExistent!A1")', sheets)).toBe('#REF!'); 118 + }); 119 + }); 120 + 121 + describe('INDIRECT — used inside other formulas', () => { 122 + it('works inside SUM: =SUM(INDIRECT("A1"), INDIRECT("A2"))', () => { 123 + expect(evalWith('SUM(INDIRECT("A1"), INDIRECT("A2"))', { A1: 10, A2: 20 })).toBe(30); 124 + }); 125 + 126 + it('works inside IF', () => { 127 + expect(evalWith('IF(INDIRECT("A1")>5, "big", "small")', { A1: 10 })).toBe('big'); 128 + expect(evalWith('IF(INDIRECT("A1")>5, "big", "small")', { A1: 3 })).toBe('small'); 129 + }); 130 + 131 + it('can be used with VLOOKUP (indirect for lookup value)', () => { 132 + const cells = { A1: 'key2', B1: 'key1', B2: 'key2', C1: 100, C2: 200 }; 133 + expect(evalWith('VLOOKUP(INDIRECT("A1"),B1:C2,2,FALSE)', cells)).toBe(200); 134 + }); 135 + }); 136 + 137 + describe('INDIRECT — case insensitivity of ref string', () => { 138 + it('handles lowercase ref string "a1"', () => { 139 + expect(evalWith('INDIRECT("a1")', { A1: 42 })).toBe(42); 140 + }); 141 + 142 + it('handles mixed case "b3"', () => { 143 + expect(evalWith('INDIRECT("b3")', { B3: 'test' })).toBe('test'); 144 + }); 145 + 146 + it('handles lowercase cross-sheet ref', () => { 147 + const sheets = { 148 + Sheet1: {}, 149 + Sheet2: { A1: 'lower' }, 150 + }; 151 + expect(evalCrossSheet('INDIRECT("sheet2!a1")', sheets)).toBe('#REF!'); 152 + // Note: sheet names are case-sensitive, but cell refs should be uppercased 153 + expect(evalCrossSheet('INDIRECT("Sheet2!a1")', sheets)).toBe('lower'); 154 + }); 155 + }); 156 + 157 + // ============================================================ 158 + // ADDRESS 159 + // ============================================================ 160 + 161 + describe('ADDRESS — basic', () => { 162 + it('returns absolute reference by default: ADDRESS(1,1) => "$A$1"', () => { 163 + expect(evalWith('ADDRESS(1,1)')).toBe('$A$1'); 164 + }); 165 + 166 + it('returns absolute reference for row 5, col 2', () => { 167 + expect(evalWith('ADDRESS(5,2)')).toBe('$B$5'); 168 + }); 169 + 170 + it('handles multi-letter columns', () => { 171 + expect(evalWith('ADDRESS(1,27)')).toBe('$AA$1'); 172 + }); 173 + }); 174 + 175 + describe('ADDRESS — abs_num parameter', () => { 176 + it('abs_num=1: absolute row and column ($A$1)', () => { 177 + expect(evalWith('ADDRESS(3,2,1)')).toBe('$B$3'); 178 + }); 179 + 180 + it('abs_num=2: absolute row, relative column (B$3)', () => { 181 + expect(evalWith('ADDRESS(3,2,2)')).toBe('B$3'); 182 + }); 183 + 184 + it('abs_num=3: relative row, absolute column ($B3)', () => { 185 + expect(evalWith('ADDRESS(3,2,3)')).toBe('$B3'); 186 + }); 187 + 188 + it('abs_num=4: relative row and column (B3)', () => { 189 + expect(evalWith('ADDRESS(3,2,4)')).toBe('B3'); 190 + }); 191 + 192 + it('defaults to abs_num=1 when omitted', () => { 193 + expect(evalWith('ADDRESS(1,1)')).toBe('$A$1'); 194 + }); 195 + }); 196 + 197 + describe('ADDRESS — used with INDIRECT', () => { 198 + it('INDIRECT(ADDRESS(row, col)) resolves the cell', () => { 199 + expect(evalWith('INDIRECT(ADDRESS(1,1))', { A1: 'found' })).toBe('found'); 200 + }); 201 + 202 + it('INDIRECT(ADDRESS(row, col, 4)) with relative ref', () => { 203 + expect(evalWith('INDIRECT(ADDRESS(2,3,4))', { C2: 'bingo' })).toBe('bingo'); 204 + }); 205 + 206 + it('dynamic row: INDIRECT(ADDRESS(A1, 2))', () => { 207 + expect(evalWith('INDIRECT(ADDRESS(A1, 2))', { A1: 5, B5: 'dynamic' })).toBe('dynamic'); 208 + }); 209 + }); 210 + 211 + // ============================================================ 212 + // ROW and COLUMN 213 + // ============================================================ 214 + 215 + describe('ROW — basic', () => { 216 + it('ROW(A5) returns 5', () => { 217 + expect(evalWith('ROW(A5)')).toBe(5); 218 + }); 219 + 220 + it('ROW(C100) returns 100', () => { 221 + expect(evalWith('ROW(C100)')).toBe(100); 222 + }); 223 + }); 224 + 225 + describe('COLUMN — basic', () => { 226 + it('COLUMN(A1) returns 1', () => { 227 + expect(evalWith('COLUMN(A1)')).toBe(1); 228 + }); 229 + 230 + it('COLUMN(C1) returns 3', () => { 231 + expect(evalWith('COLUMN(C1)')).toBe(3); 232 + }); 233 + 234 + it('COLUMN(AA1) returns 27', () => { 235 + expect(evalWith('COLUMN(AA1)')).toBe(27); 236 + }); 237 + }); 238 + 239 + describe('ROW and COLUMN — used with ADDRESS and INDIRECT', () => { 240 + it('ADDRESS(ROW(A3), COLUMN(B1)) => "$B$3"', () => { 241 + expect(evalWith('ADDRESS(ROW(A3), COLUMN(B1))')).toBe('$B$3'); 242 + }); 243 + 244 + it('INDIRECT(ADDRESS(ROW(A3), COLUMN(B1))) resolves B3', () => { 245 + expect(evalWith('INDIRECT(ADDRESS(ROW(A3), COLUMN(B1)))', { B3: 42 })).toBe(42); 246 + }); 247 + });