From 37f7c9eeea44449feb5f639314de5b5e1511090d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mindaugas=20Kasparavic=CC=8Cius?= Date: Mon, 3 Aug 2026 15:22:53 +0300 Subject: [PATCH] test(store): split diffStore's tests by concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One 2725-line file was the append point for every new store feature, and three merge conflicts in a row landed on its final lines — each one two branches APPENDING, never disagreeing. The conflicts carried no information and every resolution was a chance to drop a suite. Seven files by concern: image export (the largest at 838 lines), disk changes, streamed, paste, export/backup, diagram, snippets. The core file keeps loading, receiving and routing a comparison. Same 196 tests before and after, name for name. Worth stating how that was checked, because the obvious check lies: `git stash push -- tests` does not stash UNTRACKED files, so a before/after count with the new files present reads 536 vs 446 and looks like 90 lost tests. Comparing the `it(` names directly, and the original file against the split total, both give 196 = 196. Co-Authored-By: Claude Opus 5 --- .../renderer/stores/diffStore.diagram.test.js | 67 + tests/renderer/stores/diffStore.disk.test.js | 223 +++ .../renderer/stores/diffStore.export.test.js | 96 ++ tests/renderer/stores/diffStore.image.test.js | 838 ++++++++++ tests/renderer/stores/diffStore.paste.test.js | 103 ++ .../stores/diffStore.snippets.test.js | 81 + .../stores/diffStore.streamed.test.js | 148 ++ tests/renderer/stores/diffStore.test.js | 1477 +---------------- 8 files changed, 1558 insertions(+), 1475 deletions(-) create mode 100644 tests/renderer/stores/diffStore.diagram.test.js create mode 100644 tests/renderer/stores/diffStore.disk.test.js create mode 100644 tests/renderer/stores/diffStore.export.test.js create mode 100644 tests/renderer/stores/diffStore.image.test.js create mode 100644 tests/renderer/stores/diffStore.paste.test.js create mode 100644 tests/renderer/stores/diffStore.snippets.test.js create mode 100644 tests/renderer/stores/diffStore.streamed.test.js diff --git a/tests/renderer/stores/diffStore.diagram.test.js b/tests/renderer/stores/diffStore.diagram.test.js new file mode 100644 index 0000000..31b9665 --- /dev/null +++ b/tests/renderer/stores/diffStore.diagram.test.js @@ -0,0 +1,67 @@ +// The Diagram view: which comparisons offer it, and what it routes to. +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useDiffStore } from '../../../src/renderer/src/stores/diffStore' + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + window.api = {} +}) + +// The Diagram toggle reuses the Structure checkbox — a second control would be +// the repo's recurring "second bespoke copy". So the getters have to agree. +describe('diagram comparison', () => { + const mmd = (body) => `flowchart TD\n${body}\n` + const load = (diff, l, r) => { + diff.left = { path: '/a.mmd', name: 'a.mmd', content: l } + diff.right = { path: '/b.mmd', name: 'b.mmd', content: r } + diff.mode = 'files' + } + + it('offers the toggle only when both sides look like Mermaid', () => { + const diff = useDiffStore() + load(diff, mmd(' A --> B'), mmd(' A --> C')) + expect(diff.canCompareDiagram).toBe(true) + + load(diff, mmd(' A --> B'), 'just some text') + expect(diff.canCompareDiagram).toBe(false) + }) + + it('calls itself Diagram, not Structure', () => { + const diff = useDiffStore() + load(diff, mmd(' A --> B'), mmd(' A --> C')) + expect(diff.structureLabel).toBe('Diagram') + }) + + it('routes to the diagram viewer only with the toggle on', () => { + const diff = useDiffStore() + load(diff, mmd(' A --> B'), mmd(' A --> C')) + diff.semanticView = false + expect(diff.comparableKind).toBe('text') + diff.semanticView = true + expect(diff.comparableKind).toBe('diagram') + }) + + it('never offers it for a streamed comparison', () => { + const diff = useDiffStore() + load(diff, mmd(' A --> B'), mmd(' A --> C')) + diff.left = { ...diff.left, kind: 'streamed' } + expect(diff.canCompareDiagram).toBe(false) + }) +}) + +// Pasted text is a comparison like any other — comparePasted() fills left/right, +// so the Diagram toggle must be offered there too. +describe('diagram comparison from pasted text', () => { + it('offers the diagram view after comparing two pasted diagrams', () => { + const diff = useDiffStore() + diff.mode = 'paste' + diff.pasteLeft = 'flowchart TD\n A --> B' + diff.pasteRight = 'flowchart TD\n A --> C' + diff.comparePasted() + expect(diff.canCompareDiagram).toBe(true) + diff.semanticView = true + expect(diff.comparableKind).toBe('diagram') + }) +}) diff --git a/tests/renderer/stores/diffStore.disk.test.js b/tests/renderer/stores/diffStore.disk.test.js new file mode 100644 index 0000000..46b43fe --- /dev/null +++ b/tests/renderer/stores/diffStore.disk.test.js @@ -0,0 +1,223 @@ +// What happens when the files move under a live comparison. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { DISK_NOTICE_MS, useDiffStore } from '../../../src/renderer/src/stores/diffStore' + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + window.api = {} +}) + +const FILE = (name) => ({ path: `/tmp/${name}`, name, content: `content of ${name}` }) + +// "Saved" is what silences the discard prompts, so a comparison that no longer +// matches the vault copy must stop claiming to be it. +describe('staying honest about what is saved', () => { + const FILE_AT = (name, content) => ({ path: `/tmp/${name}`, name, content }) + + it('a file changing on disk makes the reloaded comparison unsaved again', async () => { + const store = useDiffStore() + store.left = FILE_AT('a.txt', 'before') + store.right = FILE_AT('b.txt', 'other') + store.markSaved() + window.api = { + readFile: async (path) => + path.endsWith('a.txt') + ? { path, name: 'a.txt', content: 'edited elsewhere' } + : { path, name: 'b.txt', content: 'other' } + } + + await store.refreshFromDisk() + expect(store.left.content).toBe('edited elsewhere') + expect(store.diffSaved).toBe(false) + }) + + it('leaves a diff alone when nothing on disk actually changed', async () => { + const store = useDiffStore() + store.left = FILE_AT('a.txt', 'same') + store.markSaved() + window.api = { readFile: async (path) => ({ path, name: 'a.txt', content: 'same' }) } + + await store.refreshFromDisk() + expect(store.diffSaved).toBe(true) + }) +}) + +// The change check compared `content`, which a spreadsheet has none of, so no +// workbook ever reloaded. +describe('following a spreadsheet on disk', () => { + const book = (v) => ({ + path: '/tmp/book.xlsx', + name: 'book.xlsx', + kind: 'spreadsheet', + sheets: [{ name: 'S1', rows: [['a', v]] }] + }) + + it('reloads a workbook whose grid changed, and says so', async () => { + const store = useDiffStore() + store.left = book(1) + store.markSaved() + window.api = { readFile: async () => book(2) } + + await store.refreshFromDisk() + expect(store.left.sheets[0].rows[0][1]).toBe(2) + expect(store.diskNotice).toContain('changed on disk') + expect(store.diffSaved).toBe(false) + }) + + it('leaves an untouched workbook alone', async () => { + const store = useDiffStore() + store.left = book(1) + store.markSaved() + window.api = { readFile: async () => book(1) } + + await store.refreshFromDisk() + expect(store.diskNotice).toBeNull() + expect(store.diffSaved).toBe(true) + }) +}) + +// A second save adds nothing but a duplicate row, so it is not offered. +describe('saving the same comparison twice', () => { + it('is not offered while the comparison on screen is already saved', () => { + const store = useDiffStore() + store.left = FILE('a.txt') + store.right = FILE('b.txt') + expect(store.hasUnsavedWork).toBe(true) + + store.markSaved() + expect(store.canSave).toBe(true) // there is still a comparison to share + expect(store.hasUnsavedWork).toBe(false) + + store.handleMenuAction('save') + expect(store.showSaveDialog).toBe(false) + }) + + it('is offered again the moment the comparison changes', () => { + const store = useDiffStore() + store.left = FILE('a.txt') + store.right = FILE('b.txt') + store.markSaved() + + store.swap() + expect(store.hasUnsavedWork).toBe(true) + }) + + it('is never offered for an empty comparison', () => { + expect(useDiffStore().hasUnsavedWork).toBe(false) + }) +}) + +// Format rewrites a side in memory, so the app's copy and the file diverge. The +// focus re-read saw a difference it had caused itself, threw the formatting +// away, and reported a disk change that never happened. +describe('when the app and the disk have both moved', () => { + const UGLY = '{"a":1}' + const onDisk = (content) => ({ path: '/tmp/a.json', name: 'a.json', content }) + + it('keeps a side the app reformatted, and does not claim the disk changed', async () => { + const store = useDiffStore() + store.left = onDisk(UGLY) + store.formatSide('left') + const formatted = store.left.content + expect(formatted).not.toBe(UGLY) + + window.api = { readFile: async () => onDisk(UGLY) } + await store.refreshFromDisk() + + expect(store.left.content).toBe(formatted) + expect(store.diskNotice).toBeNull() + }) + + it('holds the app’s copy when the file ALSO changed, and says which', async () => { + const store = useDiffStore() + store.left = onDisk(UGLY) + store.formatSide('left') + const formatted = store.left.content + + window.api = { readFile: async () => onDisk('{"a":2}') } + await store.refreshFromDisk() + + expect(store.left.content).toBe(formatted) + expect(store.diskNotice).toContain('a.json') + expect(store.diskNotice).toContain('changed on disk') + expect(store.diskNotice).toContain('kept') + }) + + it('follows the disk again once the side is reloaded from it', async () => { + const store = useDiffStore() + store.left = onDisk(UGLY) + store.formatSide('left') + + // Re-picking the file is the deliberate "take theirs". + store.receive('left', onDisk('{"a":2}')) + window.api = { readFile: async () => onDisk('{"a":3}') } + await store.refreshFromDisk() + + expect(store.left.content).toBe('{"a":3}') + expect(store.diskNotice).toContain('diff reloaded') + }) + + it('still reloads an untouched side while another is held back', async () => { + const store = useDiffStore() + store.left = onDisk(UGLY) + store.formatSide('left') + store.right = { path: '/tmp/b.json', name: 'b.json', content: 'old' } + + window.api = { + readFile: async (path) => + path.endsWith('a.json') ? onDisk('{"a":9}') : { ...store.right, content: 'new' } + } + await store.refreshFromDisk() + + expect(store.right.content).toBe('new') + expect(store.diskNotice).toContain('a.json') + expect(store.diskNotice).toContain('b.json') + }) +}) + +// A held, dismissible label — not a toast that clears itself out from under you. +describe('the file-changed label', () => { + const onDisk = (content) => ({ path: '/tmp/a.txt', name: 'a.txt', content }) + + it('goes up on a disk change and clears itself after its window', async () => { + vi.useFakeTimers() + try { + const store = useDiffStore() + store.left = onDisk('before') + window.api = { readFile: async () => onDisk('after') } + + await store.refreshFromDisk() + expect(store.diskNotice).toContain('a.txt') + + vi.advanceTimersByTime(DISK_NOTICE_MS - 1) + expect(store.diskNotice).not.toBeNull() + vi.advanceTimersByTime(1) + expect(store.diskNotice).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('outlives the ordinary toast, which would have cleared first', () => { + expect(DISK_NOTICE_MS).toBeGreaterThan(5000) + }) + + it('can be dismissed by hand, and stays dismissed', () => { + vi.useFakeTimers() + try { + const store = useDiffStore() + store.showDiskNotice('"a.txt" changed on disk — diff reloaded.') + store.dismissDiskNotice() + expect(store.diskNotice).toBeNull() + + // The timer it cancelled cannot come back and blank a later one. + store.showDiskNotice('second') + vi.advanceTimersByTime(DISK_NOTICE_MS - 1) + expect(store.diskNotice).toBe('second') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/tests/renderer/stores/diffStore.export.test.js b/tests/renderer/stores/diffStore.export.test.js new file mode 100644 index 0000000..a3b1889 --- /dev/null +++ b/tests/renderer/stores/diffStore.export.test.js @@ -0,0 +1,96 @@ +// Getting a comparison back out: patches, HTML, and the config backup bundle. +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useDiffStore } from '../../../src/renderer/src/stores/diffStore' +import { loadPersisted, savePersisted } from '../../../src/renderer/src/persist' + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + window.api = {} +}) + +describe('applyPatch', () => { + const PATCH = '--- original\n+++ changed\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n' + const pick = (base, patch) => async (side) => + side === 'base' + ? { path: '/tmp/config.js', name: 'config.js', content: base } + : { name: 'change.patch', content: patch } + + it('opens base ↔ patched from the chosen files', async () => { + const store = useDiffStore() + window.api.openFile = pick('a\nb\nc\n', PATCH) + await store.applyPatch() + expect(store.left).toEqual({ path: '/tmp/config.js', name: 'config.js', content: 'a\nb\nc\n' }) + expect(store.right).toEqual({ path: null, name: 'config.js (patched)', content: 'a\nB\nc\n' }) + expect(store.mode).toBe('files') + }) + + it('does nothing when the base pick is cancelled', async () => { + const store = useDiffStore() + window.api.openFile = async () => null + await store.applyPatch() + expect(store.left).toBeNull() + expect(store.right).toBeNull() + }) + + it('rejects a file that is not a unified diff without loading anything', async () => { + const store = useDiffStore() + window.api.openFile = pick('a\nb\nc\n', 'not a patch') + await store.applyPatch() + expect(store.left).toBeNull() + expect(store.right).toBeNull() + }) +}) + +describe('exportDiff', () => { + it('builds a self-contained HTML doc and hands it to the save IPC', async () => { + const store = useDiffStore() + store.left = { path: null, name: 'a.js', content: 'a\nb\n' } + store.right = { path: null, name: 'b.js', content: 'a\nB\n' } + let sent = null + window.api.exportDiffFile = async (payload) => { + sent = payload + return { ok: true, path: '/tmp/out.html' } + } + await store.exportDiff() + expect(sent.name).toBe('a.js-vs-b.js') + expect(sent.format).toBe('html') + expect(sent.text).toContain('') + expect(sent.text).toContain('a.js ↔ b.js') + }) + + it('does nothing (no IPC) when there is nothing to compare', async () => { + const store = useDiffStore() + let called = false + window.api.exportDiffFile = async () => { + called = true + return { ok: true } + } + await store.exportDiff() + expect(called).toBe(false) + }) +}) + +// The bundle carried `session` from the start; without this it was sealed into +// the archive and silently dropped on the way back. +describe('config backup — session round trip', () => { + it('collects the session into the bundle and writes it back on restore', async () => { + const diff = useDiffStore() + savePersisted('session', '{"tabs":["a"]}') + let sent = null + window.api.backupConfig = async (bundle) => { + sent = bundle + return { ok: true, path: '/tmp/x' } + } + await diff.runConfigBackup('passphrase-long-enough') + expect(sent.session).toBe('{"tabs":["a"]}') + + savePersisted('session', '{"tabs":["different"]}') + window.api.restoreConfig = async () => ({ ok: true, session: sent.session }) + await diff.runConfigRestore('passphrase-long-enough') + // Written to persistence, not applied live: replacing the comparisons the + // reader is looking at mid-restore is not what they asked for. + expect(loadPersisted('session')).toBe('{"tabs":["a"]}') + }) +}) diff --git a/tests/renderer/stores/diffStore.image.test.js b/tests/renderer/stores/diffStore.image.test.js new file mode 100644 index 0000000..4e102d1 --- /dev/null +++ b/tests/renderer/stores/diffStore.image.test.js @@ -0,0 +1,838 @@ +// Image export: the screenshot pipeline, its failure paths and the grid it +// photographs. Split out of diffStore.test.js — every feature was appending to +// the end of one 2700-line file, which made three merge conflicts that carried +// no information. +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useDiffStore } from '../../../src/renderer/src/stores/diffStore' +import { useVaultStore } from '../../../src/renderer/src/stores/vaultStore' +import { useSettingsStore } from '../../../src/renderer/src/stores/settingsStore' +import { useSnippetStore } from '../../../src/renderer/src/stores/snippetStore' +import { + elementScroller, + getDiffScroller, + setDiffScroller +} from '../../../src/renderer/src/utils/diffScroller' + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + window.api = {} +}) + +const FILE = (name) => ({ path: `/tmp/${name}`, name, content: `content of ${name}` }) + +describe('exportImage (saved diffs only)', () => { + // A .content box for captureRectOf to measure, plus a synchronous rAF so the + // "wait for Monaco" frames resolve without a real compositor. + function stageViewer() { + const el = document.createElement('div') + el.className = 'content' + el.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 640 }) + document.body.append(el) + window.requestAnimationFrame = (cb) => setTimeout(cb, 0) + return () => el.remove() + } + + async function savedDiff(payload, name = 'Nightly config') { + const vault = useVaultStore() + window.api.vaultEncrypt = async (plaintext) => ({ iv: 'iv', data: plaintext }) + window.api.vaultDecrypt = async (box) => box.data + return vault.save(name, null, payload) + } + + const CAPTURE = { dataUrl: 'data:image/png;base64,SHOT', width: 1800, height: 1280 } + + it('opens the saved diff, shoots the diff column, and previews the result', async () => { + const cleanup = stageViewer() + const store = useDiffStore() + const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) + let rect = null + let shot = null + window.api.captureDiffImage = async (r) => { + rect = r + // What is on screen AT THE SHOT is the saved diff; afterwards it is not. + shot = [store.left?.name, store.right?.name] + return CAPTURE + } + + await store.exportImage(id) + + expect(shot).toEqual(['a.txt', 'b.txt']) + expect(rect).toEqual({ x: 260, y: 88, width: 900, height: 640 }) + expect(store.imageEntry).toMatchObject({ id, name: 'Nightly config', ...CAPTURE }) + expect(store.imageCapturing).toBe(false) + cleanup() + }) + + it('keeps the app out of its own screenshot while the shutter is open', async () => { + const cleanup = stageViewer() + const store = useDiffStore() + const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) + let capturingDuringShot = null + window.api.captureDiffImage = async () => { + capturingDuringShot = store.imageCapturing + return CAPTURE + } + await store.exportImage(id) + // App.vue hides the toast and the shortcut bar off this flag — they float + // inside the captured region, so they must be gone when the shot is taken. + expect(capturingDuringShot).toBe(true) + expect(store.imageCapturing).toBe(false) + cleanup() + }) + + it('waits for frames to pass before capturing, so Monaco has repainted', async () => { + const cleanup = stageViewer() + const store = useDiffStore() + const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) + let framesBeforeShot = 0 + let frames = 0 + window.requestAnimationFrame = (cb) => { + frames++ + setTimeout(cb, 0) + } + window.api.captureDiffImage = async () => ((framesBeforeShot = frames), CAPTURE) + await store.exportImage(id) + expect(framesBeforeShot).toBeGreaterThan(1) + cleanup() + }) + + // The reported bug: the picture showed the two files with no highlights at + // all, so a real difference looked like no difference. Monaco computes the + // diff in a worker and paints its decorations only when that returns, which + // is long after the handful of frames the shutter used to count. + it('waits for Monaco to finish diffing before shooting, not just for frames', async () => { + const cleanup = stageViewer() + const store = useDiffStore() + const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) + let frames = 0 + window.requestAnimationFrame = (cb) => { + frames++ + // DiffViewer bumps this from onDidUpdateDiff; here the worker is slow. + if (frames === 20) store.diffRevision++ + setTimeout(cb, 0) + } + let revisionAtShot = null + window.api.captureDiffImage = async () => ((revisionAtShot = store.diffRevision), CAPTURE) + + await store.exportImage(id) + + expect(revisionAtShot).toBe(1) + cleanup() + }) + + // A diff taller than its pane cannot be photographed in one shot, so the + // export scrolls Monaco and main joins the strips. Without this the picture + // stopped at the bottom of the visible pane. + describe('a diff taller than the pane', () => { + // .content at y=88 h=640, with Monaco starting at y=140 — so 588px of pane + // under a 52px header. + function stageTallViewer({ contentHeight }) { + const pane = document.createElement('div') + pane.className = 'diff-container' + pane.getBoundingClientRect = () => ({ top: 140, height: 588 }) + const el = document.createElement('div') + el.className = 'content' + el.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 640 }) + el.append(pane) + document.body.append(el) + window.requestAnimationFrame = (cb) => setTimeout(cb, 0) + let scrollTop = 0 + setDiffScroller({ + contentHeight: () => contentHeight, + viewportHeight: () => 588, + scrollTop: () => scrollTop, + scrollTo: (top) => (scrollTop = top) + }) + return () => { + el.remove() + setDiffScroller(null) + } + } + + it('scrolls through the diff and stitches the strips into one picture', async () => { + const cleanup = stageTallViewer({ contentHeight: 1400 }) + const store = useDiffStore() + const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) + const appended = [] + window.api.captureDiffImage = async () => { + throw new Error('a tall diff must not be shot in one frame') + } + window.api.appendDiffImageSlice = async (rect, reset) => { + appended.push({ rect, reset, scrolledTo: getDiffScroller().scrollTop() }) + return { ok: true } + } + window.api.stitchDiffImage = async () => CAPTURE + + await store.exportImage(id) + + // 1400px of diff over a 588px pane: two full viewports, then a 224px tail + // shot at the scroll clamp (1400 - 588 = 812) and cropped to its bottom. + expect(appended.map((a) => a.scrolledTo)).toEqual([0, 588, 812]) + expect(appended.map((a) => a.reset)).toEqual([true, false, false]) + // The header rides on the first strip only, never repeated. + expect(appended[0].rect).toEqual({ x: 260, y: 88, width: 900, height: 52 + 588 }) + expect(appended[1].rect).toEqual({ x: 260, y: 140, width: 900, height: 588 }) + expect(appended[2].rect).toEqual({ x: 260, y: 140 + 364, width: 900, height: 224 }) + expect(store.imageEntry).toMatchObject({ id, ...CAPTURE, truncated: false }) + cleanup() + }) + + it('puts the reader back where they were when the shutter closes', async () => { + const cleanup = stageTallViewer({ contentHeight: 1400 }) + const store = useDiffStore() + const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) + getDiffScroller().scrollTo(300) + window.api.appendDiffImageSlice = async () => ({ ok: true }) + window.api.stitchDiffImage = async () => CAPTURE + await store.exportImage(id) + expect(getDiffScroller().scrollTop()).toBe(300) + cleanup() + }) + + it('stops slicing at the configured ceiling and admits the picture is cut short', async () => { + const cleanup = stageTallViewer({ contentHeight: 200_000 }) + const store = useDiffStore() + const settings = useSettingsStore() + settings.setMaxExportHeightPx(2940) // five 588px viewports + const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) + let covered = 0 + window.api.appendDiffImageSlice = async (rect, reset) => { + covered += reset ? rect.height - 52 : rect.height // the header rides slice one + return { ok: true } + } + window.api.stitchDiffImage = async () => CAPTURE + await store.exportImage(id) + expect(covered).toBe(2940) + expect(store.imageEntry.truncated).toBe(true) + cleanup() + }) + + // The ceiling is in screen pixels, so the same diff exports the same amount + // whatever the display scale — expressing it in device pixels made a Retina + // machine capture half as much as a 1× one from identical settings. + it('covers the same amount of diff whatever the display scale', async () => { + const covered = async (dpr) => { + setActivePinia(createPinia()) + const cleanup = stageTallViewer({ contentHeight: 200_000 }) + window.devicePixelRatio = dpr + const store = useDiffStore() + useSettingsStore().setMaxExportHeightPx(2940) + window.api.vaultEncrypt = async (plaintext) => ({ iv: 'iv', data: plaintext }) + window.api.vaultDecrypt = async (box) => box.data + const id = await useVaultStore().save('t', null, { + mode: 'files', + left: FILE('a.txt'), + right: FILE('b.txt') + }) + let total = 0 + window.api.appendDiffImageSlice = async (rect, reset) => { + total += reset ? rect.height - 52 : rect.height + return { ok: true } + } + window.api.stitchDiffImage = async () => CAPTURE + await store.exportImage(id) + cleanup() + return total + } + expect(await covered(1)).toBe(await covered(2)) + }) + + it('gives up on a refused strip instead of stitching a partial picture', async () => { + const cleanup = stageTallViewer({ contentHeight: 1400 }) + const store = useDiffStore() + const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) + let stitched = false + window.api.appendDiffImageSlice = async (_r, reset) => + reset ? { ok: true } : { error: 'bad-rect' } + window.api.stitchDiffImage = async () => ((stitched = true), CAPTURE) + await store.exportImage(id) + expect(stitched).toBe(false) + expect(store.imageEntry).toBeNull() + expect(store.notice).toContain('Could not take a picture') + cleanup() + }) + }) + + // Exporting what's on screen, with lines selected in either pane narrowing the + // picture to just those. + describe('exportCurrentImage', () => { + function stageSelectable({ contentHeight, selection }) { + const pane = document.createElement('div') + pane.className = 'diff-container' + pane.getBoundingClientRect = () => ({ top: 140, height: 588 }) + const el = document.createElement('div') + el.className = 'content' + el.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 640 }) + el.append(pane) + document.body.append(el) + window.requestAnimationFrame = (cb) => setTimeout(cb, 0) + let scrollTop = 0 + setDiffScroller({ + contentHeight: () => contentHeight, + viewportHeight: () => 588, + scrollTop: () => scrollTop, + scrollTo: (top) => (scrollTop = top), + selection: () => selection + }) + return () => { + el.remove() + setDiffScroller(null) + } + } + + const loaded = (store) => { + store.left = FILE('a.txt') + store.right = FILE('b.txt') + store.mode = 'files' + } + + it('captures only the selected band, not the whole diff', async () => { + const cleanup = stageSelectable({ + contentHeight: 4000, + selection: { top: 1000, bottom: 1300 } + }) + const store = useDiffStore() + loaded(store) + const rects = [] + window.api.appendDiffImageSlice = async (rect, reset) => ( + rects.push({ rect, reset }), + { ok: true } + ) + window.api.stitchDiffImage = async () => CAPTURE + window.api.captureDiffImage = async () => { + throw new Error('a selection must not fall back to the whole-column shot') + } + + await store.exportCurrentImage() + + expect(rects).toHaveLength(1) + // Scrolled to the top of the selection; the header rides above it. + expect(rects[0]).toEqual({ + rect: { x: 260, y: 88, width: 900, height: 52 + 300 }, + reset: true + }) + expect(store.imageEntry).toMatchObject({ id: null, name: 'a.txt ↔ b.txt' }) + cleanup() + }) + + it('reaches a selection at the very end through Monaco’s scroll clamp', async () => { + const cleanup = stageSelectable({ + contentHeight: 4000, + selection: { top: 3800, bottom: 4000 } + }) + const store = useDiffStore() + loaded(store) + const rects = [] + window.api.appendDiffImageSlice = async (rect) => (rects.push(rect), { ok: true }) + window.api.stitchDiffImage = async () => CAPTURE + await store.exportCurrentImage() + // 4000 - 588 = 3412 is as far as it scrolls, so the band sits 388px down. + expect(getDiffScroller().scrollTop()).toBe(0) // and it is put back after + expect(rects[0].height).toBe(52 + 200) + cleanup() + }) + + it('captures the whole diff when no lines are selected', async () => { + const cleanup = stageSelectable({ contentHeight: 300, selection: null }) + const store = useDiffStore() + loaded(store) + let rect = null + window.api.captureDiffImage = async (r) => ((rect = r), CAPTURE) + await store.exportCurrentImage() + expect(rect).toEqual({ x: 260, y: 88, width: 900, height: 640 }) + expect(store.imageEntry).toMatchObject({ id: null }) + cleanup() + }) + + it('refuses when there is no comparison on screen', async () => { + const cleanup = stageSelectable({ contentHeight: 300, selection: null }) + const store = useDiffStore() + store.mode = 'paste' + let called = false + window.api.captureDiffImage = async () => ((called = true), CAPTURE) + await store.exportCurrentImage() + expect(called).toBe(false) + expect(store.imageEntry).toBeNull() + expect(store.notice).toContain('Nothing to export') + cleanup() + }) + }) + + it('exports nothing for an id that is not a saved diff', async () => { + const cleanup = stageViewer() + const store = useDiffStore() + // The live comparison is deliberately NOT a source: saved diffs only. + store.left = FILE('onscreen.txt') + store.right = FILE('other.txt') + let called = false + window.api.captureDiffImage = async () => ((called = true), CAPTURE) + await store.exportImage('no-such-id') + expect(store.imageEntry).toBeNull() + expect(called).toBe(false) + cleanup() + }) + + it('shoots the SAVED entry, never whatever was already on screen', async () => { + const cleanup = stageViewer() + const store = useDiffStore() + const id = await savedDiff({ + mode: 'files', + left: FILE('saved-l.txt'), + right: FILE('saved-r.txt') + }) + store.left = FILE('onscreen-l.txt') + store.right = FILE('onscreen-r.txt') + store.diffSaved = false + let shot = null + window.api.captureDiffImage = async () => { + shot = [store.left?.name, store.right?.name] + return CAPTURE + } + await store.exportImage(id) + expect(shot).toEqual(['saved-l.txt', 'saved-r.txt']) + // ...and the comparison the user was working on is handed straight back. + expect(store.left).toMatchObject({ name: 'onscreen-l.txt' }) + expect(store.right).toMatchObject({ name: 'onscreen-r.txt' }) + expect(store.diffSaved).toBe(false) + cleanup() + }) + + it('reports an entry that no longer decrypts and photographs nothing', async () => { + const cleanup = stageViewer() + const store = useDiffStore() + const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) + window.api.vaultDecrypt = async () => null + let called = false + window.api.captureDiffImage = async () => ((called = true), CAPTURE) + await store.exportImage(id) + expect(called).toBe(false) + expect(store.imageEntry).toBeNull() + expect(store.notice).toContain('expired or could not be decrypted') + cleanup() + }) + + it('reports a refused capture instead of opening an empty preview', async () => { + const cleanup = stageViewer() + const store = useDiffStore() + const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) + window.api.captureDiffImage = async () => ({ error: 'bad-rect' }) + await store.exportImage(id) + expect(store.imageEntry).toBeNull() + expect(store.imageCapturing).toBe(false) + expect(store.notice).toContain('Could not take a picture') + cleanup() + }) + + it('does not call main when there is no diff column to measure', async () => { + const store = useDiffStore() + window.requestAnimationFrame = (cb) => setTimeout(cb, 0) + const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) + let called = false + window.api.captureDiffImage = async () => ((called = true), CAPTURE) + await store.exportImage(id) // no .content element staged + expect(called).toBe(false) + expect(store.imageEntry).toBeNull() + expect(store.notice).toContain('Could not take a picture') + }) + + it('copyImage asks main for the capture it is holding, and acknowledges', async () => { + const store = useDiffStore() + let called = 0 + window.api.copyDiffImage = async (...args) => { + called++ + // No image bytes travel back to main — it still has the bitmap. + expect(args).toHaveLength(0) + return { ok: true } + } + expect(await store.copyImage()).toBe(true) + expect(called).toBe(1) + expect(store.notice).toContain('copied to clipboard') + }) + + it('copyImage reports a refusal rather than claiming success', async () => { + const store = useDiffStore() + window.api.copyDiffImage = async () => ({ ok: false, error: 'nothing-captured' }) + expect(await store.copyImage()).toBe(false) + expect(store.notice).toContain('Could not copy') + }) + + it('saveImage names the file after the saved diff and says where it landed', async () => { + const store = useDiffStore() + store.imageEntry = { id: 'x', name: 'Nightly config' } + let sentName = null + window.api.saveDiffImage = async (name) => { + sentName = name + return { ok: true, path: '/tmp/Nightly config.png' } + } + await store.saveImage() + expect(sentName).toBe('Nightly config') + expect(store.notice).toContain('/tmp/Nightly config.png') + }) + + it('saveImage stays quiet when the save dialog was cancelled', async () => { + const store = useDiffStore() + window.api.saveDiffImage = async () => ({ canceled: true }) + await store.saveImage() + expect(store.notice).toBeNull() + }) + + it('saveImage surfaces a failed write', async () => { + const store = useDiffStore() + window.api.saveDiffImage = async () => ({ ok: false, error: 'nothing-captured' }) + await store.saveImage() + expect(store.notice).toContain('Could not save') + }) + + it('closing the preview tells main to drop the bitmap it was holding', async () => { + const store = useDiffStore() + let forgotten = false + window.api.forgetDiffImage = async () => ((forgotten = true), { ok: true }) + store.imageEntry = { id: 'x', name: 'n', dataUrl: 'data:image/png;base64,SHOT' } + store.closeImageExport() + expect(store.imageEntry).toBeNull() + expect(forgotten).toBe(true) + }) +}) + +describe('exportImage failure handling', () => { + it('never leaves the app chrome hidden when the capture throws', async () => { + const store = useDiffStore() + const vault = useVaultStore() + window.api.vaultEncrypt = async (plaintext) => ({ iv: 'iv', data: plaintext }) + window.api.vaultDecrypt = async (box) => box.data + window.requestAnimationFrame = (cb) => setTimeout(cb, 0) + const el = document.createElement('div') + el.className = 'content' + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 900, height: 640 }) + document.body.append(el) + const id = await vault.save('boom', null, { + mode: 'files', + left: FILE('a.txt'), + right: FILE('b.txt') + }) + window.api.captureDiffImage = async () => { + throw new Error('IPC exploded') + } + + await store.exportImage(id) + + // A stuck flag would hide the shortcut bar for the rest of the session. + expect(store.imageCapturing).toBe(false) + expect(store.imageEntry).toBeNull() + expect(store.notice).toContain('Could not take a picture') + el.remove() + }) +}) + +// A snippet is photographed by putting it on a stage over the diff column and +// firing the SAME shutter. The stage is a component, so these tests drive its +// "I'm painted" signal by hand — what they pin is the store's half: the subject +// staged, the wait before the shot, and the stage always coming down. +describe('exportSnippetImage', () => { + const CAPTURE = { dataUrl: 'data:image/png;base64,SHOT', width: 1200, height: 700 } + + function stageColumn() { + const el = document.createElement('div') + el.className = 'content' + el.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 640 }) + document.body.append(el) + return () => el.remove() + } + + // Paint the stage a few frames in, the way the real component does once its + // highlighting settles or Mermaid returns. + function paintAfter(store, frames, mark = 'ready') { + let seen = 0 + window.requestAnimationFrame = (cb) => { + seen++ + if (seen === frames && store.snippetShot) store.snippetShot[mark] = true + setTimeout(cb, 0) + } + return () => seen + } + + async function addSnippet(over = {}) { + const snippets = useSnippetStore() + window.api.vaultEncrypt = async (plaintext) => ({ iv: 'iv', data: plaintext }) + window.api.vaultDecrypt = async (box) => box.data + return snippets.add({ + name: 'Deploy steps', + content: '{ "a": 1 }', + language: 'json', + ...over + }) + } + + it('stages the snippet, shoots it, and previews it', async () => { + const cleanup = stageColumn() + const store = useDiffStore() + const id = await addSnippet() + paintAfter(store, 2) + let staged = null + window.api.captureDiffImage = async () => { + staged = store.snippetShot && { ...store.snippetShot } + return CAPTURE + } + + await store.exportSnippetImage(id) + + // The snippet really was on screen when the shutter opened... + expect(staged).toMatchObject({ name: 'Deploy steps', lang: 'json', code: '{ "a": 1 }' }) + expect(store.imageEntry).toMatchObject({ id, name: 'Deploy steps', subject: 'snippet' }) + // ...and the column is the user's again afterwards. + expect(store.snippetShot).toBeNull() + expect(store.imageCapturing).toBe(false) + cleanup() + }) + + it('names a diagram as a diagram, since that is what was photographed', async () => { + const cleanup = stageColumn() + const store = useDiffStore() + const id = await addSnippet({ + name: 'Flow', + content: 'flowchart TD\n A-->B', + language: 'mermaid' + }) + paintAfter(store, 2) + window.api.captureDiffImage = async () => CAPTURE + + await store.exportSnippetImage(id) + + expect(store.imageEntry).toMatchObject({ name: 'Flow', subject: 'diagram' }) + cleanup() + }) + + // Counting frames is what once photographed a diff with no highlights at all. + // Mermaid renders behind a 2.8 MB dynamic import and a cold grammar tokenizes + // untyped, so the stage says when it is painted and the shutter waits. + it('does not shoot until the stage says it is painted', async () => { + const cleanup = stageColumn() + const store = useDiffStore() + const id = await addSnippet() + const frames = paintAfter(store, 30) + let framesAtShot = null + window.api.captureDiffImage = async () => ((framesAtShot = frames()), CAPTURE) + + await store.exportSnippetImage(id) + + expect(framesAtShot).toBeGreaterThanOrEqual(30) + expect(store.imageEntry).toMatchObject({ subject: 'snippet' }) + cleanup() + }) + + it('takes no picture of a diagram that would not render', async () => { + const cleanup = stageColumn() + const store = useDiffStore() + const id = await addSnippet({ name: 'Broken', content: 'flowchart ???', language: 'mermaid' }) + paintAfter(store, 2, 'failed') + let shots = 0 + window.api.captureDiffImage = async () => (shots++, CAPTURE) + + await store.exportSnippetImage(id) + + expect(shots).toBe(0) + expect(store.imageEntry).toBeNull() + expect(store.notice).toContain('could not be rendered') + expect(store.snippetShot).toBeNull() + cleanup() + }) + + // A photograph of a masked secret is either useless or a leak, so it is + // refused before anything decrypts it. + it('refuses a secret snippet without decrypting it', async () => { + const cleanup = stageColumn() + const store = useDiffStore() + const id = await addSnippet({ name: 'API key', content: 'sk-live-xyz', secret: true }) + let decrypts = 0 + const decrypt = window.api.vaultDecrypt + window.api.vaultDecrypt = async (box) => (decrypts++, decrypt(box)) + let shots = 0 + window.api.captureDiffImage = async () => (shots++, CAPTURE) + + await store.exportSnippetImage(id) + + expect(decrypts).toBe(0) + expect(shots).toBe(0) + expect(store.imageEntry).toBeNull() + expect(store.snippetShot).toBeNull() + expect(store.notice).toContain('Hidden') + cleanup() + }) + + it('takes the stage down even when the capture fails', async () => { + const cleanup = stageColumn() + const store = useDiffStore() + const id = await addSnippet() + paintAfter(store, 2) + window.api.captureDiffImage = async () => { + throw new Error('IPC exploded') + } + + await store.exportSnippetImage(id) + + expect(store.snippetShot).toBeNull() + expect(store.imageCapturing).toBe(false) + expect(store.imageEntry).toBeNull() + expect(store.notice).toContain('Could not take a picture') + cleanup() + }) +}) + +// The grid scrolls inside itself with no scroller to drive, so the shutter could +// only ever catch the visible slice. Refused outright rather than truncated. +describe('image export and the spreadsheet grid', () => { + const SHOT = { dataUrl: 'data:image/png;base64,GRID', width: 900, height: 1800 } + // jsdom's scroll metrics are read-only getters that always answer 0. + const sizeOf = (el, dims) => { + for (const [k, value] of Object.entries(dims)) Object.defineProperty(el, k, { value }) + } + const grid = (name) => ({ + path: `/tmp/${name}`, + name, + kind: 'spreadsheet', + sheets: [{ name: 'S1', rows: [['a', 1]] }] + }) + + // The grid scrolls inside itself with no Monaco behind it. It registers its + // own scroller, which is all the export needs — scroll a viewport at a time + // and stitch, exactly as for a tall diff. + it('is offered for a spreadsheet comparison', () => { + const store = useDiffStore() + store.left = grid('a.xlsx') + store.right = grid('b.xlsx') + expect(store.isSpreadsheet).toBe(true) + expect(store.canExportImage).toBe(true) + }) + + it('is still offered for a text comparison', () => { + const store = useDiffStore() + store.left = FILE('a.txt') + store.right = FILE('b.txt') + expect(store.isSpreadsheet).toBe(false) + expect(store.canExportImage).toBe(true) + }) + + it('scrolls and stitches the grid the way it does a tall diff', async () => { + const store = useDiffStore() + store.left = grid('a.xlsx') + store.right = grid('b.xlsx') + + const grids = document.createElement('div') + grids.getBoundingClientRect = () => ({ top: 140, height: 600 }) + sizeOf(grids, { scrollHeight: 1800, clientHeight: 600, scrollWidth: 900, clientWidth: 900 }) + const column = document.createElement('div') + column.className = 'content' + column.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 700 }) + column.append(grids) + document.body.append(column) + window.requestAnimationFrame = (cb) => setTimeout(cb, 0) + setDiffScroller(elementScroller(() => grids)) + + const tops = [] + window.api.appendDiffImageSlice = async (rect) => { + tops.push(rect.y) + return { ok: true } + } + window.api.stitchDiffImage = async () => SHOT + + await store.exportCurrentImage() + + expect(tops).toHaveLength(3) + expect(store.imageEntry).toMatchObject({ ...SHOT, hiddenColumns: 0 }) + column.remove() + setDiffScroller(null) + }) + + // A grid wider than the window loses its right-hand columns to a picture that + // only scrolls down. The dialog is told, rather than handing over a crop that + // looks complete. + it('reports the columns a picture cannot reach', async () => { + const store = useDiffStore() + store.left = grid('a.xlsx') + store.right = grid('b.xlsx') + + const grids = document.createElement('div') + grids.getBoundingClientRect = () => ({ top: 140, height: 600 }) + sizeOf(grids, { scrollHeight: 600, clientHeight: 600, scrollWidth: 2700, clientWidth: 900 }) + const column = document.createElement('div') + column.className = 'content' + column.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 700 }) + column.append(grids) + document.body.append(column) + window.requestAnimationFrame = (cb) => setTimeout(cb, 0) + setDiffScroller(elementScroller(() => grids)) + window.api.captureDiffImage = async () => SHOT + + await store.exportCurrentImage() + + expect(store.imageEntry?.hiddenColumns).toBe(2) + column.remove() + setDiffScroller(null) + }) +}) + +// Exporting a picture of a SAVED diff must not take the live document hostage: +// it used to replace it, mark it saved (so no discard prompt could fire), and +// leave the tab claiming to hold a comparison it no longer had. +describe('exportImage', () => { + const seedEntry = async (store) => { + const vault = useVaultStore() + vault.entries = [{ id: 'e1', name: 'saved one' }] + vault.load = async () => ({ + mode: 'files', + left: FILE('old-left.txt'), + right: FILE('old-right.txt') + }) + store._shoot = async () => ({ dataUrl: 'data:image/png;base64,zzz' }) + return vault + } + + it('leaves unsaved work on screen exactly as it was', async () => { + const store = useDiffStore() + await seedEntry(store) + store.mode = 'paste' + store.pasteLeft = 'work in progress' + store.pasteRight = 'other side' + store.diffSaved = false + + await store.exportImage('e1') + + expect(store.mode).toBe('paste') + expect(store.pasteLeft).toBe('work in progress') + expect(store.pasteRight).toBe('other side') + expect(store.diffSaved).toBe(false) + expect(store.imageEntry).toMatchObject({ id: 'e1', name: 'saved one' }) + }) + + it('restores a loaded file comparison, not just paste text', async () => { + const store = useDiffStore() + await seedEntry(store) + store.left = FILE('live-left.txt') + store.right = FILE('live-right.txt') + store.diffSaved = false + + await store.exportImage('e1') + + expect(store.left.name).toBe('live-left.txt') + expect(store.right.name).toBe('live-right.txt') + expect(store.diffSaved).toBe(false) + }) + + it('puts the live document back even when the shot fails', async () => { + const store = useDiffStore() + await seedEntry(store) + store._shoot = async () => ({ error: 'capture-failed' }) + store.mode = 'paste' + store.pasteLeft = 'work in progress' + store.diffSaved = false + + await store.exportImage('e1') + + expect(store.pasteLeft).toBe('work in progress') + expect(store.diffSaved).toBe(false) + expect(store.imageEntry).toBeNull() + expect(store.notice).toBeTruthy() + }) +}) diff --git a/tests/renderer/stores/diffStore.paste.test.js b/tests/renderer/stores/diffStore.paste.test.js new file mode 100644 index 0000000..fec2ebd --- /dev/null +++ b/tests/renderer/stores/diffStore.paste.test.js @@ -0,0 +1,103 @@ +// Paste mode and copied files. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useDiffStore } from '../../../src/renderer/src/stores/diffStore' + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + window.api = {} +}) + +const FILE = (name) => ({ path: `/tmp/${name}`, name, content: `content of ${name}` }) + +// Copied files land exactly like dropped ones, confirm included. +describe('pasting copied files', () => { + const AT = (name) => ({ path: `/tmp/${name}`, name, content: `content of ${name}` }) + + it('asks before it replaces a complete, unsaved comparison', async () => { + const store = useDiffStore() + store.left = AT('old-left.txt') + store.right = AT('old-right.txt') + window.api = { + readClipboardFiles: async () => [AT('new-left.txt'), AT('new-right.txt')], + readFile: async (path) => AT(path.split('/').pop()) + } + + await store.requestPasteFromClipboard() + // What matters is that it WAITS, holding both incoming files, and has not + // touched the comparison on screen — not how the pending pair is carried. + expect(store.pendingReplace).toHaveLength(2) + expect(store.pendingReplace.map((f) => f.name)).toEqual(['new-left.txt', 'new-right.txt']) + expect(store.left.name).toBe('old-left.txt') + + await store.confirmReplace() + expect(store.left.name).toBe('new-left.txt') + expect(store.right.name).toBe('new-right.txt') + }) + + it('replaces a SAVED comparison without asking, like a drop does', async () => { + const store = useDiffStore() + store.left = AT('old-left.txt') + store.right = AT('old-right.txt') + store.markSaved() + window.api = { + readClipboardFiles: async () => [AT('new-left.txt'), AT('new-right.txt')], + readFile: async (path) => AT(path.split('/').pop()) + } + + await store.requestPasteFromClipboard() + expect(store.pendingReplace).toBeNull() + expect(store.left.name).toBe('new-left.txt') + }) + + it('still fills the free side straight away when nothing would be lost', async () => { + const store = useDiffStore() + store.left = AT('kept.txt') + window.api = { + readClipboardFiles: async () => [AT('second.txt')], + readFile: async (path) => AT(path.split('/').pop()) + } + + await store.requestPasteFromClipboard() + expect(store.pendingReplace).toBeNull() + expect(store.left.name).toBe('kept.txt') + expect(store.right.name).toBe('second.txt') + }) +}) + +// clipboard:readFiles already reads each file through the same path file:read +// uses, so re-reading by path put the "Large file — load anyway?" prompt in +// front of the user twice for one paste. +describe('pasteClipboardFiles', () => { + it('uses the file objects it was handed instead of reading them again', async () => { + const store = useDiffStore() + const readFile = vi.fn(async (path) => ({ path, name: path.split('/').pop(), content: 'x' })) + window.api = { + readFile, + readClipboardFiles: async () => [FILE('a.txt'), FILE('b.txt')] + } + + expect(await store.pasteClipboardFiles()).toBe(true) + expect(readFile).not.toHaveBeenCalled() + expect(store.left.name).toBe('a.txt') + expect(store.right.name).toBe('b.txt') + }) + + it('is false, and touches nothing, when the clipboard holds no files', async () => { + const store = useDiffStore() + window.api = { readClipboardFiles: async () => [] } + expect(await store.pasteClipboardFiles()).toBe(false) + expect(store.left).toBeNull() + }) + + it('drops entries the main process refused to read', async () => { + const store = useDiffStore() + window.api = { + readClipboardFiles: async () => [null, FILE('only.txt')] + } + expect(await store.pasteClipboardFiles()).toBe(true) + expect(store.left.name).toBe('only.txt') + expect(store.right).toBeNull() + }) +}) diff --git a/tests/renderer/stores/diffStore.snippets.test.js b/tests/renderer/stores/diffStore.snippets.test.js new file mode 100644 index 0000000..c976cca --- /dev/null +++ b/tests/renderer/stores/diffStore.snippets.test.js @@ -0,0 +1,81 @@ +// Snippets dropped into the diff pane. +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useDiffStore } from '../../../src/renderer/src/stores/diffStore' +import { useSnippetStore } from '../../../src/renderer/src/stores/snippetStore' + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + window.api = {} +}) + +// Dropped snippets route through the SAME dropFiles the file path uses, so the +// replace guard, one-then-wait and two-fill-both come free rather than being +// re-implemented where they could drift. +describe('dropSnippets', () => { + beforeEach(() => { + const store = new Map() + window.api.vaultEncrypt = async (plaintext) => { + const iv = String(store.size) + store.set(iv, plaintext) + return { iv, data: iv } + } + window.api.vaultDecrypt = async ({ iv }) => store.get(iv) ?? null + }) + + const seed = async (over = {}) => { + const snippets = useSnippetStore() + return snippets.add({ name: 'Deploy config', content: '{"a":1}', language: 'json', ...over }) + } + + it('fills the left side from one snippet and waits for the right', async () => { + const diff = useDiffStore() + const id = await seed() + await diff.dropSnippets([id]) + expect(diff.left).toMatchObject({ path: null, name: 'Deploy config', snippetId: id }) + expect(diff.right).toBeNull() + }) + + it('fills both sides from two', async () => { + const diff = useDiffStore() + const a = await seed({ name: 'One' }) + const b = await seed({ name: 'Two', content: '{"a":2}' }) + await diff.dropSnippets([a, b]) + expect(diff.left.name).toBe('One') + expect(diff.right.name).toBe('Two') + }) + + it('drops onto the side the row was released over', async () => { + const diff = useDiffStore() + const id = await seed({ name: 'Righty' }) + await diff.dropSnippets([id], 'right') + expect(diff.right.name).toBe('Righty') + expect(diff.left).toBeNull() + }) + + it('refuses a secret snippet and says so', async () => { + const diff = useDiffStore() + const id = await seed({ name: 'Prod API key', secret: true }) + await diff.dropSnippets([id]) + expect(diff.left).toBeNull() + expect(diff.notice).toMatch(/hidden/i) + }) + + it('ignores an id that is not in the library', async () => { + const diff = useDiffStore() + await diff.dropSnippets(['no-such-id']) + expect(diff.left).toBeNull() + }) + + // The comparison is a copy; nothing here may write back to the library. + it('leaves the snippet itself untouched', async () => { + const diff = useDiffStore() + const snippets = useSnippetStore() + const id = await seed() + const before = JSON.stringify(snippets.entries) + await diff.dropSnippets([id]) + diff.clear() + expect(JSON.stringify(snippets.entries)).toBe(before) + }) +}) diff --git a/tests/renderer/stores/diffStore.streamed.test.js b/tests/renderer/stores/diffStore.streamed.test.js new file mode 100644 index 0000000..ea144b1 --- /dev/null +++ b/tests/renderer/stores/diffStore.streamed.test.js @@ -0,0 +1,148 @@ +// Comparisons too large to hold in memory. +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useDiffStore } from '../../../src/renderer/src/stores/diffStore' + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + window.api = {} +}) + +const FILE = (name) => ({ path: `/tmp/${name}`, name, content: `content of ${name}` }) + +const BIG = (name) => ({ path: `/big/${name}`, name, size: 60 * 1024 * 1024, kind: 'streamed' }) + +function loadStreamed(store) { + store.receive('left', BIG('left.log')) + store.receive('right', BIG('right.log')) + return store +} + +describe('diffStore — streamed comparisons', () => { + it('accepts a streamed descriptor into a slot', () => { + const store = loadStreamed(useDiffStore()) + expect(store.ready).toBe(true) + expect(store.notice).toBeNull() + expect(store.leftComparable).toEqual({ + kind: 'streamed', + path: '/big/left.log', + name: 'left.log', + size: 60 * 1024 * 1024 + }) + }) + + it('routes to the streamed viewer', () => { + expect(loadStreamed(useDiffStore()).comparableKind).toBe('streamed') + expect(loadStreamed(useDiffStore()).isStreamed).toBe(true) + }) + + // One side too large makes the WHOLE comparison streamed — there is no text + // for the other side to be diffed against in an editor model. + it('is streamed when only the RIGHT side is too large', () => { + const store = useDiffStore() + store.receive('left', FILE('small.txt')) + store.receive('right', BIG('huge.log')) + expect(store.comparableKind).toBe('streamed') + }) + + it('is streamed when only the LEFT side is too large', () => { + const store = useDiffStore() + store.receive('left', BIG('huge.log')) + store.receive('right', FILE('small.txt')) + expect(store.comparableKind).toBe('streamed') + }) + + it('leaves an ordinary comparison on the text viewer', () => { + const store = useDiffStore() + store.receive('left', FILE('a.txt')) + store.receive('right', FILE('b.txt')) + expect(store.comparableKind).toBe('text') + expect(store.isStreamed).toBe(false) + }) + + it('refuses to save, which would keep a copy of both files', () => { + const store = loadStreamed(useDiffStore()) + expect(store.canSave).toBe(false) + }) + + it('refuses to share, since sharing goes through saving', () => { + const store = loadStreamed(useDiffStore()) + store.shareCurrent() + expect(store.showSaveDialog).toBe(false) + expect(store.notice).toBeTruthy() + }) + + it('refuses to copy a patch, naming the reason', async () => { + const store = loadStreamed(useDiffStore()) + let copied = false + window.api.copyText = async () => { + copied = true + return { ok: true } + } + await store.copyDiff() + expect(copied).toBe(false) + expect(store.notice).toContain('Too large to copy as a patch') + }) + + it('refuses an HTML export, naming the reason', async () => { + const store = loadStreamed(useDiffStore()) + let exported = false + window.api.exportDiffFile = async () => { + exported = true + return { ok: true } + } + await store.exportDiff() + expect(exported).toBe(false) + expect(store.notice).toContain('Too large to export') + }) + + // Deliberately still allowed: the streamed viewer registers a scroller, so a + // picture of what is on screen is a real picture. + it('still allows an image export', () => { + expect(loadStreamed(useDiffStore()).canExportImage).toBe(true) + }) + + it('refuses a streamed file dropped into a paste side', () => { + const store = useDiffStore() + store.receivePasteFile('left', BIG('huge.log')) + expect(store.pasteLeftFile).toBeNull() + expect(store.notice).toContain('too large to paste against') + }) + + it('knows a streamed pair needs two files on disk', () => { + const store = loadStreamed(useDiffStore()) + expect(store.streamedPairReady).toBe(true) + store.right = { path: null, name: 'Right (pasted)', content: 'typed' } + expect(store.streamedPairReady).toBe(false) + }) + + // The streamed viewer opens a session from BOTH paths. A mixed pair reads as + // streamed, but the small side's comparable is an ordinary text one carrying + // no path — so the paths must come from the loaded files, not the comparables. + it('exposes both paths for a mixed streamed/ordinary pair', () => { + const store = useDiffStore() + store.receive('left', BIG('huge.log')) + store.receive('right', FILE('small.txt')) + expect(store.isStreamed).toBe(true) + expect(store.rightComparable.path).toBeUndefined() + expect(store.streamedPairReady).toBe(true) + expect([store.left.path, store.right.path]).toEqual(['/big/huge.log', '/tmp/small.txt']) + }) + + it('is not pair-ready when a streamed side sits opposite pasted text', () => { + const store = useDiffStore() + store.receive('left', BIG('huge.log')) + store.right = { path: null, name: 'Right (pasted)', content: 'typed' } + expect(store.isStreamed).toBe(true) + expect(store.streamedPairReady).toBe(false) + }) + + it('keeps saving available once the streamed side is cleared', () => { + const store = loadStreamed(useDiffStore()) + expect(store.canSave).toBe(false) + store.receive('left', FILE('a.txt')) + store.receive('right', FILE('b.txt')) + expect(store.canSave).toBe(true) + }) +}) diff --git a/tests/renderer/stores/diffStore.test.js b/tests/renderer/stores/diffStore.test.js index 5a46cde..2f14c08 100644 --- a/tests/renderer/stores/diffStore.test.js +++ b/tests/renderer/stores/diffStore.test.js @@ -1,16 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { createPinia, setActivePinia } from 'pinia' -import { DISK_NOTICE_MS, useDiffStore } from '../../../src/renderer/src/stores/diffStore' +import { useDiffStore } from '../../../src/renderer/src/stores/diffStore' import { useVaultStore } from '../../../src/renderer/src/stores/vaultStore' -import { useSettingsStore } from '../../../src/renderer/src/stores/settingsStore' import { useSnippetStore } from '../../../src/renderer/src/stores/snippetStore' -import { loadPersisted, savePersisted } from '../../../src/renderer/src/persist' import { useTabsStore } from '../../../src/renderer/src/stores/tabsStore' -import { - elementScroller, - getDiffScroller, - setDiffScroller -} from '../../../src/renderer/src/utils/diffScroller' beforeEach(() => { setActivePinia(createPinia()) @@ -1199,726 +1192,6 @@ describe('diffStore', () => { }) }) -describe('applyPatch', () => { - const PATCH = '--- original\n+++ changed\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n' - const pick = (base, patch) => async (side) => - side === 'base' - ? { path: '/tmp/config.js', name: 'config.js', content: base } - : { name: 'change.patch', content: patch } - - it('opens base ↔ patched from the chosen files', async () => { - const store = useDiffStore() - window.api.openFile = pick('a\nb\nc\n', PATCH) - await store.applyPatch() - expect(store.left).toEqual({ path: '/tmp/config.js', name: 'config.js', content: 'a\nb\nc\n' }) - expect(store.right).toEqual({ path: null, name: 'config.js (patched)', content: 'a\nB\nc\n' }) - expect(store.mode).toBe('files') - }) - - it('does nothing when the base pick is cancelled', async () => { - const store = useDiffStore() - window.api.openFile = async () => null - await store.applyPatch() - expect(store.left).toBeNull() - expect(store.right).toBeNull() - }) - - it('rejects a file that is not a unified diff without loading anything', async () => { - const store = useDiffStore() - window.api.openFile = pick('a\nb\nc\n', 'not a patch') - await store.applyPatch() - expect(store.left).toBeNull() - expect(store.right).toBeNull() - }) -}) - -describe('exportDiff', () => { - it('builds a self-contained HTML doc and hands it to the save IPC', async () => { - const store = useDiffStore() - store.left = { path: null, name: 'a.js', content: 'a\nb\n' } - store.right = { path: null, name: 'b.js', content: 'a\nB\n' } - let sent = null - window.api.exportDiffFile = async (payload) => { - sent = payload - return { ok: true, path: '/tmp/out.html' } - } - await store.exportDiff() - expect(sent.name).toBe('a.js-vs-b.js') - expect(sent.format).toBe('html') - expect(sent.text).toContain('') - expect(sent.text).toContain('a.js ↔ b.js') - }) - - it('does nothing (no IPC) when there is nothing to compare', async () => { - const store = useDiffStore() - let called = false - window.api.exportDiffFile = async () => { - called = true - return { ok: true } - } - await store.exportDiff() - expect(called).toBe(false) - }) -}) - -describe('exportImage (saved diffs only)', () => { - // A .content box for captureRectOf to measure, plus a synchronous rAF so the - // "wait for Monaco" frames resolve without a real compositor. - function stageViewer() { - const el = document.createElement('div') - el.className = 'content' - el.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 640 }) - document.body.append(el) - window.requestAnimationFrame = (cb) => setTimeout(cb, 0) - return () => el.remove() - } - - async function savedDiff(payload, name = 'Nightly config') { - const vault = useVaultStore() - window.api.vaultEncrypt = async (plaintext) => ({ iv: 'iv', data: plaintext }) - window.api.vaultDecrypt = async (box) => box.data - return vault.save(name, null, payload) - } - - const CAPTURE = { dataUrl: 'data:image/png;base64,SHOT', width: 1800, height: 1280 } - - it('opens the saved diff, shoots the diff column, and previews the result', async () => { - const cleanup = stageViewer() - const store = useDiffStore() - const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) - let rect = null - let shot = null - window.api.captureDiffImage = async (r) => { - rect = r - // What is on screen AT THE SHOT is the saved diff; afterwards it is not. - shot = [store.left?.name, store.right?.name] - return CAPTURE - } - - await store.exportImage(id) - - expect(shot).toEqual(['a.txt', 'b.txt']) - expect(rect).toEqual({ x: 260, y: 88, width: 900, height: 640 }) - expect(store.imageEntry).toMatchObject({ id, name: 'Nightly config', ...CAPTURE }) - expect(store.imageCapturing).toBe(false) - cleanup() - }) - - it('keeps the app out of its own screenshot while the shutter is open', async () => { - const cleanup = stageViewer() - const store = useDiffStore() - const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) - let capturingDuringShot = null - window.api.captureDiffImage = async () => { - capturingDuringShot = store.imageCapturing - return CAPTURE - } - await store.exportImage(id) - // App.vue hides the toast and the shortcut bar off this flag — they float - // inside the captured region, so they must be gone when the shot is taken. - expect(capturingDuringShot).toBe(true) - expect(store.imageCapturing).toBe(false) - cleanup() - }) - - it('waits for frames to pass before capturing, so Monaco has repainted', async () => { - const cleanup = stageViewer() - const store = useDiffStore() - const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) - let framesBeforeShot = 0 - let frames = 0 - window.requestAnimationFrame = (cb) => { - frames++ - setTimeout(cb, 0) - } - window.api.captureDiffImage = async () => ((framesBeforeShot = frames), CAPTURE) - await store.exportImage(id) - expect(framesBeforeShot).toBeGreaterThan(1) - cleanup() - }) - - // The reported bug: the picture showed the two files with no highlights at - // all, so a real difference looked like no difference. Monaco computes the - // diff in a worker and paints its decorations only when that returns, which - // is long after the handful of frames the shutter used to count. - it('waits for Monaco to finish diffing before shooting, not just for frames', async () => { - const cleanup = stageViewer() - const store = useDiffStore() - const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) - let frames = 0 - window.requestAnimationFrame = (cb) => { - frames++ - // DiffViewer bumps this from onDidUpdateDiff; here the worker is slow. - if (frames === 20) store.diffRevision++ - setTimeout(cb, 0) - } - let revisionAtShot = null - window.api.captureDiffImage = async () => ((revisionAtShot = store.diffRevision), CAPTURE) - - await store.exportImage(id) - - expect(revisionAtShot).toBe(1) - cleanup() - }) - - // A diff taller than its pane cannot be photographed in one shot, so the - // export scrolls Monaco and main joins the strips. Without this the picture - // stopped at the bottom of the visible pane. - describe('a diff taller than the pane', () => { - // .content at y=88 h=640, with Monaco starting at y=140 — so 588px of pane - // under a 52px header. - function stageTallViewer({ contentHeight }) { - const pane = document.createElement('div') - pane.className = 'diff-container' - pane.getBoundingClientRect = () => ({ top: 140, height: 588 }) - const el = document.createElement('div') - el.className = 'content' - el.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 640 }) - el.append(pane) - document.body.append(el) - window.requestAnimationFrame = (cb) => setTimeout(cb, 0) - let scrollTop = 0 - setDiffScroller({ - contentHeight: () => contentHeight, - viewportHeight: () => 588, - scrollTop: () => scrollTop, - scrollTo: (top) => (scrollTop = top) - }) - return () => { - el.remove() - setDiffScroller(null) - } - } - - it('scrolls through the diff and stitches the strips into one picture', async () => { - const cleanup = stageTallViewer({ contentHeight: 1400 }) - const store = useDiffStore() - const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) - const appended = [] - window.api.captureDiffImage = async () => { - throw new Error('a tall diff must not be shot in one frame') - } - window.api.appendDiffImageSlice = async (rect, reset) => { - appended.push({ rect, reset, scrolledTo: getDiffScroller().scrollTop() }) - return { ok: true } - } - window.api.stitchDiffImage = async () => CAPTURE - - await store.exportImage(id) - - // 1400px of diff over a 588px pane: two full viewports, then a 224px tail - // shot at the scroll clamp (1400 - 588 = 812) and cropped to its bottom. - expect(appended.map((a) => a.scrolledTo)).toEqual([0, 588, 812]) - expect(appended.map((a) => a.reset)).toEqual([true, false, false]) - // The header rides on the first strip only, never repeated. - expect(appended[0].rect).toEqual({ x: 260, y: 88, width: 900, height: 52 + 588 }) - expect(appended[1].rect).toEqual({ x: 260, y: 140, width: 900, height: 588 }) - expect(appended[2].rect).toEqual({ x: 260, y: 140 + 364, width: 900, height: 224 }) - expect(store.imageEntry).toMatchObject({ id, ...CAPTURE, truncated: false }) - cleanup() - }) - - it('puts the reader back where they were when the shutter closes', async () => { - const cleanup = stageTallViewer({ contentHeight: 1400 }) - const store = useDiffStore() - const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) - getDiffScroller().scrollTo(300) - window.api.appendDiffImageSlice = async () => ({ ok: true }) - window.api.stitchDiffImage = async () => CAPTURE - await store.exportImage(id) - expect(getDiffScroller().scrollTop()).toBe(300) - cleanup() - }) - - it('stops slicing at the configured ceiling and admits the picture is cut short', async () => { - const cleanup = stageTallViewer({ contentHeight: 200_000 }) - const store = useDiffStore() - const settings = useSettingsStore() - settings.setMaxExportHeightPx(2940) // five 588px viewports - const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) - let covered = 0 - window.api.appendDiffImageSlice = async (rect, reset) => { - covered += reset ? rect.height - 52 : rect.height // the header rides slice one - return { ok: true } - } - window.api.stitchDiffImage = async () => CAPTURE - await store.exportImage(id) - expect(covered).toBe(2940) - expect(store.imageEntry.truncated).toBe(true) - cleanup() - }) - - // The ceiling is in screen pixels, so the same diff exports the same amount - // whatever the display scale — expressing it in device pixels made a Retina - // machine capture half as much as a 1× one from identical settings. - it('covers the same amount of diff whatever the display scale', async () => { - const covered = async (dpr) => { - setActivePinia(createPinia()) - const cleanup = stageTallViewer({ contentHeight: 200_000 }) - window.devicePixelRatio = dpr - const store = useDiffStore() - useSettingsStore().setMaxExportHeightPx(2940) - window.api.vaultEncrypt = async (plaintext) => ({ iv: 'iv', data: plaintext }) - window.api.vaultDecrypt = async (box) => box.data - const id = await useVaultStore().save('t', null, { - mode: 'files', - left: FILE('a.txt'), - right: FILE('b.txt') - }) - let total = 0 - window.api.appendDiffImageSlice = async (rect, reset) => { - total += reset ? rect.height - 52 : rect.height - return { ok: true } - } - window.api.stitchDiffImage = async () => CAPTURE - await store.exportImage(id) - cleanup() - return total - } - expect(await covered(1)).toBe(await covered(2)) - }) - - it('gives up on a refused strip instead of stitching a partial picture', async () => { - const cleanup = stageTallViewer({ contentHeight: 1400 }) - const store = useDiffStore() - const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) - let stitched = false - window.api.appendDiffImageSlice = async (_r, reset) => - reset ? { ok: true } : { error: 'bad-rect' } - window.api.stitchDiffImage = async () => ((stitched = true), CAPTURE) - await store.exportImage(id) - expect(stitched).toBe(false) - expect(store.imageEntry).toBeNull() - expect(store.notice).toContain('Could not take a picture') - cleanup() - }) - }) - - // Exporting what's on screen, with lines selected in either pane narrowing the - // picture to just those. - describe('exportCurrentImage', () => { - function stageSelectable({ contentHeight, selection }) { - const pane = document.createElement('div') - pane.className = 'diff-container' - pane.getBoundingClientRect = () => ({ top: 140, height: 588 }) - const el = document.createElement('div') - el.className = 'content' - el.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 640 }) - el.append(pane) - document.body.append(el) - window.requestAnimationFrame = (cb) => setTimeout(cb, 0) - let scrollTop = 0 - setDiffScroller({ - contentHeight: () => contentHeight, - viewportHeight: () => 588, - scrollTop: () => scrollTop, - scrollTo: (top) => (scrollTop = top), - selection: () => selection - }) - return () => { - el.remove() - setDiffScroller(null) - } - } - - const loaded = (store) => { - store.left = FILE('a.txt') - store.right = FILE('b.txt') - store.mode = 'files' - } - - it('captures only the selected band, not the whole diff', async () => { - const cleanup = stageSelectable({ - contentHeight: 4000, - selection: { top: 1000, bottom: 1300 } - }) - const store = useDiffStore() - loaded(store) - const rects = [] - window.api.appendDiffImageSlice = async (rect, reset) => ( - rects.push({ rect, reset }), - { ok: true } - ) - window.api.stitchDiffImage = async () => CAPTURE - window.api.captureDiffImage = async () => { - throw new Error('a selection must not fall back to the whole-column shot') - } - - await store.exportCurrentImage() - - expect(rects).toHaveLength(1) - // Scrolled to the top of the selection; the header rides above it. - expect(rects[0]).toEqual({ - rect: { x: 260, y: 88, width: 900, height: 52 + 300 }, - reset: true - }) - expect(store.imageEntry).toMatchObject({ id: null, name: 'a.txt ↔ b.txt' }) - cleanup() - }) - - it('reaches a selection at the very end through Monaco’s scroll clamp', async () => { - const cleanup = stageSelectable({ - contentHeight: 4000, - selection: { top: 3800, bottom: 4000 } - }) - const store = useDiffStore() - loaded(store) - const rects = [] - window.api.appendDiffImageSlice = async (rect) => (rects.push(rect), { ok: true }) - window.api.stitchDiffImage = async () => CAPTURE - await store.exportCurrentImage() - // 4000 - 588 = 3412 is as far as it scrolls, so the band sits 388px down. - expect(getDiffScroller().scrollTop()).toBe(0) // and it is put back after - expect(rects[0].height).toBe(52 + 200) - cleanup() - }) - - it('captures the whole diff when no lines are selected', async () => { - const cleanup = stageSelectable({ contentHeight: 300, selection: null }) - const store = useDiffStore() - loaded(store) - let rect = null - window.api.captureDiffImage = async (r) => ((rect = r), CAPTURE) - await store.exportCurrentImage() - expect(rect).toEqual({ x: 260, y: 88, width: 900, height: 640 }) - expect(store.imageEntry).toMatchObject({ id: null }) - cleanup() - }) - - it('refuses when there is no comparison on screen', async () => { - const cleanup = stageSelectable({ contentHeight: 300, selection: null }) - const store = useDiffStore() - store.mode = 'paste' - let called = false - window.api.captureDiffImage = async () => ((called = true), CAPTURE) - await store.exportCurrentImage() - expect(called).toBe(false) - expect(store.imageEntry).toBeNull() - expect(store.notice).toContain('Nothing to export') - cleanup() - }) - }) - - it('exports nothing for an id that is not a saved diff', async () => { - const cleanup = stageViewer() - const store = useDiffStore() - // The live comparison is deliberately NOT a source: saved diffs only. - store.left = FILE('onscreen.txt') - store.right = FILE('other.txt') - let called = false - window.api.captureDiffImage = async () => ((called = true), CAPTURE) - await store.exportImage('no-such-id') - expect(store.imageEntry).toBeNull() - expect(called).toBe(false) - cleanup() - }) - - it('shoots the SAVED entry, never whatever was already on screen', async () => { - const cleanup = stageViewer() - const store = useDiffStore() - const id = await savedDiff({ - mode: 'files', - left: FILE('saved-l.txt'), - right: FILE('saved-r.txt') - }) - store.left = FILE('onscreen-l.txt') - store.right = FILE('onscreen-r.txt') - store.diffSaved = false - let shot = null - window.api.captureDiffImage = async () => { - shot = [store.left?.name, store.right?.name] - return CAPTURE - } - await store.exportImage(id) - expect(shot).toEqual(['saved-l.txt', 'saved-r.txt']) - // ...and the comparison the user was working on is handed straight back. - expect(store.left).toMatchObject({ name: 'onscreen-l.txt' }) - expect(store.right).toMatchObject({ name: 'onscreen-r.txt' }) - expect(store.diffSaved).toBe(false) - cleanup() - }) - - it('reports an entry that no longer decrypts and photographs nothing', async () => { - const cleanup = stageViewer() - const store = useDiffStore() - const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) - window.api.vaultDecrypt = async () => null - let called = false - window.api.captureDiffImage = async () => ((called = true), CAPTURE) - await store.exportImage(id) - expect(called).toBe(false) - expect(store.imageEntry).toBeNull() - expect(store.notice).toContain('expired or could not be decrypted') - cleanup() - }) - - it('reports a refused capture instead of opening an empty preview', async () => { - const cleanup = stageViewer() - const store = useDiffStore() - const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) - window.api.captureDiffImage = async () => ({ error: 'bad-rect' }) - await store.exportImage(id) - expect(store.imageEntry).toBeNull() - expect(store.imageCapturing).toBe(false) - expect(store.notice).toContain('Could not take a picture') - cleanup() - }) - - it('does not call main when there is no diff column to measure', async () => { - const store = useDiffStore() - window.requestAnimationFrame = (cb) => setTimeout(cb, 0) - const id = await savedDiff({ mode: 'files', left: FILE('a.txt'), right: FILE('b.txt') }) - let called = false - window.api.captureDiffImage = async () => ((called = true), CAPTURE) - await store.exportImage(id) // no .content element staged - expect(called).toBe(false) - expect(store.imageEntry).toBeNull() - expect(store.notice).toContain('Could not take a picture') - }) - - it('copyImage asks main for the capture it is holding, and acknowledges', async () => { - const store = useDiffStore() - let called = 0 - window.api.copyDiffImage = async (...args) => { - called++ - // No image bytes travel back to main — it still has the bitmap. - expect(args).toHaveLength(0) - return { ok: true } - } - expect(await store.copyImage()).toBe(true) - expect(called).toBe(1) - expect(store.notice).toContain('copied to clipboard') - }) - - it('copyImage reports a refusal rather than claiming success', async () => { - const store = useDiffStore() - window.api.copyDiffImage = async () => ({ ok: false, error: 'nothing-captured' }) - expect(await store.copyImage()).toBe(false) - expect(store.notice).toContain('Could not copy') - }) - - it('saveImage names the file after the saved diff and says where it landed', async () => { - const store = useDiffStore() - store.imageEntry = { id: 'x', name: 'Nightly config' } - let sentName = null - window.api.saveDiffImage = async (name) => { - sentName = name - return { ok: true, path: '/tmp/Nightly config.png' } - } - await store.saveImage() - expect(sentName).toBe('Nightly config') - expect(store.notice).toContain('/tmp/Nightly config.png') - }) - - it('saveImage stays quiet when the save dialog was cancelled', async () => { - const store = useDiffStore() - window.api.saveDiffImage = async () => ({ canceled: true }) - await store.saveImage() - expect(store.notice).toBeNull() - }) - - it('saveImage surfaces a failed write', async () => { - const store = useDiffStore() - window.api.saveDiffImage = async () => ({ ok: false, error: 'nothing-captured' }) - await store.saveImage() - expect(store.notice).toContain('Could not save') - }) - - it('closing the preview tells main to drop the bitmap it was holding', async () => { - const store = useDiffStore() - let forgotten = false - window.api.forgetDiffImage = async () => ((forgotten = true), { ok: true }) - store.imageEntry = { id: 'x', name: 'n', dataUrl: 'data:image/png;base64,SHOT' } - store.closeImageExport() - expect(store.imageEntry).toBeNull() - expect(forgotten).toBe(true) - }) -}) - -describe('exportImage failure handling', () => { - it('never leaves the app chrome hidden when the capture throws', async () => { - const store = useDiffStore() - const vault = useVaultStore() - window.api.vaultEncrypt = async (plaintext) => ({ iv: 'iv', data: plaintext }) - window.api.vaultDecrypt = async (box) => box.data - window.requestAnimationFrame = (cb) => setTimeout(cb, 0) - const el = document.createElement('div') - el.className = 'content' - el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 900, height: 640 }) - document.body.append(el) - const id = await vault.save('boom', null, { - mode: 'files', - left: FILE('a.txt'), - right: FILE('b.txt') - }) - window.api.captureDiffImage = async () => { - throw new Error('IPC exploded') - } - - await store.exportImage(id) - - // A stuck flag would hide the shortcut bar for the rest of the session. - expect(store.imageCapturing).toBe(false) - expect(store.imageEntry).toBeNull() - expect(store.notice).toContain('Could not take a picture') - el.remove() - }) -}) - -// A snippet is photographed by putting it on a stage over the diff column and -// firing the SAME shutter. The stage is a component, so these tests drive its -// "I'm painted" signal by hand — what they pin is the store's half: the subject -// staged, the wait before the shot, and the stage always coming down. -describe('exportSnippetImage', () => { - const CAPTURE = { dataUrl: 'data:image/png;base64,SHOT', width: 1200, height: 700 } - - function stageColumn() { - const el = document.createElement('div') - el.className = 'content' - el.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 640 }) - document.body.append(el) - return () => el.remove() - } - - // Paint the stage a few frames in, the way the real component does once its - // highlighting settles or Mermaid returns. - function paintAfter(store, frames, mark = 'ready') { - let seen = 0 - window.requestAnimationFrame = (cb) => { - seen++ - if (seen === frames && store.snippetShot) store.snippetShot[mark] = true - setTimeout(cb, 0) - } - return () => seen - } - - async function addSnippet(over = {}) { - const snippets = useSnippetStore() - window.api.vaultEncrypt = async (plaintext) => ({ iv: 'iv', data: plaintext }) - window.api.vaultDecrypt = async (box) => box.data - return snippets.add({ - name: 'Deploy steps', - content: '{ "a": 1 }', - language: 'json', - ...over - }) - } - - it('stages the snippet, shoots it, and previews it', async () => { - const cleanup = stageColumn() - const store = useDiffStore() - const id = await addSnippet() - paintAfter(store, 2) - let staged = null - window.api.captureDiffImage = async () => { - staged = store.snippetShot && { ...store.snippetShot } - return CAPTURE - } - - await store.exportSnippetImage(id) - - // The snippet really was on screen when the shutter opened... - expect(staged).toMatchObject({ name: 'Deploy steps', lang: 'json', code: '{ "a": 1 }' }) - expect(store.imageEntry).toMatchObject({ id, name: 'Deploy steps', subject: 'snippet' }) - // ...and the column is the user's again afterwards. - expect(store.snippetShot).toBeNull() - expect(store.imageCapturing).toBe(false) - cleanup() - }) - - it('names a diagram as a diagram, since that is what was photographed', async () => { - const cleanup = stageColumn() - const store = useDiffStore() - const id = await addSnippet({ - name: 'Flow', - content: 'flowchart TD\n A-->B', - language: 'mermaid' - }) - paintAfter(store, 2) - window.api.captureDiffImage = async () => CAPTURE - - await store.exportSnippetImage(id) - - expect(store.imageEntry).toMatchObject({ name: 'Flow', subject: 'diagram' }) - cleanup() - }) - - // Counting frames is what once photographed a diff with no highlights at all. - // Mermaid renders behind a 2.8 MB dynamic import and a cold grammar tokenizes - // untyped, so the stage says when it is painted and the shutter waits. - it('does not shoot until the stage says it is painted', async () => { - const cleanup = stageColumn() - const store = useDiffStore() - const id = await addSnippet() - const frames = paintAfter(store, 30) - let framesAtShot = null - window.api.captureDiffImage = async () => ((framesAtShot = frames()), CAPTURE) - - await store.exportSnippetImage(id) - - expect(framesAtShot).toBeGreaterThanOrEqual(30) - expect(store.imageEntry).toMatchObject({ subject: 'snippet' }) - cleanup() - }) - - it('takes no picture of a diagram that would not render', async () => { - const cleanup = stageColumn() - const store = useDiffStore() - const id = await addSnippet({ name: 'Broken', content: 'flowchart ???', language: 'mermaid' }) - paintAfter(store, 2, 'failed') - let shots = 0 - window.api.captureDiffImage = async () => (shots++, CAPTURE) - - await store.exportSnippetImage(id) - - expect(shots).toBe(0) - expect(store.imageEntry).toBeNull() - expect(store.notice).toContain('could not be rendered') - expect(store.snippetShot).toBeNull() - cleanup() - }) - - // A photograph of a masked secret is either useless or a leak, so it is - // refused before anything decrypts it. - it('refuses a secret snippet without decrypting it', async () => { - const cleanup = stageColumn() - const store = useDiffStore() - const id = await addSnippet({ name: 'API key', content: 'sk-live-xyz', secret: true }) - let decrypts = 0 - const decrypt = window.api.vaultDecrypt - window.api.vaultDecrypt = async (box) => (decrypts++, decrypt(box)) - let shots = 0 - window.api.captureDiffImage = async () => (shots++, CAPTURE) - - await store.exportSnippetImage(id) - - expect(decrypts).toBe(0) - expect(shots).toBe(0) - expect(store.imageEntry).toBeNull() - expect(store.snippetShot).toBeNull() - expect(store.notice).toContain('Hidden') - cleanup() - }) - - it('takes the stage down even when the capture fails', async () => { - const cleanup = stageColumn() - const store = useDiffStore() - const id = await addSnippet() - paintAfter(store, 2) - window.api.captureDiffImage = async () => { - throw new Error('IPC exploded') - } - - await store.exportSnippetImage(id) - - expect(store.snippetShot).toBeNull() - expect(store.imageCapturing).toBe(false) - expect(store.imageEntry).toBeNull() - expect(store.notice).toContain('Could not take a picture') - cleanup() - }) -}) - // Closing the active tab from the File menu (or Cmd+Shift+W). This wiring was // once dropped by an unrelated commit and nothing noticed, because nothing // tested it — the menu item stayed, the action behind it did not. @@ -1977,749 +1250,3 @@ describe('closing the active comparison from the menu', () => { expect(tabs.activeId).toBe(second) }) }) - -// "Saved" is what silences the discard prompts, so a comparison that no longer -// matches the vault copy must stop claiming to be it. -describe('staying honest about what is saved', () => { - const FILE_AT = (name, content) => ({ path: `/tmp/${name}`, name, content }) - - it('a file changing on disk makes the reloaded comparison unsaved again', async () => { - const store = useDiffStore() - store.left = FILE_AT('a.txt', 'before') - store.right = FILE_AT('b.txt', 'other') - store.markSaved() - window.api = { - readFile: async (path) => - path.endsWith('a.txt') - ? { path, name: 'a.txt', content: 'edited elsewhere' } - : { path, name: 'b.txt', content: 'other' } - } - - await store.refreshFromDisk() - expect(store.left.content).toBe('edited elsewhere') - expect(store.diffSaved).toBe(false) - }) - - it('leaves a diff alone when nothing on disk actually changed', async () => { - const store = useDiffStore() - store.left = FILE_AT('a.txt', 'same') - store.markSaved() - window.api = { readFile: async (path) => ({ path, name: 'a.txt', content: 'same' }) } - - await store.refreshFromDisk() - expect(store.diffSaved).toBe(true) - }) -}) - -// Copied files land exactly like dropped ones, confirm included. -describe('pasting copied files', () => { - const AT = (name) => ({ path: `/tmp/${name}`, name, content: `content of ${name}` }) - - it('asks before it replaces a complete, unsaved comparison', async () => { - const store = useDiffStore() - store.left = AT('old-left.txt') - store.right = AT('old-right.txt') - window.api = { - readClipboardFiles: async () => [AT('new-left.txt'), AT('new-right.txt')], - readFile: async (path) => AT(path.split('/').pop()) - } - - await store.requestPasteFromClipboard() - // What matters is that it WAITS, holding both incoming files, and has not - // touched the comparison on screen — not how the pending pair is carried. - expect(store.pendingReplace).toHaveLength(2) - expect(store.pendingReplace.map((f) => f.name)).toEqual(['new-left.txt', 'new-right.txt']) - expect(store.left.name).toBe('old-left.txt') - - await store.confirmReplace() - expect(store.left.name).toBe('new-left.txt') - expect(store.right.name).toBe('new-right.txt') - }) - - it('replaces a SAVED comparison without asking, like a drop does', async () => { - const store = useDiffStore() - store.left = AT('old-left.txt') - store.right = AT('old-right.txt') - store.markSaved() - window.api = { - readClipboardFiles: async () => [AT('new-left.txt'), AT('new-right.txt')], - readFile: async (path) => AT(path.split('/').pop()) - } - - await store.requestPasteFromClipboard() - expect(store.pendingReplace).toBeNull() - expect(store.left.name).toBe('new-left.txt') - }) - - it('still fills the free side straight away when nothing would be lost', async () => { - const store = useDiffStore() - store.left = AT('kept.txt') - window.api = { - readClipboardFiles: async () => [AT('second.txt')], - readFile: async (path) => AT(path.split('/').pop()) - } - - await store.requestPasteFromClipboard() - expect(store.pendingReplace).toBeNull() - expect(store.left.name).toBe('kept.txt') - expect(store.right.name).toBe('second.txt') - }) -}) - -// The change check compared `content`, which a spreadsheet has none of, so no -// workbook ever reloaded. -describe('following a spreadsheet on disk', () => { - const book = (v) => ({ - path: '/tmp/book.xlsx', - name: 'book.xlsx', - kind: 'spreadsheet', - sheets: [{ name: 'S1', rows: [['a', v]] }] - }) - - it('reloads a workbook whose grid changed, and says so', async () => { - const store = useDiffStore() - store.left = book(1) - store.markSaved() - window.api = { readFile: async () => book(2) } - - await store.refreshFromDisk() - expect(store.left.sheets[0].rows[0][1]).toBe(2) - expect(store.diskNotice).toContain('changed on disk') - expect(store.diffSaved).toBe(false) - }) - - it('leaves an untouched workbook alone', async () => { - const store = useDiffStore() - store.left = book(1) - store.markSaved() - window.api = { readFile: async () => book(1) } - - await store.refreshFromDisk() - expect(store.diskNotice).toBeNull() - expect(store.diffSaved).toBe(true) - }) -}) - -// A second save adds nothing but a duplicate row, so it is not offered. -describe('saving the same comparison twice', () => { - it('is not offered while the comparison on screen is already saved', () => { - const store = useDiffStore() - store.left = FILE('a.txt') - store.right = FILE('b.txt') - expect(store.hasUnsavedWork).toBe(true) - - store.markSaved() - expect(store.canSave).toBe(true) // there is still a comparison to share - expect(store.hasUnsavedWork).toBe(false) - - store.handleMenuAction('save') - expect(store.showSaveDialog).toBe(false) - }) - - it('is offered again the moment the comparison changes', () => { - const store = useDiffStore() - store.left = FILE('a.txt') - store.right = FILE('b.txt') - store.markSaved() - - store.swap() - expect(store.hasUnsavedWork).toBe(true) - }) - - it('is never offered for an empty comparison', () => { - expect(useDiffStore().hasUnsavedWork).toBe(false) - }) -}) - -// Format rewrites a side in memory, so the app's copy and the file diverge. The -// focus re-read saw a difference it had caused itself, threw the formatting -// away, and reported a disk change that never happened. -describe('when the app and the disk have both moved', () => { - const UGLY = '{"a":1}' - const onDisk = (content) => ({ path: '/tmp/a.json', name: 'a.json', content }) - - it('keeps a side the app reformatted, and does not claim the disk changed', async () => { - const store = useDiffStore() - store.left = onDisk(UGLY) - store.formatSide('left') - const formatted = store.left.content - expect(formatted).not.toBe(UGLY) - - window.api = { readFile: async () => onDisk(UGLY) } - await store.refreshFromDisk() - - expect(store.left.content).toBe(formatted) - expect(store.diskNotice).toBeNull() - }) - - it('holds the app’s copy when the file ALSO changed, and says which', async () => { - const store = useDiffStore() - store.left = onDisk(UGLY) - store.formatSide('left') - const formatted = store.left.content - - window.api = { readFile: async () => onDisk('{"a":2}') } - await store.refreshFromDisk() - - expect(store.left.content).toBe(formatted) - expect(store.diskNotice).toContain('a.json') - expect(store.diskNotice).toContain('changed on disk') - expect(store.diskNotice).toContain('kept') - }) - - it('follows the disk again once the side is reloaded from it', async () => { - const store = useDiffStore() - store.left = onDisk(UGLY) - store.formatSide('left') - - // Re-picking the file is the deliberate "take theirs". - store.receive('left', onDisk('{"a":2}')) - window.api = { readFile: async () => onDisk('{"a":3}') } - await store.refreshFromDisk() - - expect(store.left.content).toBe('{"a":3}') - expect(store.diskNotice).toContain('diff reloaded') - }) - - it('still reloads an untouched side while another is held back', async () => { - const store = useDiffStore() - store.left = onDisk(UGLY) - store.formatSide('left') - store.right = { path: '/tmp/b.json', name: 'b.json', content: 'old' } - - window.api = { - readFile: async (path) => - path.endsWith('a.json') ? onDisk('{"a":9}') : { ...store.right, content: 'new' } - } - await store.refreshFromDisk() - - expect(store.right.content).toBe('new') - expect(store.diskNotice).toContain('a.json') - expect(store.diskNotice).toContain('b.json') - }) -}) - -// A held, dismissible label — not a toast that clears itself out from under you. -describe('the file-changed label', () => { - const onDisk = (content) => ({ path: '/tmp/a.txt', name: 'a.txt', content }) - - it('goes up on a disk change and clears itself after its window', async () => { - vi.useFakeTimers() - try { - const store = useDiffStore() - store.left = onDisk('before') - window.api = { readFile: async () => onDisk('after') } - - await store.refreshFromDisk() - expect(store.diskNotice).toContain('a.txt') - - vi.advanceTimersByTime(DISK_NOTICE_MS - 1) - expect(store.diskNotice).not.toBeNull() - vi.advanceTimersByTime(1) - expect(store.diskNotice).toBeNull() - } finally { - vi.useRealTimers() - } - }) - - it('outlives the ordinary toast, which would have cleared first', () => { - expect(DISK_NOTICE_MS).toBeGreaterThan(5000) - }) - - it('can be dismissed by hand, and stays dismissed', () => { - vi.useFakeTimers() - try { - const store = useDiffStore() - store.showDiskNotice('"a.txt" changed on disk — diff reloaded.') - store.dismissDiskNotice() - expect(store.diskNotice).toBeNull() - - // The timer it cancelled cannot come back and blank a later one. - store.showDiskNotice('second') - vi.advanceTimersByTime(DISK_NOTICE_MS - 1) - expect(store.diskNotice).toBe('second') - } finally { - vi.useRealTimers() - } - }) -}) - -// The grid scrolls inside itself with no scroller to drive, so the shutter could -// only ever catch the visible slice. Refused outright rather than truncated. -describe('image export and the spreadsheet grid', () => { - const SHOT = { dataUrl: 'data:image/png;base64,GRID', width: 900, height: 1800 } - // jsdom's scroll metrics are read-only getters that always answer 0. - const sizeOf = (el, dims) => { - for (const [k, value] of Object.entries(dims)) Object.defineProperty(el, k, { value }) - } - const grid = (name) => ({ - path: `/tmp/${name}`, - name, - kind: 'spreadsheet', - sheets: [{ name: 'S1', rows: [['a', 1]] }] - }) - - // The grid scrolls inside itself with no Monaco behind it. It registers its - // own scroller, which is all the export needs — scroll a viewport at a time - // and stitch, exactly as for a tall diff. - it('is offered for a spreadsheet comparison', () => { - const store = useDiffStore() - store.left = grid('a.xlsx') - store.right = grid('b.xlsx') - expect(store.isSpreadsheet).toBe(true) - expect(store.canExportImage).toBe(true) - }) - - it('is still offered for a text comparison', () => { - const store = useDiffStore() - store.left = FILE('a.txt') - store.right = FILE('b.txt') - expect(store.isSpreadsheet).toBe(false) - expect(store.canExportImage).toBe(true) - }) - - it('scrolls and stitches the grid the way it does a tall diff', async () => { - const store = useDiffStore() - store.left = grid('a.xlsx') - store.right = grid('b.xlsx') - - const grids = document.createElement('div') - grids.getBoundingClientRect = () => ({ top: 140, height: 600 }) - sizeOf(grids, { scrollHeight: 1800, clientHeight: 600, scrollWidth: 900, clientWidth: 900 }) - const column = document.createElement('div') - column.className = 'content' - column.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 700 }) - column.append(grids) - document.body.append(column) - window.requestAnimationFrame = (cb) => setTimeout(cb, 0) - setDiffScroller(elementScroller(() => grids)) - - const tops = [] - window.api.appendDiffImageSlice = async (rect) => { - tops.push(rect.y) - return { ok: true } - } - window.api.stitchDiffImage = async () => SHOT - - await store.exportCurrentImage() - - expect(tops).toHaveLength(3) - expect(store.imageEntry).toMatchObject({ ...SHOT, hiddenColumns: 0 }) - column.remove() - setDiffScroller(null) - }) - - // A grid wider than the window loses its right-hand columns to a picture that - // only scrolls down. The dialog is told, rather than handing over a crop that - // looks complete. - it('reports the columns a picture cannot reach', async () => { - const store = useDiffStore() - store.left = grid('a.xlsx') - store.right = grid('b.xlsx') - - const grids = document.createElement('div') - grids.getBoundingClientRect = () => ({ top: 140, height: 600 }) - sizeOf(grids, { scrollHeight: 600, clientHeight: 600, scrollWidth: 2700, clientWidth: 900 }) - const column = document.createElement('div') - column.className = 'content' - column.getBoundingClientRect = () => ({ left: 260, top: 88, width: 900, height: 700 }) - column.append(grids) - document.body.append(column) - window.requestAnimationFrame = (cb) => setTimeout(cb, 0) - setDiffScroller(elementScroller(() => grids)) - window.api.captureDiffImage = async () => SHOT - - await store.exportCurrentImage() - - expect(store.imageEntry?.hiddenColumns).toBe(2) - column.remove() - setDiffScroller(null) - }) -}) - -// A file past the streamed threshold arrives as a descriptor with no `content`. -const BIG = (name) => ({ path: `/big/${name}`, name, size: 60 * 1024 * 1024, kind: 'streamed' }) - -function loadStreamed(store) { - store.receive('left', BIG('left.log')) - store.receive('right', BIG('right.log')) - return store -} - -describe('diffStore — streamed comparisons', () => { - it('accepts a streamed descriptor into a slot', () => { - const store = loadStreamed(useDiffStore()) - expect(store.ready).toBe(true) - expect(store.notice).toBeNull() - expect(store.leftComparable).toEqual({ - kind: 'streamed', - path: '/big/left.log', - name: 'left.log', - size: 60 * 1024 * 1024 - }) - }) - - it('routes to the streamed viewer', () => { - expect(loadStreamed(useDiffStore()).comparableKind).toBe('streamed') - expect(loadStreamed(useDiffStore()).isStreamed).toBe(true) - }) - - // One side too large makes the WHOLE comparison streamed — there is no text - // for the other side to be diffed against in an editor model. - it('is streamed when only the RIGHT side is too large', () => { - const store = useDiffStore() - store.receive('left', FILE('small.txt')) - store.receive('right', BIG('huge.log')) - expect(store.comparableKind).toBe('streamed') - }) - - it('is streamed when only the LEFT side is too large', () => { - const store = useDiffStore() - store.receive('left', BIG('huge.log')) - store.receive('right', FILE('small.txt')) - expect(store.comparableKind).toBe('streamed') - }) - - it('leaves an ordinary comparison on the text viewer', () => { - const store = useDiffStore() - store.receive('left', FILE('a.txt')) - store.receive('right', FILE('b.txt')) - expect(store.comparableKind).toBe('text') - expect(store.isStreamed).toBe(false) - }) - - it('refuses to save, which would keep a copy of both files', () => { - const store = loadStreamed(useDiffStore()) - expect(store.canSave).toBe(false) - }) - - it('refuses to share, since sharing goes through saving', () => { - const store = loadStreamed(useDiffStore()) - store.shareCurrent() - expect(store.showSaveDialog).toBe(false) - expect(store.notice).toBeTruthy() - }) - - it('refuses to copy a patch, naming the reason', async () => { - const store = loadStreamed(useDiffStore()) - let copied = false - window.api.copyText = async () => { - copied = true - return { ok: true } - } - await store.copyDiff() - expect(copied).toBe(false) - expect(store.notice).toContain('Too large to copy as a patch') - }) - - it('refuses an HTML export, naming the reason', async () => { - const store = loadStreamed(useDiffStore()) - let exported = false - window.api.exportDiffFile = async () => { - exported = true - return { ok: true } - } - await store.exportDiff() - expect(exported).toBe(false) - expect(store.notice).toContain('Too large to export') - }) - - // Deliberately still allowed: the streamed viewer registers a scroller, so a - // picture of what is on screen is a real picture. - it('still allows an image export', () => { - expect(loadStreamed(useDiffStore()).canExportImage).toBe(true) - }) - - it('refuses a streamed file dropped into a paste side', () => { - const store = useDiffStore() - store.receivePasteFile('left', BIG('huge.log')) - expect(store.pasteLeftFile).toBeNull() - expect(store.notice).toContain('too large to paste against') - }) - - it('knows a streamed pair needs two files on disk', () => { - const store = loadStreamed(useDiffStore()) - expect(store.streamedPairReady).toBe(true) - store.right = { path: null, name: 'Right (pasted)', content: 'typed' } - expect(store.streamedPairReady).toBe(false) - }) - - // The streamed viewer opens a session from BOTH paths. A mixed pair reads as - // streamed, but the small side's comparable is an ordinary text one carrying - // no path — so the paths must come from the loaded files, not the comparables. - it('exposes both paths for a mixed streamed/ordinary pair', () => { - const store = useDiffStore() - store.receive('left', BIG('huge.log')) - store.receive('right', FILE('small.txt')) - expect(store.isStreamed).toBe(true) - expect(store.rightComparable.path).toBeUndefined() - expect(store.streamedPairReady).toBe(true) - expect([store.left.path, store.right.path]).toEqual(['/big/huge.log', '/tmp/small.txt']) - }) - - it('is not pair-ready when a streamed side sits opposite pasted text', () => { - const store = useDiffStore() - store.receive('left', BIG('huge.log')) - store.right = { path: null, name: 'Right (pasted)', content: 'typed' } - expect(store.isStreamed).toBe(true) - expect(store.streamedPairReady).toBe(false) - }) - - it('keeps saving available once the streamed side is cleared', () => { - const store = loadStreamed(useDiffStore()) - expect(store.canSave).toBe(false) - store.receive('left', FILE('a.txt')) - store.receive('right', FILE('b.txt')) - expect(store.canSave).toBe(true) - }) -}) - -// clipboard:readFiles already reads each file through the same path file:read -// uses, so re-reading by path put the "Large file — load anyway?" prompt in -// front of the user twice for one paste. -describe('pasteClipboardFiles', () => { - it('uses the file objects it was handed instead of reading them again', async () => { - const store = useDiffStore() - const readFile = vi.fn(async (path) => ({ path, name: path.split('/').pop(), content: 'x' })) - window.api = { - readFile, - readClipboardFiles: async () => [FILE('a.txt'), FILE('b.txt')] - } - - expect(await store.pasteClipboardFiles()).toBe(true) - expect(readFile).not.toHaveBeenCalled() - expect(store.left.name).toBe('a.txt') - expect(store.right.name).toBe('b.txt') - }) - - it('is false, and touches nothing, when the clipboard holds no files', async () => { - const store = useDiffStore() - window.api = { readClipboardFiles: async () => [] } - expect(await store.pasteClipboardFiles()).toBe(false) - expect(store.left).toBeNull() - }) - - it('drops entries the main process refused to read', async () => { - const store = useDiffStore() - window.api = { - readClipboardFiles: async () => [null, FILE('only.txt')] - } - expect(await store.pasteClipboardFiles()).toBe(true) - expect(store.left.name).toBe('only.txt') - expect(store.right).toBeNull() - }) -}) - -// Exporting a picture of a SAVED diff must not take the live document hostage: -// it used to replace it, mark it saved (so no discard prompt could fire), and -// leave the tab claiming to hold a comparison it no longer had. -describe('exportImage', () => { - const seedEntry = async (store) => { - const vault = useVaultStore() - vault.entries = [{ id: 'e1', name: 'saved one' }] - vault.load = async () => ({ - mode: 'files', - left: FILE('old-left.txt'), - right: FILE('old-right.txt') - }) - store._shoot = async () => ({ dataUrl: 'data:image/png;base64,zzz' }) - return vault - } - - it('leaves unsaved work on screen exactly as it was', async () => { - const store = useDiffStore() - await seedEntry(store) - store.mode = 'paste' - store.pasteLeft = 'work in progress' - store.pasteRight = 'other side' - store.diffSaved = false - - await store.exportImage('e1') - - expect(store.mode).toBe('paste') - expect(store.pasteLeft).toBe('work in progress') - expect(store.pasteRight).toBe('other side') - expect(store.diffSaved).toBe(false) - expect(store.imageEntry).toMatchObject({ id: 'e1', name: 'saved one' }) - }) - - it('restores a loaded file comparison, not just paste text', async () => { - const store = useDiffStore() - await seedEntry(store) - store.left = FILE('live-left.txt') - store.right = FILE('live-right.txt') - store.diffSaved = false - - await store.exportImage('e1') - - expect(store.left.name).toBe('live-left.txt') - expect(store.right.name).toBe('live-right.txt') - expect(store.diffSaved).toBe(false) - }) - - it('puts the live document back even when the shot fails', async () => { - const store = useDiffStore() - await seedEntry(store) - store._shoot = async () => ({ error: 'capture-failed' }) - store.mode = 'paste' - store.pasteLeft = 'work in progress' - store.diffSaved = false - - await store.exportImage('e1') - - expect(store.pasteLeft).toBe('work in progress') - expect(store.diffSaved).toBe(false) - expect(store.imageEntry).toBeNull() - expect(store.notice).toBeTruthy() - }) -}) - -// Dropped snippets route through the SAME dropFiles the file path uses, so the -// replace guard, one-then-wait and two-fill-both come free rather than being -// re-implemented where they could drift. -describe('dropSnippets', () => { - beforeEach(() => { - const store = new Map() - window.api.vaultEncrypt = async (plaintext) => { - const iv = String(store.size) - store.set(iv, plaintext) - return { iv, data: iv } - } - window.api.vaultDecrypt = async ({ iv }) => store.get(iv) ?? null - }) - - const seed = async (over = {}) => { - const snippets = useSnippetStore() - return snippets.add({ name: 'Deploy config', content: '{"a":1}', language: 'json', ...over }) - } - - it('fills the left side from one snippet and waits for the right', async () => { - const diff = useDiffStore() - const id = await seed() - await diff.dropSnippets([id]) - expect(diff.left).toMatchObject({ path: null, name: 'Deploy config', snippetId: id }) - expect(diff.right).toBeNull() - }) - - it('fills both sides from two', async () => { - const diff = useDiffStore() - const a = await seed({ name: 'One' }) - const b = await seed({ name: 'Two', content: '{"a":2}' }) - await diff.dropSnippets([a, b]) - expect(diff.left.name).toBe('One') - expect(diff.right.name).toBe('Two') - }) - - it('drops onto the side the row was released over', async () => { - const diff = useDiffStore() - const id = await seed({ name: 'Righty' }) - await diff.dropSnippets([id], 'right') - expect(diff.right.name).toBe('Righty') - expect(diff.left).toBeNull() - }) - - it('refuses a secret snippet and says so', async () => { - const diff = useDiffStore() - const id = await seed({ name: 'Prod API key', secret: true }) - await diff.dropSnippets([id]) - expect(diff.left).toBeNull() - expect(diff.notice).toMatch(/hidden/i) - }) - - it('ignores an id that is not in the library', async () => { - const diff = useDiffStore() - await diff.dropSnippets(['no-such-id']) - expect(diff.left).toBeNull() - }) - - // The comparison is a copy; nothing here may write back to the library. - it('leaves the snippet itself untouched', async () => { - const diff = useDiffStore() - const snippets = useSnippetStore() - const id = await seed() - const before = JSON.stringify(snippets.entries) - await diff.dropSnippets([id]) - diff.clear() - expect(JSON.stringify(snippets.entries)).toBe(before) - }) -}) - -// The Diagram toggle reuses the Structure checkbox — a second control would be -// the repo's recurring "second bespoke copy". So the getters have to agree. -describe('diagram comparison', () => { - const mmd = (body) => `flowchart TD\n${body}\n` - const load = (diff, l, r) => { - diff.left = { path: '/a.mmd', name: 'a.mmd', content: l } - diff.right = { path: '/b.mmd', name: 'b.mmd', content: r } - diff.mode = 'files' - } - - it('offers the toggle only when both sides look like Mermaid', () => { - const diff = useDiffStore() - load(diff, mmd(' A --> B'), mmd(' A --> C')) - expect(diff.canCompareDiagram).toBe(true) - - load(diff, mmd(' A --> B'), 'just some text') - expect(diff.canCompareDiagram).toBe(false) - }) - - it('calls itself Diagram, not Structure', () => { - const diff = useDiffStore() - load(diff, mmd(' A --> B'), mmd(' A --> C')) - expect(diff.structureLabel).toBe('Diagram') - }) - - it('routes to the diagram viewer only with the toggle on', () => { - const diff = useDiffStore() - load(diff, mmd(' A --> B'), mmd(' A --> C')) - diff.semanticView = false - expect(diff.comparableKind).toBe('text') - diff.semanticView = true - expect(diff.comparableKind).toBe('diagram') - }) - - it('never offers it for a streamed comparison', () => { - const diff = useDiffStore() - load(diff, mmd(' A --> B'), mmd(' A --> C')) - diff.left = { ...diff.left, kind: 'streamed' } - expect(diff.canCompareDiagram).toBe(false) - }) -}) - -// Pasted text is a comparison like any other — comparePasted() fills left/right, -// so the Diagram toggle must be offered there too. -describe('diagram comparison from pasted text', () => { - it('offers the diagram view after comparing two pasted diagrams', () => { - const diff = useDiffStore() - diff.mode = 'paste' - diff.pasteLeft = 'flowchart TD\n A --> B' - diff.pasteRight = 'flowchart TD\n A --> C' - diff.comparePasted() - expect(diff.canCompareDiagram).toBe(true) - diff.semanticView = true - expect(diff.comparableKind).toBe('diagram') - }) -}) - -// The bundle carried `session` from the start; without this it was sealed into -// the archive and silently dropped on the way back. -describe('config backup — session round trip', () => { - it('collects the session into the bundle and writes it back on restore', async () => { - const diff = useDiffStore() - savePersisted('session', '{"tabs":["a"]}') - let sent = null - window.api.backupConfig = async (bundle) => { - sent = bundle - return { ok: true, path: '/tmp/x' } - } - await diff.runConfigBackup('passphrase-long-enough') - expect(sent.session).toBe('{"tabs":["a"]}') - - savePersisted('session', '{"tabs":["different"]}') - window.api.restoreConfig = async () => ({ ok: true, session: sent.session }) - await diff.runConfigRestore('passphrase-long-enough') - // Written to persistence, not applied live: replacing the comparisons the - // reader is looking at mid-restore is not what they asked for. - expect(loadPersisted('session')).toBe('{"tabs":["a"]}') - }) -})