Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion desktop/src/__test__/main/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ const mockApp = {
}
return `/mock/${name}`
}),
getName: jest.fn(() => 'TestApp')
getName: jest.fn(() => 'TestApp'),
isPackaged: true,
getAppPath: jest.fn(() => '/mock/appPath')
}

jest.mock('electron', () => ({
Expand Down
134 changes: 134 additions & 0 deletions desktop/src/__test__/main/findInPage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import type { WebContents } from 'electron'
import { IpcMessage } from '../../ui/ipcMessages'

type IpcHandlerFn = (...args: any[]) => any

/** Captured ipcMain.handle / ipcMain.on callbacks for assertions. */
const ipcHandlers = new Map<string, IpcHandlerFn>()

jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn((channel: string, handler: (...args: any[]) => any) => {
ipcHandlers.set(channel, handler)
}),
on: jest.fn((channel: string, handler: (...args: any[]) => any) => {
ipcHandlers.set(`on:${channel}`, handler)
})
},
BrowserWindow: {
fromWebContents: jest.fn()
},
BrowserView: jest.fn()
}))

function makeWebContents (partial: {
id: number
destroyed?: boolean
findInPage?: jest.Mock
stopFindInPage?: jest.Mock
send?: jest.Mock
}): WebContents {
return {
id: partial.id,
isDestroyed: () => partial.destroyed ?? false,
findInPage: partial.findInPage ?? jest.fn(),
stopFindInPage: partial.stopFindInPage ?? jest.fn(),
send: partial.send ?? jest.fn()
} as unknown as WebContents
}

describe('findInPage main IPC', () => {
let originalConsoleError: typeof console.error
let registerFindInPageIpcHandlers: () => void
let registerFindInPageTarget: (overlayWc: WebContents, pageWc: WebContents) => void

beforeEach(async () => {
ipcHandlers.clear()
jest.resetModules()
originalConsoleError = console.error
console.error = jest.fn()
const m = await import('../../main/findInPage')
registerFindInPageIpcHandlers = m.registerFindInPageIpcHandlers
registerFindInPageTarget = m.registerFindInPageTarget
registerFindInPageIpcHandlers()
})

afterEach(() => {
console.error = originalConsoleError
})

test('FindInPage clears selection and returns -1 for empty text', async () => {
const stopFindInPage = jest.fn()
const findInPage = jest.fn()
const sender = makeWebContents({ id: 10, stopFindInPage, findInPage })
const handler = ipcHandlers.get(IpcMessage.FindInPage)
expect(handler).toBeDefined()
const result = await handler?.({ sender }, '', {})
expect(result).toBe(-1)
expect(stopFindInPage).toHaveBeenCalledWith('clearSelection')
expect(findInPage).not.toHaveBeenCalled()
})

test('FindInPage runs on page webContents when overlay is registered as invoker', async () => {
const pageFindInPage = jest.fn().mockResolvedValue(7)
const pageStop = jest.fn()
const pageWc = makeWebContents({ id: 1, findInPage: pageFindInPage, stopFindInPage: pageStop })
const overlayWc = makeWebContents({ id: 2 })
registerFindInPageTarget(overlayWc, pageWc)

const handler = ipcHandlers.get(IpcMessage.FindInPage)
const result = await handler?.({ sender: overlayWc }, 'needle', { forward: true, findNext: false })
expect(result).toBe(7)
expect(pageFindInPage).toHaveBeenCalledWith('needle', { forward: true, findNext: false })
})

test('FindInPage returns -1 when target webContents is destroyed', async () => {
const findInPage = jest.fn()
const sender = makeWebContents({ id: 20, destroyed: true, findInPage })
const handler = ipcHandlers.get(IpcMessage.FindInPage)
const result = await handler?.({ sender }, 'x', {})
expect(result).toBe(-1)
expect(findInPage).not.toHaveBeenCalled()
})

test('FindInPage returns -1 when findInPage throws', async () => {
const findInPage = jest.fn().mockImplementation(() => {
throw new Error('find failed')
})
const sender = makeWebContents({ id: 30, findInPage })
const handler = ipcHandlers.get(IpcMessage.FindInPage)
const result = await handler?.({ sender }, 'x', {})
expect(result).toBe(-1)
})

test('StopFindInPage no-ops when webContents is destroyed', async () => {
const stopFindInPage = jest.fn()
const sender = makeWebContents({ id: 40, destroyed: true, stopFindInPage })
const handler = ipcHandlers.get(IpcMessage.StopFindInPage)
await handler?.({ sender }, 'clearSelection')
expect(stopFindInPage).not.toHaveBeenCalled()
})

test('StopFindInPage forwards to resolveFindTarget', async () => {
const stopFindInPage = jest.fn()
const sender = makeWebContents({ id: 50, stopFindInPage })
const handler = ipcHandlers.get(IpcMessage.StopFindInPage)
await handler?.({ sender }, 'keepSelection')
expect(stopFindInPage).toHaveBeenCalledWith('keepSelection')
})
})
45 changes: 45 additions & 0 deletions desktop/src/__test__/main/path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import * as nodePath from 'path'
import { getBundledUiDistPath, getFileInPublicBundledFolder } from '../../main/path'

const mockGetAppPath = jest.fn(() => '/mock/appPath')

jest.mock('electron', () => ({
app: {
getAppPath: (): string => mockGetAppPath()
}
}))

describe('path (bundled UI)', () => {
beforeEach(() => {
mockGetAppPath.mockReturnValue('/mock/appPath')
})

test('getBundledUiDistPath joins getAppPath with dist/ui', () => {
mockGetAppPath.mockReturnValue('/Applications/Huly.app/Contents/Resources/app.asar')
expect(getBundledUiDistPath()).toBe(
nodePath.join('/Applications/Huly.app/Contents/Resources/app.asar', 'dist', 'ui')
)
})

test('getFileInPublicBundledFolder nests under public', () => {
mockGetAppPath.mockReturnValue('/repo/desktop')
expect(getFileInPublicBundledFolder('AppIcon.png')).toBe(
nodePath.join('/repo/desktop', 'dist', 'ui', 'public', 'AppIcon.png')
)
})
})
1 change: 1 addition & 0 deletions desktop/src/main/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { OptionValues, program } from 'commander'
program
.name('Huly')
.allowUnknownOption()
.allowExcessArguments(true)
.option('-s, --server <url>', 'Remote server URL (front). E.g. https://huly.app')

let opts: OptionValues | null = null
Expand Down
20 changes: 15 additions & 5 deletions desktop/src/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,23 @@ export interface PackedConfig {
function readConfigFile (filePath: string): PackedConfig | undefined {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as PackedConfig
} catch (err) {
console.error(`Failed to read config from ${filePath}:`, err)
} catch (err: unknown) {
const code = err != null && typeof err === 'object' && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined
if (code !== 'ENOENT') {
console.error(`Failed to read config from ${filePath}:`, err)
}
return undefined
}
}

/** Packaged app: extraResources `config/config.json`. Dev: webpack `public/` → `dist/ui/public/`. */
function getBundledResourcesConfigPath (): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'config', 'config.json')
}
return path.join(app.getAppPath(), 'dist', 'ui', 'public', 'config', 'config.json')
}

/**
* Writes a JSON config file, logging errors.
*/
Expand All @@ -55,7 +66,7 @@ function writeConfigFile (filePath: string, config: PackedConfig): boolean {
function migrateConfigIfNeeded (): void {
try {
const userDataConfigPath = path.join(app.getPath('userData'), 'config.json')
const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json')
const resourcesConfigPath = getBundledResourcesConfigPath()

const userDataDir = app.getPath('userData')
if (!fs.existsSync(userDataDir)) {
Expand Down Expand Up @@ -109,6 +120,5 @@ export function readPackedConfig (): PackedConfig | undefined {
}

// Fallback to bundled config if userData config doesn't exist
const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json')
return readConfigFile(resourcesConfigPath)
return readConfigFile(getBundledResourcesConfigPath())
}
6 changes: 5 additions & 1 deletion desktop/src/main/customMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@

import { BrowserWindow } from 'electron'
import { MenuBarAction, CommandLogout, CommandSelectWorkspace, CommandOpenSettings } from '../ui/types'
import { OsIntegration } from './osIntegration'
import { IpcMessage } from '../ui/ipcMessages'
import { OsIntegration } from './osIntegration'
import { openFindInPageBar } from './findInPage'

export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, action: MenuBarAction, os: OsIntegration | undefined): void {
if (mainWindow == null) {
Expand Down Expand Up @@ -67,6 +68,9 @@ export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, ac
case 'select-all':
mainWindow.webContents.selectAll()
break
case 'find':
openFindInPageBar(mainWindow)
break
case 'reload':
mainWindow?.reload()
break
Expand Down
Loading
Loading