diff --git a/app/containers/appContainer/index.js b/app/containers/appContainer/index.js
index 4683fc68..b39359e2 100644
--- a/app/containers/appContainer/index.js
+++ b/app/containers/appContainer/index.js
@@ -4,12 +4,11 @@ import { connect } from 'react-redux'
import electronBridge from '../../utilities/electronBridge'
import { updateUpdateAvailableBarStatus } from '../../actions/index'
import AboutPage from '../aboutPage'
-import Dashboard from '../dashboard'
import FindInPage, { isFindInPageAvailable } from '../findInPage'
import LoginPage from '../loginPage'
import NavigationPanel from '../navigationPanel'
import NavigationPanelDetails from '../navigationPanelDetails'
-import React, { Component } from 'react'
+import React, { Component, lazy, Suspense } from 'react'
import SearchPage from '../searchPage'
import SnippetPanel from '../snippetPanel'
import { Pane, SplitPane } from 'react-split-pane'
@@ -20,6 +19,7 @@ import './index.scss'
import './scrollbar.scss'
const conf = electronBridge.config
+const Dashboard = lazy(() => import(/* webpackChunkName: "dashboard" */ '../dashboard'))
const themeManager = new ThemeManager()
themeManager.setTheme(conf.get('theme'))
@@ -129,9 +129,12 @@ export class AppContainer extends Component {
}
renderDashboard () {
- const { updateDashboardStatus } = this.props
+ const { dashboardModalStatus, updateDashboardStatus } = this.props
+ if (dashboardModalStatus !== 'ON') return null
return (
-
${contentTable}
`
}
- renderCodeArea (filename, content, lang, kTabLength, siblingFiles) {
+ getRenderedContent (filename, content, lang, kTabLength, siblingFiles) {
+ // Keep only this component's most recent result. Callback changes and
+ // unrelated UI updates must not reparse Markdown, highlight code, or make
+ // synchronous notebook IPC calls for unchanged content.
+ const inputs = [filename, content, lang, kTabLength, siblingFiles, this.state.asciiDoc]
+ if (this.renderedContent && inputs.every((value, index) => value === this.renderedContent.inputs[index])) {
+ return this.renderedContent.html
+ }
const language = adaptedLanguage(filename, lang)
+ this.renderFailed = false
let htmlContent = ''
switch (language) {
case 'Jupyter Notebook':
@@ -293,6 +324,13 @@ export default class CodeArea extends Component {
default:
htmlContent = this.createHighlightedCodeBlock(content, language, kTabLength)
}
+ // Preserve retry behavior for transient renderer/IPC failures.
+ this.renderedContent = this.renderFailed ? null : { inputs, html: htmlContent }
+ return htmlContent
+ }
+
+ renderCodeArea (filename, content, lang, kTabLength, siblingFiles) {
+ const htmlContent = this.getRenderedContent(filename, content, lang, kTabLength, siblingFiles)
return (
${text}
` } +})) + +// CodeArea selects a theme through CommonJS; Node needs a CSS stub for that +// import. Electron smoke tests verify the real stylesheet and rendering. +const require = createRequire(import.meta.url) +const cssLoader = require.extensions['.css'] +require.extensions['.css'] = () => {} +let CodeArea +try { + CodeArea = (await import('../../app/containers/codeArea')).default +} finally { + if (cssLoader) require.extensions['.css'] = cssLoader + else delete require.extensions['.css'] +} + +function createCodeArea (language) { + const component = new CodeArea({ filename: 'example', language, content: 'original' }) + component.setState = vi.fn(update => { Object.assign(component.state, update) }) + return component +} + +describe('on-demand AsciiDoc rendering', () => { + it('does not load the converter for ordinary snippets, then loads on a format change', async () => { + const component = createCodeArea('JavaScript') + component.componentDidMount() + expect(component.asciiDocLoad).toBeUndefined() + component.props = { ...component.props, language: 'AsciiDoc' } + component.componentDidUpdate() + await component.asciiDocLoad + expect(component.createAsciiDocCodeBlock('latest', 'AsciiDoc', 4)).toContain('latest
') + expect(component.setState).toHaveBeenCalledTimes(1) + component.componentDidUpdate() + expect(component.setState).toHaveBeenCalledTimes(1) + }) + + it('keeps readable source while loading and does not update an unmounted component', async () => { + const component = createCodeArea('AsciiDoc') + expect(component.createAsciiDocCodeBlock('plain source', 'AsciiDoc', 4)).toContain('plain source') + component.componentDidMount() + component.componentWillUnmount() + await component.asciiDocLoad + expect(component.setState).not.toHaveBeenCalled() + }) +}) + +describe('snippet render caching', () => { + it('retries notebook rendering after an IPC failure instead of caching the fallback', () => { + mocks.notebookRender.mockReturnValueOnce({ status: false, error: 'Unavailable' }) + .mockReturnValue({ status: true, html: 'Recovered notebook
' }) + const component = createCodeArea('Jupyter Notebook') + const render = () => component.getRenderedContent('example.ipynb', '{}', 'Jupyter Notebook', 4) + expect(render()).not.toContain('Recovered notebook') + expect(render()).toContain('Recovered notebook') + expect(render()).toContain('Recovered notebook') + expect(mocks.notebookRender).toHaveBeenCalledTimes(2) + }) + + it('reuses unchanged code and invalidates when content or rendering inputs change', () => { + const component = createCodeArea('JavaScript') + const highlight = vi.spyOn(component, 'createHighlightedCodeBlock') + const files = {} + const first = component.getRenderedContent('example.js', 'const a = 1', 'JavaScript', 4, files) + expect(component.getRenderedContent('example.js', 'const a = 1', 'JavaScript', 4, files)).toBe(first) + expect(highlight).toHaveBeenCalledTimes(1) + component.getRenderedContent('example.js', 'const a = 2', 'JavaScript', 4, files) + component.getRenderedContent('example.js', 'const a = 2', 'JavaScript', 2, files) + component.getRenderedContent('example.py', 'const a = 2', 'Python', 2, files) + expect(highlight).toHaveBeenCalledTimes(4) + }) + + it('invalidates Markdown when sibling attachments change', () => { + const component = createCodeArea('Markdown') + const markdown = vi.spyOn(component, 'createMarkdownCodeBlock') + const files = {} + component.getRenderedContent('notes.md', '', 'Markdown', 4, files) + component.getRenderedContent('notes.md', '', 'Markdown', 4, files) + expect(markdown).toHaveBeenCalledTimes(1) + component.getRenderedContent('notes.md', '', 'Markdown', 4, { 'image.png': {} }) + expect(markdown).toHaveBeenCalledTimes(2) + }) + + it('replaces cached source after the AsciiDoc converter loads', async () => { + const component = createCodeArea('AsciiDoc') + const initial = component.getRenderedContent('example.adoc', 'Example', 'AsciiDoc', 4) + expect(initial).not.toContain('asciidoc-section') + component.componentDidMount() + await component.asciiDocLoad + expect(component.getRenderedContent('example.adoc', 'Example', 'AsciiDoc', 4)).toContain('Example
') + }) + + it('avoids repeated notebook IPC while keeping only the latest result', () => { + const component = createCodeArea('Jupyter Notebook') + const notebook = vi.spyOn(component, 'createJupyterNotebookCodeBlock').mockImplementation(content => content) + component.getRenderedContent('example.ipynb', 'first', 'Jupyter Notebook', 4) + component.getRenderedContent('example.ipynb', 'first', 'Jupyter Notebook', 4) + expect(notebook).toHaveBeenCalledTimes(1) + component.getRenderedContent('example.ipynb', 'second', 'Jupyter Notebook', 4) + component.getRenderedContent('example.ipynb', 'first', 'Jupyter Notebook', 4) + expect(notebook).toHaveBeenCalledTimes(3) + }) +}) diff --git a/tests/containers/compatPanel.test.js b/tests/containers/compatPanel.test.js index a7383bff..a9269518 100644 --- a/tests/containers/compatPanel.test.js +++ b/tests/containers/compatPanel.test.js @@ -67,4 +67,20 @@ describe('React 19 panel compatibility', () => { expect(container.querySelector('.collapse').className).toContain('file-collapse') expect(container.textContent).toContain('Expanded file') }) + + it('retains the same mounted content while collapsed and expanded', () => { + const render = expanded => act(() => { + root.render(h(Collapse, { in: expanded }, h('pre', null, 'Keep mounted'))) + }) + render(false) + const content = container.querySelector('pre') + expect(content.textContent).toBe('Keep mounted') + expect(container.querySelector('.collapse').classList.contains('in')).toBe(false) + render(true) + expect(container.querySelector('pre')).toBe(content) + expect(container.querySelector('.collapse').classList.contains('in')).toBe(true) + render(false) + expect(container.querySelector('pre')).toBe(content) + expect(container.querySelector('.collapse').classList.contains('in')).toBe(false) + }) }) diff --git a/tests/containers/criticalFlow.test.js b/tests/containers/criticalFlow.test.js new file mode 100644 index 00000000..d93974dd --- /dev/null +++ b/tests/containers/criticalFlow.test.js @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ settings: {}, get: vi.fn() })) +vi.mock('../../app/utilities/electronBridge', () => ({ + default: { + config: { get: mocks.get }, + logger: { info: vi.fn(), error: vi.fn() }, + ipc: {}, + clipboard: {} + } +})) +vi.mock('../../app/utilities/snippetApi', () => ({ + getSnippetProvider: () => ({ type: 'github' }) +})) +vi.mock('../../app/containers/gistEditor', () => ({ default: () => null })) + +import ConnectedList from '../../app/containers/navigationPanelDetails' +import ConnectedSearch from '../../app/containers/searchPage' +import GistEditorForm from '../../app/containers/gistEditorForm' +import { createDefaultGistFilters } from '../../app/utilities/gistFilters' + +const List = ConnectedList.WrappedComponent +const Search = ConnectedSearch.WrappedComponent + +function synchronousState (component) { + component.setState = vi.fn((update, callback) => { + Object.assign(component.state, typeof update === 'function' ? update(component.state) : update) + if (callback) callback() + }) + return component +} + +beforeEach(() => { + mocks.settings = { 'snippet:sorting': 'description', 'snippet:sortingReverse': false } + mocks.get.mockImplementation(key => mocks.settings[key]) +}) + +describe('everyday snippet navigation', () => { + function makeList () { + const ids = Array.from({ length: 100 }, (_, index) => String(index)) + const gists = Object.fromEntries(ids.map(id => [id, { + brief: { id, description: `[Title ${id.padStart(3, '0')}] #common`, public: false, files: { 'helper.js': {} } } + }])) + return new List({ gists, gistTags: { 'lang@All': ids, common: ids }, activeGistFilters: createDefaultGistFilters(), activeGist: '0' }) + } + + it('reads settings per list rather than per row and reuses metadata on selection', () => { + const list = makeList() + const entries = list.getSnippetEntries() + expect(entries).toHaveLength(100) + expect(mocks.get).toHaveBeenCalledTimes(3) + list.props = { ...list.props, activeGist: '1' } + expect(list.getSnippetEntries()).toBe(entries) + const rows = list.renderSnippetThumbnails() + expect(rows[0].props.active).toBe(false) + expect(rows[1].props.active).toBe(true) + }) + + it('refreshes titles, sort preferences, and tags when their inputs change', () => { + const list = makeList() + const initial = list.getSnippetEntries() + mocks.settings['snippet:sortingReverse'] = true + expect(list.getSnippetEntries()[0].id).toBe('99') + mocks.settings['tag:showInSnippetList'] = true + mocks.settings['tag:colored'] = true + expect(list.getSnippetEntries()[0].tags).toContain('common') + list.props = { ...list.props, gists: { ...list.props.gists, 99: { brief: { ...list.props.gists[99].brief, description: '[Zebra]' } } } } + expect(list.getSnippetEntries()[0].title).toBe('Zebra') + expect(list.getSnippetEntries()).not.toBe(initial) + }) + + it('handles tag entries whose snippets have not arrived during sync', () => { + const list = makeList() + list.props = { ...list.props, gists: {} } + expect(list.getSnippetEntries()).toEqual([]) + }) +}) + +describe('search interaction', () => { + it('searches once per changed input, including paste, and not on arrow navigation', () => { + const fuseSearch = vi.fn(() => [{ id: 'a' }, { id: 'b' }]) + const search = synchronousState(new Search({ searchIndex: { fuseSearch } })) + search.updateInputValue({ target: { value: 'pasted query' } }) + expect(fuseSearch).toHaveBeenCalledExactlyOnceWith('pasted query') + expect(search.state.inputValue).toBe('pasted query') + search.handleKeyDown({ keyCode: 40, preventDefault: vi.fn() }) + expect(search.state.selectedIndex).toBe(1) + expect(fuseSearch).toHaveBeenCalledTimes(1) + search.updateInputValue({ target: { value: '' } }) + expect(fuseSearch).toHaveBeenCalledTimes(2) + expect(search.state.selectedIndex).toBe(0) + }) +}) + +describe('editor input reconciliation', () => { + it('skips initial-content serialization during typing but still accepts new initial data', () => { + const initialData = { get description () { return 'Original' }, gists: [{ filename: 'a.js', content: 'original' }] } + const form = synchronousState(new GistEditorForm({ initialData })) + const read = vi.spyOn(initialData, 'description', 'get') + form.componentDidUpdate({ initialData }) + expect(read).not.toHaveBeenCalled() + form.props = { initialData: { description: 'Replacement', gists: [{ filename: 'b.js', content: 'replacement' }] } } + form.componentDidUpdate({ initialData }) + expect(form.state.values.description).toBe('Replacement') + expect(form.state.values.gistFiles[0].content).toBe('replacement') + }) +}) diff --git a/tests/containers/editorViewport.test.js b/tests/containers/editorViewport.test.js new file mode 100644 index 00000000..50c03829 --- /dev/null +++ b/tests/containers/editorViewport.test.js @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { needsBoundedViewport } from '../../app/containers/gistEditor/viewport' + +describe('editor viewport sizing', () => { + it('keeps ordinary snippets at their natural height', () => { + expect(needsBoundedViewport()).toBe(false) + expect(needsBoundedViewport('const value = 42')).toBe(false) + expect(needsBoundedViewport('x\n'.repeat(199))).toBe(false) + expect(needsBoundedViewport('x'.repeat(20000))).toBe(false) + }) + + it('bounds both long documents and very long wrapped lines', () => { + expect(needsBoundedViewport('x\n'.repeat(200))).toBe(true) + expect(needsBoundedViewport('x\r\n'.repeat(200))).toBe(true) + expect(needsBoundedViewport('x'.repeat(20001))).toBe(true) + }) +}) diff --git a/tests/performance/README.md b/tests/performance/README.md new file mode 100644 index 00000000..72f3bc9c --- /dev/null +++ b/tests/performance/README.md @@ -0,0 +1,26 @@ +# Local performance profiles + +Build the production renderer, then run the profiles on the same machine and +Electron version before and after a change: + +```sh +npm run webpack-prod +node node_modules/electron/cli.js tests/performance/electron-flow.cjs +node node_modules/electron/cli.js tests/performance/electron-search.cjs +``` + +Each run uses isolated configuration and user data. Repeat runs and compare +medians; do not run competing builds during measurement. Set +`LEPTON_PROFILE_REPO` to the path of another checkout with its own production +bundle and the same `memory-flow` and `memory-search` fixtures to compare it +with the same harness. + +The flow profile uses a 100-snippet fixture and measures the first Markdown +render, editor open, Vue mode, typing, and process working sets before and +after editing. The search profile uses 1,000 snippets of 100 lines each and +measures result rendering, a two-token search, heap, DOM nodes, and process +working sets. Working sets include shared pages, so their sum is an upper-bound +indicator rather than the app's unique physical memory. On macOS, the profiles +also report the system `footprint` summary when available; it includes GPU +allocations and may be unavailable without permission. These fixtures do not +measure authentication or network fetches. diff --git a/tests/performance/electron-flow.cjs b/tests/performance/electron-flow.cjs new file mode 100644 index 00000000..0b1d4105 --- /dev/null +++ b/tests/performance/electron-flow.cjs @@ -0,0 +1,128 @@ +// Run after npm run webpack-prod: node node_modules/electron/cli.js tests/performance/electron-flow.cjs +const fs = require('fs') +const os = require('os') +const path = require('path') +const { app, BrowserWindow } = require('electron') +const physicalFootprint = require('./physicalFootprint.cjs') + +const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lepton-performance-')) +process.env.XDG_CONFIG_HOME = profileDir +process.env.LEPTON_RENDER_FIXTURE = 'memory-flow' +fs.writeFileSync(path.join(profileDir, '.leptonrc'), JSON.stringify({ autoUpdate: false, logger: { level: 'error' } })) +app.setPath('userData', path.join(profileDir, 'user-data')) +process.on('exit', () => { + try { fs.rmSync(profileDir, { recursive: true, force: true }) } catch (_) {} +}) + +const repo = path.resolve(process.env.LEPTON_PROFILE_REPO || path.join(__dirname, '../..')) +const started = performance.now() +app.getAppPath = () => repo +require(path.join(repo, 'main.js')) +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + +app.whenReady().then(async () => { + try { + while (!BrowserWindow.getAllWindows().length) await wait(10) + const window = BrowserWindow.getAllWindows()[0] + if (window.webContents.isLoading()) { + await new Promise(resolve => window.webContents.once('did-finish-load', resolve)) + } + const run = expression => window.webContents.executeJavaScript(expression, true) + await run(`(async () => { + const deadline = Date.now() + 30000; + const isReady = () => document.querySelectorAll('.snippet-thumnail-list-item').length === 100 && document.querySelector('.code-table'); + while (!isReady() && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + if (!isReady()) throw Error('Browsing fixture did not render'); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + })()`) + const startupMs = performance.now() - started + window.webContents.debugger.attach('1.3') + await window.webContents.debugger.sendCommand('Performance.enable') + const memory = async () => { + await window.webContents.debugger.sendCommand('HeapProfiler.collectGarbage') + await wait(150) + const metrics = app.getAppMetrics() + const processes = metrics.map(metric => ({ + type: metric.type, + serviceName: metric.serviceName, + workingSetKiB: metric.memory.workingSetSize + })) + // Working sets include shared pages; their sum is not unique physical memory. + return { + processes, + physicalFootprintBytes: physicalFootprint(metrics), + sumWorkingSetKiB: processes.reduce((total, metric) => total + metric.workingSetKiB, 0) + } + } + const initialMemory = await memory() + const firstUse = await run(`(async () => { + const poll = async (condition, message) => { + const deadline = performance.now() + 10000; + while (!condition() && performance.now() < deadline) await new Promise(resolve => setTimeout(resolve, 1)); + if (!condition()) throw Error(message); + }; + const click = index => document.querySelectorAll('.snippet-thumnail-list-item > div')[index].click(); + const start = performance.now(); + click(1); + await poll(() => document.querySelector('.markdown-section h1')?.textContent === 'Profile Markdown', 'Markdown did not render'); + const markdownMs = performance.now() - start; + click(0); + await poll(() => document.querySelector('.code-table')?.textContent.includes('const value0 = 0;'), 'Snippet did not render'); + const editStart = performance.now(); + document.querySelector('.snippet-control[title="Edit"]').click(); + await poll(() => document.querySelector('.edit-modal .CodeMirror'), 'Editor did not render'); + const cm = document.querySelector('.edit-modal .CodeMirror').CodeMirror; + await poll(() => cm.getMode().name === 'javascript', 'JavaScript mode did not load'); + const editorMs = performance.now() - editStart; + const input = document.querySelector('.gist-editor-filename-area'); + const modeStart = performance.now(); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'component.vue'); + input.dispatchEvent(new Event('input', { bubbles: true })); + await poll(() => cm.getMode().name === 'vue', 'Vue mode did not load'); + const vueMs = performance.now() - modeStart; + const typing = []; + for (let index = 0; index < 12; index++) { + const typingStart = performance.now(); + cm.replaceRange('a', cm.getCursor()); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + typing.push(performance.now() - typingStart); + } + return { markdownMs, editorMs, vueMs, typing }; + })()`) + const afterMemory = await memory() + await run("document.querySelector('.edit-modal .close').click()") + await wait(200) + const closedMemory = await memory() + const startMetrics = await window.webContents.debugger.sendCommand('Performance.getMetrics') + const switching = await run(`(async () => { + const times = []; + for (let index = 2; index < 14; index++) { + const start = performance.now(); + document.querySelectorAll('.snippet-thumnail-list-item > div')[index].click(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + if (!document.querySelector('.code-table').textContent.includes('const value0 = ' + index + ';')) { + throw Error('Wrong snippet after selection'); + } + times.push(performance.now() - start); + } + return times; + })()`) + const endMetrics = await window.webContents.debugger.sendCommand('Performance.getMetrics') + const metricDelta = name => { + const before = startMetrics.metrics.find(metric => metric.name === name).value + const after = endMetrics.metrics.find(metric => metric.name === name).value + return (after - before) * 1000 + } + console.log('PROFILE=' + JSON.stringify({ + startupMs, initialMemory, firstUse, afterMemory, closedMemory, switching, + switchScriptMs: metricDelta('ScriptDuration'), + switchTaskMs: metricDelta('TaskDuration') + })) + app.exit(0) + } catch (error) { + console.error(error) + app.exit(1) + } +}) diff --git a/tests/performance/electron-search.cjs b/tests/performance/electron-search.cjs new file mode 100644 index 00000000..3bdbdc00 --- /dev/null +++ b/tests/performance/electron-search.cjs @@ -0,0 +1,88 @@ +// Run after npm run webpack-prod: node node_modules/electron/cli.js tests/performance/electron-search.cjs +const fs = require('fs') +const os = require('os') +const path = require('path') +const { app, BrowserWindow } = require('electron') +const physicalFootprint = require('./physicalFootprint.cjs') + +const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lepton-performance-')) +process.env.XDG_CONFIG_HOME = profileDir +process.env.LEPTON_RENDER_FIXTURE = 'memory-search' +fs.writeFileSync(path.join(profileDir, '.leptonrc'), JSON.stringify({ autoUpdate: false, logger: { level: 'error' } })) +app.setPath('userData', path.join(profileDir, 'user-data')) +process.on('exit', () => { + try { fs.rmSync(profileDir, { recursive: true, force: true }) } catch (_) {} +}) + +const repo = path.resolve(process.env.LEPTON_PROFILE_REPO || path.join(__dirname, '../..')) +const started = performance.now() +app.getAppPath = () => repo +require(path.join(repo, 'main.js')) +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + +app.whenReady().then(async () => { + try { + while (!BrowserWindow.getAllWindows().length) await wait(10) + const window = BrowserWindow.getAllWindows()[0] + if (window.webContents.isLoading()) { + await new Promise(resolve => window.webContents.once('did-finish-load', resolve)) + } + const ui = await window.webContents.executeJavaScript(`(async () => { + const deadline = Date.now() + 30000; + while (document.querySelectorAll('.result-group > li').length !== 1000 && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + const rows = document.querySelectorAll('.result-group > li').length; + if (rows !== 1000) throw Error('Expected 1000 search results, got ' + rows); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return { + rows, + count: document.querySelector('.search-result-count').textContent, + match: document.querySelector('.search-match-excerpt').textContent + }; + })()`) + const readyMs = performance.now() - started + await wait(500) + window.webContents.debugger.attach('1.3') + await window.webContents.debugger.sendCommand('HeapProfiler.collectGarbage') + await wait(150) + const heap = await window.webContents.debugger.sendCommand('Runtime.getHeapUsage') + const dom = await window.webContents.debugger.sendCommand('Memory.getDOMCounters') + const metrics = app.getAppMetrics() + const processes = metrics.map(metric => ({ + type: metric.type, + serviceName: metric.serviceName, + workingSetKiB: metric.memory.workingSetSize + })) + const physicalFootprintBytes = physicalFootprint(metrics) + const queryMs = await window.webContents.executeJavaScript(`(async () => { + const input = document.querySelector('.search-box'); + const start = performance.now(); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'const value0'); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: '0', keyCode: 48 })); + const deadline = Date.now() + 30000; + const isUpdated = () => Array.from(document.querySelectorAll('.search-match-excerpt .search-match-highlight')) + .some(node => node.textContent === 'value0'); + while (!isUpdated() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 2)); + if (!isUpdated()) throw Error('Updated search did not render'); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return performance.now() - start; + })()`) + console.log('PROFILE=' + JSON.stringify({ + readyMs, + queryMs, + ui, + heapBytes: heap.usedSize, + domNodes: dom.nodes, + processes, + physicalFootprintBytes, + // Working sets include shared pages; their sum is not unique physical memory. + sumWorkingSetKiB: processes.reduce((total, metric) => total + metric.workingSetKiB, 0) + })) + app.exit(0) + } catch (error) { + console.error(error) + app.exit(1) + } +}) diff --git a/tests/performance/physicalFootprint.cjs b/tests/performance/physicalFootprint.cjs new file mode 100644 index 00000000..ae394da8 --- /dev/null +++ b/tests/performance/physicalFootprint.cjs @@ -0,0 +1,12 @@ +const { spawnSync } = require('child_process') + +// macOS's footprint tool accounts for memory not visible in resident-set sums, +// including GPU allocations. Other platforms can still use working-set data. +module.exports = function physicalFootprint (metrics) { + if (process.platform !== 'darwin') return null + const args = ['--noCategories', '-f', 'bytes', ...metrics.flatMap(metric => ['-p', String(metric.pid)])] + const result = spawnSync('/usr/bin/footprint', args, { encoding: 'utf8', timeout: 10000 }) + if (result.error || result.status !== 0) return null + const match = result.stdout.match(/Summary Footprint:\s*(\d+)\s*B/) + return match ? Number(match[1]) : null +} diff --git a/tests/smoke/electron-packaged-smoke.js b/tests/smoke/electron-packaged-smoke.js index 8d68a7c3..43f39204 100644 --- a/tests/smoke/electron-packaged-smoke.js +++ b/tests/smoke/electron-packaged-smoke.js @@ -41,6 +41,26 @@ function createTempHome (locale = 'en') { } const PACKAGED_RENDER_FIXTURES = [ + { + name: 'large-editor', + selector: '.gist-editor-viewport .CodeMirror', + text: 'Large snippet fixture|large.js' + }, + { + name: 'edit', + selector: '.modal .gist-editor-form .CodeMirror', + text: 'Edit' + }, + { + name: 'new', + selector: '.modal .gist-editor-form .CodeMirror', + text: 'New' + }, + { + name: 'asciidoc', + selector: '.asciidoc-section strong', + text: 'On-demand AsciiDoc|Formatted after loading' + }, { name: 'active', selector: '.profile-image-section', diff --git a/tests/smoke/electron-render-smoke-main.js b/tests/smoke/electron-render-smoke-main.js index 3d592197..b58d3490 100644 --- a/tests/smoke/electron-render-smoke-main.js +++ b/tests/smoke/electron-render-smoke-main.js @@ -1390,6 +1390,101 @@ async function assertFixtureImageAttachments (window) { } } +async function assertEverydayInteractions (window) { + const fixture = process.env.LEPTON_RENDER_FIXTURE + if (!['everyday', 'everyday-search'].includes(fixture)) return + await window.webContents.executeJavaScript(`(async () => { + const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + if (${JSON.stringify(fixture)} === 'everyday') { + for (const index of [1, 50, 99, 0]) { + document.querySelectorAll('.snippet-thumnail-list-item > div')[index].click(); + await frame(); + const selected = document.querySelector('.active-snippet-thumnail-private, .active-snippet-thumnail-public'); + if (!selected.textContent.includes('Utility ' + String(index).padStart(3, '0'))) throw Error('Wrong sidebar selection'); + if (!document.querySelector('.code-table').textContent.includes('const value0 = ' + index + ';')) throw Error('Wrong snippet content'); + } + } else { + const input = document.querySelector('.search-box'); + // Model input/paste without a keyup event. + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'Utility 099'); + input.dispatchEvent(new Event('input', { bubbles: true })); + await frame(); + if (document.querySelectorAll('.result-group > li').length !== 1) throw Error('Search input did not update results'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + await frame(); + if (document.querySelector('.search-box')) throw Error('Search selection did not close search'); + if (!document.querySelector('.code-table').textContent.includes('const value0 = 99;')) throw Error('Search opened wrong snippet'); + } + })()`) +} + +async function assertLargeSnippetRendering (window) { + const fixture = process.env.LEPTON_RENDER_FIXTURE + if (!['large-collapsed', 'large-editor'].includes(fixture)) return + + if (fixture === 'large-collapsed') { + await window.webContents.executeJavaScript(`(async () => { + const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const toggle = document.querySelector('.file-expand'); + const content = document.querySelector('.code-area'); + if (!content || content.getClientRects().length) throw Error('Collapsed file is missing or visible'); + for (let cycle = 0; cycle < 2; cycle++) { + toggle.click(); + await frame(); + const rows = content.querySelectorAll('.code-table tr'); + if (document.querySelector('.code-area') !== content || !content.getClientRects().length || + rows.length !== 10000 || !rows[9999].textContent.includes('item9999')) throw Error('Expanded file lost its mounted content'); + toggle.click(); + await frame(); + if (document.querySelector('.code-area') !== content || content.getClientRects().length) throw Error('Collapsed file did not retain hidden content'); + } + })()`) + return + } + + await window.webContents.executeJavaScript(`(async () => { + const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const cm = document.querySelector('.CodeMirror').CodeMirror; + const original = cm.getValue(); + const assertViewport = () => { + const bounds = cm.getWrapperElement().getBoundingClientRect(); + const visibleLines = document.querySelectorAll('.CodeMirror-code > div').length; + if (cm.lineCount() !== 10000 || cm.getOption('viewportMargin') !== 10 || visibleLines > 200 || bounds.height > 650) { + throw Error('Large editor is not bounded: ' + JSON.stringify({ visibleLines, height: bounds.height })); + } + }; + assertViewport(); + cm.setValue('const small = true'); + await frame(); + if (document.querySelector('.gist-editor-viewport') || cm.getOption('viewportMargin') !== Infinity) throw Error('Small editor did not restore natural height'); + cm.setValue(original); + await frame(); + assertViewport(); + cm.setCursor({ line: 9999, ch: cm.getLine(9999).length }); + cm.scrollIntoView(cm.getCursor(), 40); + cm.focus(); + await frame(); + if (cm.getViewport().to < 10000) throw Error('Last line is not reachable'); + })()`) + window.focus() + window.webContents.focus() + await window.webContents.executeJavaScript("document.querySelector('.CodeMirror').CodeMirror.focus()", true) + await window.webContents.insertText(' // viewport smoke') + await window.webContents.executeJavaScript(`(async () => { + const cm = document.querySelector('.CodeMirror').CodeMirror; + const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const deadline = Date.now() + 2000; + while (!cm.getLine(9999).endsWith(' // viewport smoke') && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 20)); + if (cm.lineCount() !== 10000 || !cm.getLine(9999).endsWith(' // viewport smoke')) throw Error('Typing at the end of the large file failed: ' + JSON.stringify({ line: cm.getLine(9999), focused: cm.hasFocus(), cursor: cm.getCursor() })); + cm.undo(); + await frame(); + if (cm.getLine(9999) !== 'const item9999 = "example 9999";') throw Error('Undo lost file content'); + cm.setCursor({ line: 0, ch: 0 }); + cm.scrollIntoView(cm.getCursor()); + await frame(); + })()`) +} + async function main () { let window @@ -1402,6 +1497,8 @@ async function main () { await waitForFixtureUi(window) await applyFixtureFilterClicks(window) assertFixtureRendererState(await getRendererState(window)) + await assertEverydayInteractions(window) + await assertLargeSnippetRendering(window) await assertFixtureLoginModeSwitch(window) await assertFixturePageFind(window) if (process.env.LEPTON_RENDER_FIXTURE === 'new-draft') { diff --git a/tests/smoke/electron-render-smoke.js b/tests/smoke/electron-render-smoke.js index cca72ab9..3f0492c3 100644 --- a/tests/smoke/electron-render-smoke.js +++ b/tests/smoke/electron-render-smoke.js @@ -38,6 +38,31 @@ function createTempHome (locale = 'en', configOverrides = {}) { } const RENDER_FIXTURES = [ + { + name: 'everyday', + selector: '.code-table', + text: 'Utility 000|helper.js' + }, + { + name: 'everyday-search', + selector: '.search-result-count', + text: '100 snippets found' + }, + { + name: 'large-collapsed', + selector: '.file-header .file-expand', + text: 'Large snippet fixture|large.js' + }, + { + name: 'large-editor', + selector: '.gist-editor-viewport .CodeMirror', + text: 'Large snippet fixture|large.js' + }, + { + name: 'asciidoc', + selector: '.asciidoc-section strong', + text: 'On-demand AsciiDoc|Formatted after loading' + }, { name: 'active', selector: '.active-layout .snippet-box', diff --git a/tests/utilities/search.test.js b/tests/utilities/search.test.js index ef2b0c04..60f78f62 100644 --- a/tests/utilities/search.test.js +++ b/tests/utilities/search.test.js @@ -100,6 +100,22 @@ describe('search utility', () => { ]) }) + it('retains only excerpt highlights for content matches while searching the entire file', () => { + const content = Array.from({ length: 100 }, (_, i) => `const helper${i} = ${i};`).join('\n') + SearchIndex.resetFuseIndex([SearchIndex.buildSearchRecord({ + id: 'many-lines', description: 'Utility', + files: { 'helper.js': { filename: 'helper.js', language: 'JavaScript', content } } + })]) + const [result] = SearchIndex.fuseSearch('const helper99') + expect(result.id).toBe('many-lines') + const match = result.searchMeta.matches.find(match => match.key === 'files.content') + expect(match.segments).toBe(match.excerpt.segments) + expect(match.segments.length).toBeLessThan(10) + expect(match.excerpt.segments).toContainEqual({ text: 'const', match: true }) + expect(SearchIndex.fuseSearch('helper99')[0].searchMeta.bestMatch.excerpt.segments) + .toContainEqual({ text: 'helper99', match: true }) + }) + it('keeps image filenames searchable without indexing Base64 payloads', () => { const imageContent = Buffer.from([ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, diff --git a/webpack.config.js b/webpack.config.js index 9253667d..b3c96f70 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -34,7 +34,7 @@ module.exports = { loader: 'babel-loader', options: { presets: [ - ['@babel/preset-env', { modules: false }], + ['@babel/preset-env', { modules: false, targets: { electron: '42' } }], '@babel/preset-react' ] } @@ -43,6 +43,8 @@ module.exports = { use: [ 'style-loader', 'css-loader', + // Run after Sass, before css-loader resolves and emits font assets. + path.resolve(__dirname, 'configs/loaders/woff2Only.js'), { loader: 'sass-loader', options: {