fmt(js): npm run fix on merge (#118250)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
github-actions[bot]
parent
5bb314fa01
commit
bc655bfb40
@@ -418,7 +418,9 @@ describe('htmlResponseError', () => {
|
||||
expect(redirected).toContain('to https://sso.example.com/login?next=%2Fapi%2Fprofiles')
|
||||
expect(redirected).toMatch(/authentication proxy/)
|
||||
expect(redirected).not.toContain('endpoint is likely missing')
|
||||
expect(htmlResponseError('https://gateway.example.com/api/profiles', 307).message).toMatch(/redirected \(status 307\)\. This is usually/)
|
||||
expect(htmlResponseError('https://gateway.example.com/api/profiles', 307).message).toMatch(
|
||||
/redirected \(status 307\)\. This is usually/
|
||||
)
|
||||
expect(htmlResponseError('https://gateway.example.com/api/missing', 200).message).toContain(
|
||||
'endpoint is likely missing'
|
||||
)
|
||||
@@ -437,6 +439,8 @@ describe('htmlResponseError', () => {
|
||||
expect(message).not.toMatch(/authentication proxy/)
|
||||
}
|
||||
|
||||
expect(htmlResponseError('https://gateway.example.com/api/profiles', 302, 'https://gateway.example.com/login').message).toMatch(/authentication proxy/)
|
||||
expect(
|
||||
htmlResponseError('https://gateway.example.com/api/profiles', 302, 'https://gateway.example.com/login').message
|
||||
).toMatch(/authentication proxy/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -145,10 +145,16 @@ test('exit line carries the buffered tail next to the exit code, preferring the
|
||||
formatBackendExitLine('Ignoring stale Hermes backend exit', 1, null, tail),
|
||||
'Ignoring stale Hermes backend exit (1)\nRecent backend output:\nTraceback (most recent call last):'
|
||||
)
|
||||
assert.equal(formatBackendExitLine('Hermes backend exited', null, 'SIGTERM', tail), 'Hermes backend exited (SIGTERM)\nRecent backend output:\nTraceback (most recent call last):')
|
||||
assert.equal(
|
||||
formatBackendExitLine('Hermes backend exited', null, 'SIGTERM', tail),
|
||||
'Hermes backend exited (SIGTERM)\nRecent backend output:\nTraceback (most recent call last):'
|
||||
)
|
||||
})
|
||||
|
||||
test('exit line stays byte-identical to the legacy shape when the tail is empty or missing', () => {
|
||||
assert.equal(formatBackendExitLine('Hermes backend exited', 0, null, createBackendOutputTail(64)), 'Hermes backend exited (0)')
|
||||
assert.equal(
|
||||
formatBackendExitLine('Hermes backend exited', 0, null, createBackendOutputTail(64)),
|
||||
'Hermes backend exited (0)'
|
||||
)
|
||||
assert.equal(formatBackendExitLine('Hermes backend exited', 1, null, null), 'Hermes backend exited (1)')
|
||||
})
|
||||
|
||||
@@ -39,8 +39,7 @@ test('a running backend record makes startup attach and spawn zero processes', a
|
||||
|
||||
const setup = await runPrimaryBackendStartup({
|
||||
assertCurrentAttempt: () => {},
|
||||
attachHostBackend: () =>
|
||||
attachToHostBackend({ isolated: false, ledgerPath: '/ledger.json' }, attachDeps(LEDGER)),
|
||||
attachHostBackend: () => attachToHostBackend({ isolated: false, ledgerPath: '/ledger.json' }, attachDeps(LEDGER)),
|
||||
connectRemote: async () => ({ mode: 'remote' }),
|
||||
ensureLocalRuntime: async backend => backend,
|
||||
prepareLocalBackend: () => {
|
||||
|
||||
@@ -30,8 +30,7 @@ export interface HostBackendRecord {
|
||||
}
|
||||
|
||||
export type SpawnOrAttachDecision =
|
||||
| { action: 'attach'; record: HostBackendRecord }
|
||||
| { action: 'spawn'; reason: 'isolated' | 'no-running-backend' }
|
||||
{ action: 'attach'; record: HostBackendRecord } | { action: 'spawn'; reason: 'isolated' | 'no-running-backend' }
|
||||
|
||||
/** Filename the CLI writes under the machine Hermes root. */
|
||||
export const SPAWN_LEDGER_FILENAME = 'spawn-ledger.json'
|
||||
|
||||
@@ -34,8 +34,8 @@ const NODE_BIN = process.execPath
|
||||
test('execProbe keeps the parent event loop available to the child', async () => {
|
||||
let unexpectedSocketError: Error | undefined
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
socket.on('error', (error) => {
|
||||
const server = net.createServer(socket => {
|
||||
socket.on('error', error => {
|
||||
// A successful child exits immediately after reading the sentinel. On
|
||||
// Windows that peer close can surface as ECONNRESET on the server side.
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ECONNRESET') {
|
||||
@@ -71,7 +71,7 @@ test('execProbe keeps the parent event loop available to the child', async () =>
|
||||
})
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
server.close(error => (error ? reject(error) : resolve()))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -746,7 +746,9 @@ async function fetchManifest({
|
||||
// the installer's colour/OSC bytes here too (#112675).
|
||||
const tail = stripAnsi(result.stderr || result.stdout).trim()
|
||||
|
||||
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${tail}`)
|
||||
throw new Error(
|
||||
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${tail}`
|
||||
)
|
||||
}
|
||||
|
||||
// The manifest is the LAST JSON line on stdout (install.ps1 may print
|
||||
|
||||
@@ -16,10 +16,12 @@ test('a stale team falls back once to current memberships; unrelated failures re
|
||||
requests.push(req.url!)
|
||||
const scoped = req.url!.includes('?org=')
|
||||
res.writeHead(scoped ? status : fallbackStatus, { 'Content-Type': 'application/json' })
|
||||
|
||||
const body =
|
||||
fallbackStatus === 200
|
||||
? { agents: [], org: { id: 'new-team' } }
|
||||
: { error: 'org_selection_required', orgs: [{ id: 'new-team' }] }
|
||||
|
||||
res.end(JSON.stringify(scoped ? { error } : body))
|
||||
})
|
||||
|
||||
|
||||
@@ -22,7 +22,11 @@ describe('Command screenshot capture', () => {
|
||||
expect(await capture.take(8, request)).toEqual({ ok: false, reason: 'expired' })
|
||||
expect(sources).not.toHaveBeenCalled()
|
||||
expect(await capture.take(7, request)).toEqual({ ok: true, png })
|
||||
expect(sources).toHaveBeenCalledWith({ types: ['window'], thumbnailSize: { width: 2400, height: 1600 }, fetchWindowIcons: false })
|
||||
expect(sources).toHaveBeenCalledWith({
|
||||
types: ['window'],
|
||||
thumbnailSize: { width: 2400, height: 1600 },
|
||||
fetchWindowIcons: false
|
||||
})
|
||||
expect(await capture.take(7, request)).toEqual({ ok: false, reason: 'expired' })
|
||||
|
||||
sources.mockResolvedValue([{ id: 'window:99:0', thumbnail: { isEmpty: () => false, toPNG: () => png } }])
|
||||
@@ -37,6 +41,7 @@ describe('Command screenshot capture', () => {
|
||||
permission.mockReturnValue(true)
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const expired = capture.request(7, window)!
|
||||
vi.advanceTimersByTime(6000)
|
||||
@@ -46,7 +51,12 @@ describe('Command screenshot capture', () => {
|
||||
}
|
||||
|
||||
let finish!: (value: Awaited<ReturnType<typeof sources>>) => void
|
||||
sources.mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
sources.mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const pending = capture.take(7, capture.request(7, window)!)
|
||||
expect(capture.request(7, window)).toBeNull()
|
||||
capture.clear()
|
||||
|
||||
@@ -62,6 +62,7 @@ export function createScreenshotCapture({ getSources, hasScreenPermission }: Cap
|
||||
// Retina detail, bounded for very large windows. No display fallback:
|
||||
// sharing the desktop would expose content the gesture did not select.
|
||||
const scale = Math.min(2, 4096 / Math.max(window.width, window.height))
|
||||
|
||||
const sources = await getSources({
|
||||
types: ['window'],
|
||||
thumbnailSize: { width: Math.ceil(window.width * scale), height: Math.ceil(window.height * scale) },
|
||||
@@ -72,7 +73,9 @@ export function createScreenshotCapture({ getSources, hasScreenPermission }: Cap
|
||||
return { ok: false, reason: 'expired' }
|
||||
}
|
||||
|
||||
const source = sources.find(item => item.id.split(':')[0] === 'window' && item.id.split(':')[1] === String(window.windowId))
|
||||
const source = sources.find(
|
||||
item => item.id.split(':')[0] === 'window' && item.id.split(':')[1] === String(window.windowId)
|
||||
)
|
||||
|
||||
if (!source || source.thumbnail.isEmpty()) {
|
||||
return { ok: false, reason: 'unavailable' }
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type CommandScreenshotCapture,
|
||||
CommandScreenshotMonitor,
|
||||
type CommandScreenshotStatus,
|
||||
resolveCommandScreenshotMonitorPath,
|
||||
resolveCommandScreenshotMonitorPath
|
||||
} from './command-screenshot-monitor'
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
@@ -27,14 +27,22 @@ test('launches the unpacked helper without prompting and delivers only validated
|
||||
const spawn = vi.fn((_command: string, _args: string[], _options: SpawnOptions) => child)
|
||||
const captures: CommandScreenshotCapture[] = []
|
||||
const statuses: CommandScreenshotStatus[] = []
|
||||
|
||||
const monitor = new CommandScreenshotMonitor({
|
||||
platform: 'darwin', appPath: '/Applications/Hermes.app/Contents/Resources/app.asar', spawn,
|
||||
platform: 'darwin',
|
||||
appPath: '/Applications/Hermes.app/Contents/Resources/app.asar',
|
||||
spawn
|
||||
})
|
||||
monitor.start(value => captures.push(value), value => statuses.push(value))
|
||||
|
||||
monitor.start(
|
||||
value => captures.push(value),
|
||||
value => statuses.push(value)
|
||||
)
|
||||
assert.equal(spawn.mock.calls.length, 1)
|
||||
assert.deepEqual(spawn.mock.calls[0], [
|
||||
'/Applications/Hermes.app/Contents/Resources/app.asar.unpacked/dist/native/command-screenshot-monitor',
|
||||
[], { stdio: ['pipe', 'pipe', 'ignore'], shell: false, detached: false, windowsHide: true },
|
||||
[],
|
||||
{ stdio: ['pipe', 'pipe', 'ignore'], shell: false, detached: false, windowsHide: true }
|
||||
])
|
||||
child.stdout.write('{"type":"capture","windowId":2,"width":100,"height":200}\n')
|
||||
assert.deepEqual(captures, []) // No capture until readiness is established.
|
||||
@@ -55,21 +63,34 @@ test('launches the unpacked helper without prompting and delivers only validated
|
||||
|
||||
test('bounds startup and termination, preserves permission failures, and isolates restarts', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const first = new FakeChild()
|
||||
const second = new FakeChild()
|
||||
const spawn = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(second)
|
||||
const statuses: CommandScreenshotStatus[] = []
|
||||
const captures: CommandScreenshotCapture[] = []
|
||||
|
||||
const monitor = new CommandScreenshotMonitor({
|
||||
platform: 'darwin', spawn, startupTimeoutMs: 100, stopTimeoutMs: 50,
|
||||
platform: 'darwin',
|
||||
spawn,
|
||||
startupTimeoutMs: 100,
|
||||
stopTimeoutMs: 50
|
||||
})
|
||||
monitor.start(value => captures.push(value), value => statuses.push(value), true)
|
||||
|
||||
monitor.start(
|
||||
value => captures.push(value),
|
||||
value => statuses.push(value),
|
||||
true
|
||||
)
|
||||
assert.deepEqual(spawn.mock.calls[0][1], ['--request-permission'])
|
||||
first.stdout.write('{"type":"error","code":"permission-required"}\n')
|
||||
assert.deepEqual(statuses.at(-1), { type: 'error', code: 'permission-required' })
|
||||
assert.equal(first.stdin.writableEnded, true)
|
||||
monitor.start(value => captures.push(value), value => statuses.push(value))
|
||||
monitor.start(
|
||||
value => captures.push(value),
|
||||
value => statuses.push(value)
|
||||
)
|
||||
first.stdout.write('{"type":"ready"}\n{"type":"capture","windowId":1,"width":1,"height":1}\n')
|
||||
assert.deepEqual(captures, [])
|
||||
vi.advanceTimersByTime(50)
|
||||
@@ -91,25 +112,34 @@ test('stopping from the starting callback cancels the child before it can become
|
||||
const child = new FakeChild()
|
||||
const statuses: CommandScreenshotStatus[] = []
|
||||
const monitor = new CommandScreenshotMonitor({ platform: 'darwin', spawn: () => child })
|
||||
monitor.start(() => assert.fail('stopped monitor delivered a capture'), status => {
|
||||
statuses.push(status)
|
||||
if (status.type === 'starting') {
|
||||
monitor.stop()
|
||||
monitor.start(
|
||||
() => assert.fail('stopped monitor delivered a capture'),
|
||||
status => {
|
||||
statuses.push(status)
|
||||
|
||||
if (status.type === 'starting') {
|
||||
monitor.stop()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
assert.equal(child.kill.mock.calls[0]?.[0], 'SIGTERM')
|
||||
child.stdout.write('{"type":"ready"}\n')
|
||||
child.emit('close', 0, null)
|
||||
assert.deepEqual(statuses, [{ type: 'starting' }, { type: 'stopped' }])
|
||||
})
|
||||
|
||||
test.skipIf(process.platform !== 'darwin')('native state machine requires distinct keys, a clean chord and full release', () => {
|
||||
const dir = mkdtempSync(resolve(tmpdir(), 'hermes-command-monitor-test-'))
|
||||
try {
|
||||
const fixture = resolve(dir, 'gesture.m')
|
||||
const binary = resolve(dir, 'gesture')
|
||||
// Compile the real state machine; no posted input events, screen pixels or TCC prompts.
|
||||
writeFileSync(fixture, `
|
||||
test.skipIf(process.platform !== 'darwin')(
|
||||
'native state machine requires distinct keys, a clean chord and full release',
|
||||
() => {
|
||||
const dir = mkdtempSync(resolve(tmpdir(), 'hermes-command-monitor-test-'))
|
||||
|
||||
try {
|
||||
const fixture = resolve(dir, 'gesture.m')
|
||||
const binary = resolve(dir, 'gesture')
|
||||
// Compile the real state machine; no posted input events, screen pixels or TCC prompts.
|
||||
writeFileSync(
|
||||
fixture,
|
||||
`
|
||||
#define COMMAND_SCREENSHOT_MONITOR_TEST 1
|
||||
#include "${resolve(import.meta.dirname, 'native/command-screenshot-monitor.m')}"
|
||||
#include <assert.h>
|
||||
@@ -188,60 +218,94 @@ int main(void) { @autoreleasepool {
|
||||
assert(CommandCaptureWindow(windows, 0) == nil);
|
||||
puts("native gesture assertions passed");
|
||||
} return 0; }
|
||||
`)
|
||||
execFileSync('xcrun', ['clang', '-fobjc-arc', '-fblocks', '-framework', 'Cocoa', '-framework', 'CoreGraphics', fixture, '-o', binary], { timeout: 30_000 })
|
||||
assert.equal(execFileSync(binary, [], { encoding: 'utf8', timeout: 5_000 }).trim(), 'native gesture assertions passed')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}, 40_000)
|
||||
`
|
||||
)
|
||||
execFileSync(
|
||||
'xcrun',
|
||||
['clang', '-fobjc-arc', '-fblocks', '-framework', 'Cocoa', '-framework', 'CoreGraphics', fixture, '-o', binary],
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
assert.equal(
|
||||
execFileSync(binary, [], { encoding: 'utf8', timeout: 5_000 }).trim(),
|
||||
'native gesture assertions passed'
|
||||
)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
40_000
|
||||
)
|
||||
|
||||
test.skipIf(process.platform !== 'darwin')('builds a universal helper with a read-only permission check and real controller lifecycle', async () => {
|
||||
const dir = mkdtempSync(resolve(tmpdir(), 'hermes-command-monitor-build-'))
|
||||
try {
|
||||
const script = resolve(import.meta.dirname, '../scripts/build-command-screenshot-monitor.mjs')
|
||||
execFileSync(process.execPath, [script, '--out-dir', resolve(dir, 'dist')], { timeout: 60_000 })
|
||||
const binary = resolveCommandScreenshotMonitorPath(dir)
|
||||
const architectures = execFileSync('xcrun', ['lipo', '-archs', binary], { encoding: 'utf8' }).trim().split(/\s+/).sort()
|
||||
assert.deepEqual(architectures, ['arm64', 'x86_64'])
|
||||
const result = spawnSync(binary, ['--check'], { encoding: 'utf8', timeout: 5_000 })
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(result.stderr, '')
|
||||
const permission = JSON.parse(result.stdout)
|
||||
assert.deepEqual(permission, result.status === 0
|
||||
? { type: 'ready' } : { type: 'error', code: 'permission-required' })
|
||||
assert.ok(result.status === 0 || result.status === 2)
|
||||
const invalid = spawnSync(binary, ['--check', '--request-permission'], { encoding: 'utf8', timeout: 5_000 })
|
||||
assert.equal(invalid.status, 64)
|
||||
assert.deepEqual(JSON.parse(invalid.stdout), { type: 'error', code: 'unavailable' })
|
||||
let closed: Promise<NodeJS.Signals | null> | undefined
|
||||
const monitor = new CommandScreenshotMonitor({
|
||||
appPath: dir,
|
||||
spawn: (command, args, options) => {
|
||||
const child = nodeSpawn(command, args, options)
|
||||
closed = new Promise(settle => child.once('close', (_code, signal) => settle(signal)))
|
||||
return child
|
||||
},
|
||||
})
|
||||
const statuses: CommandScreenshotStatus[] = []
|
||||
const terminal = await new Promise<CommandScreenshotStatus>((settle, reject) => {
|
||||
const timeout = setTimeout(() => { monitor.stop(); reject(new Error('real monitor did not settle')) }, 10_000)
|
||||
monitor.start(() => {}, status => {
|
||||
statuses.push(status)
|
||||
if (status.type === 'ready' || status.type === 'error') {
|
||||
clearTimeout(timeout)
|
||||
settle(status)
|
||||
test.skipIf(process.platform !== 'darwin')(
|
||||
'builds a universal helper with a read-only permission check and real controller lifecycle',
|
||||
async () => {
|
||||
const dir = mkdtempSync(resolve(tmpdir(), 'hermes-command-monitor-build-'))
|
||||
|
||||
try {
|
||||
const script = resolve(import.meta.dirname, '../scripts/build-command-screenshot-monitor.mjs')
|
||||
execFileSync(process.execPath, [script, '--out-dir', resolve(dir, 'dist')], { timeout: 60_000 })
|
||||
const binary = resolveCommandScreenshotMonitorPath(dir)
|
||||
const architectures = execFileSync('xcrun', ['lipo', '-archs', binary], { encoding: 'utf8' })
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.sort()
|
||||
assert.deepEqual(architectures, ['arm64', 'x86_64'])
|
||||
const result = spawnSync(binary, ['--check'], { encoding: 'utf8', timeout: 5_000 })
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(result.stderr, '')
|
||||
const permission = JSON.parse(result.stdout)
|
||||
assert.deepEqual(
|
||||
permission,
|
||||
result.status === 0 ? { type: 'ready' } : { type: 'error', code: 'permission-required' }
|
||||
)
|
||||
assert.ok(result.status === 0 || result.status === 2)
|
||||
const invalid = spawnSync(binary, ['--check', '--request-permission'], { encoding: 'utf8', timeout: 5_000 })
|
||||
assert.equal(invalid.status, 64)
|
||||
assert.deepEqual(JSON.parse(invalid.stdout), { type: 'error', code: 'unavailable' })
|
||||
let closed: Promise<NodeJS.Signals | null> | undefined
|
||||
|
||||
const monitor = new CommandScreenshotMonitor({
|
||||
appPath: dir,
|
||||
spawn: (command, args, options) => {
|
||||
const child = nodeSpawn(command, args, options)
|
||||
closed = new Promise(settle => child.once('close', (_code, signal) => settle(signal)))
|
||||
|
||||
return child
|
||||
}
|
||||
})
|
||||
})
|
||||
assert.equal(statuses[0].type, 'starting')
|
||||
if (permission.type === 'error') {
|
||||
assert.deepEqual(terminal, permission)
|
||||
|
||||
const statuses: CommandScreenshotStatus[] = []
|
||||
|
||||
const terminal = await new Promise<CommandScreenshotStatus>((settle, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
monitor.stop()
|
||||
reject(new Error('real monitor did not settle'))
|
||||
}, 10_000)
|
||||
monitor.start(
|
||||
() => {},
|
||||
status => {
|
||||
statuses.push(status)
|
||||
|
||||
if (status.type === 'ready' || status.type === 'error') {
|
||||
clearTimeout(timeout)
|
||||
settle(status)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
assert.equal(statuses[0].type, 'starting')
|
||||
|
||||
if (permission.type === 'error') {
|
||||
assert.deepEqual(terminal, permission)
|
||||
}
|
||||
|
||||
monitor.stop()
|
||||
assert.ok(closed)
|
||||
assert.notEqual(await closed, 'SIGKILL') // the real helper exits without escalation
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
monitor.stop()
|
||||
assert.ok(closed)
|
||||
assert.notEqual(await closed, 'SIGKILL') // the real helper exits without escalation
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}, 75_000)
|
||||
},
|
||||
75_000
|
||||
)
|
||||
|
||||
@@ -11,8 +11,7 @@ export interface CommandScreenshotCapture extends ScreenshotWindow {
|
||||
}
|
||||
|
||||
export type CommandScreenshotStatus =
|
||||
| { type: 'starting' | 'ready' | 'stopped' }
|
||||
| { type: 'error'; code: 'permission-required' | 'unavailable' }
|
||||
{ type: 'starting' | 'ready' | 'stopped' } | { type: 'error'; code: 'permission-required' | 'unavailable' }
|
||||
|
||||
interface MonitorChild extends EventEmitter {
|
||||
stdin: Writable | null
|
||||
@@ -29,39 +28,56 @@ interface MonitorOptions {
|
||||
}
|
||||
|
||||
export function resolveCommandScreenshotMonitorPath(
|
||||
appPath = resolve(dirname(fileURLToPath(import.meta.url)), '..'),
|
||||
appPath = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
): string {
|
||||
// Executables cannot run inside ASAR. dist/** is already explicitly unpacked.
|
||||
return resolve(appPath, 'dist/native/command-screenshot-monitor')
|
||||
.replace(/\.asar(?=[/\\])/g, '.asar.unpacked')
|
||||
return resolve(appPath, 'dist/native/command-screenshot-monitor').replace(/\.asar(?=[/\\])/g, '.asar.unpacked')
|
||||
}
|
||||
|
||||
function parseMessage(line: string): CommandScreenshotCapture | CommandScreenshotStatus | null {
|
||||
let value: unknown
|
||||
|
||||
try {
|
||||
value = JSON.parse(line)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const message = value as Record<string, unknown>
|
||||
|
||||
if (message.type === 'ready') {
|
||||
return { type: 'ready' }
|
||||
}
|
||||
|
||||
if (message.type === 'error' && (message.code === 'permission-required' || message.code === 'unavailable')) {
|
||||
return { type: 'error', code: message.code }
|
||||
}
|
||||
|
||||
if (message.type !== 'capture') {
|
||||
return null
|
||||
}
|
||||
|
||||
const { windowId, width, height } = message
|
||||
if (typeof windowId !== 'number' || !Number.isInteger(windowId) || windowId <= 0 || windowId > 0xffffffff
|
||||
|| typeof width !== 'number' || !Number.isFinite(width) || width <= 0
|
||||
|| typeof height !== 'number' || !Number.isFinite(height) || height <= 0) {
|
||||
|
||||
if (
|
||||
typeof windowId !== 'number' ||
|
||||
!Number.isInteger(windowId) ||
|
||||
windowId <= 0 ||
|
||||
windowId > 0xffffffff ||
|
||||
typeof width !== 'number' ||
|
||||
!Number.isFinite(width) ||
|
||||
width <= 0 ||
|
||||
typeof height !== 'number' ||
|
||||
!Number.isFinite(height) ||
|
||||
height <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { type: 'capture', windowId, width, height }
|
||||
}
|
||||
|
||||
@@ -75,41 +91,53 @@ export class CommandScreenshotMonitor {
|
||||
start(
|
||||
onCapture: (capture: CommandScreenshotCapture) => void,
|
||||
onStatus: (status: CommandScreenshotStatus) => void,
|
||||
requestPermission = false,
|
||||
requestPermission = false
|
||||
): void {
|
||||
this.stop()
|
||||
|
||||
if ((this.options.platform ?? process.platform) !== 'darwin') {
|
||||
onStatus({ type: 'error', code: 'unavailable' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let child: MonitorChild
|
||||
|
||||
try {
|
||||
child = (this.options.spawn ?? nodeSpawn)(resolveCommandScreenshotMonitorPath(this.options.appPath),
|
||||
child = (this.options.spawn ?? nodeSpawn)(
|
||||
resolveCommandScreenshotMonitorPath(this.options.appPath),
|
||||
requestPermission ? ['--request-permission'] : [],
|
||||
{ stdio: ['pipe', 'pipe', 'ignore'], shell: false, detached: false, windowsHide: true })
|
||||
{ stdio: ['pipe', 'pipe', 'ignore'], shell: false, detached: false, windowsHide: true }
|
||||
)
|
||||
} catch {
|
||||
onStatus({ type: 'error', code: 'unavailable' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let ready = false
|
||||
let active = true
|
||||
let pending = ''
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const startupTimer = setTimeout(() => fail(), this.options.startupTimeoutMs ?? (requestPermission ? 60_000 : 5_000))
|
||||
startupTimer.unref()
|
||||
|
||||
const dispose = () => {
|
||||
active = false
|
||||
pending = ''
|
||||
clearTimeout(startupTimer)
|
||||
child.stdout?.removeListener('data', onData)
|
||||
|
||||
if (this.cleanup === stop) {
|
||||
this.cleanup = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const terminate = (status: CommandScreenshotStatus) => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
dispose()
|
||||
child.stdin?.end() // EOF also stops the helper if the parent exits unexpectedly.
|
||||
child.kill('SIGTERM')
|
||||
@@ -117,34 +145,47 @@ export class CommandScreenshotMonitor {
|
||||
killTimer.unref()
|
||||
onStatus(status)
|
||||
}
|
||||
|
||||
const fail = () => terminate({ type: 'error', code: 'unavailable' })
|
||||
|
||||
const onData = (chunk: Buffer | string) => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (chunk.length > 65_536) {
|
||||
fail()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pending += chunk.toString()
|
||||
const lines = pending.split('\n')
|
||||
pending = lines.pop() ?? ''
|
||||
|
||||
if (pending.length > 4_096) {
|
||||
fail()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (!active) {
|
||||
break
|
||||
}
|
||||
|
||||
if (line.length > 4_096) {
|
||||
fail()
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
const message = parseMessage(line)
|
||||
|
||||
if (!message) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.type === 'capture') {
|
||||
if (ready) {
|
||||
onCapture(message)
|
||||
@@ -158,6 +199,7 @@ export class CommandScreenshotMonitor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
const unexpected = active
|
||||
dispose()
|
||||
@@ -166,10 +208,12 @@ export class CommandScreenshotMonitor {
|
||||
child.removeListener('error', fail)
|
||||
child.stdin?.removeListener('error', fail)
|
||||
child.stdout?.removeListener('error', fail)
|
||||
|
||||
if (unexpected) {
|
||||
onStatus({ type: 'error', code: 'unavailable' })
|
||||
}
|
||||
}
|
||||
|
||||
const stop = () => terminate({ type: 'stopped' })
|
||||
this.cleanup = stop
|
||||
child.stdout?.on('data', onData)
|
||||
@@ -178,6 +222,7 @@ export class CommandScreenshotMonitor {
|
||||
child.once('close', onClose)
|
||||
child.on('error', fail)
|
||||
onStatus({ type: 'starting' })
|
||||
|
||||
if (!child.stdout || !child.stdin) {
|
||||
fail()
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ export interface ScreenshotWindow {
|
||||
}
|
||||
|
||||
export type ScreenshotResult =
|
||||
| { ok: true; png: Uint8Array }
|
||||
| { ok: false; reason: 'expired' | 'screen-permission' | 'unavailable' }
|
||||
{ ok: true; png: Uint8Array } | { ok: false; reason: 'expired' | 'screen-permission' | 'unavailable' }
|
||||
|
||||
export interface ScreenshotApi {
|
||||
getSettings(): Promise<ScreenshotStatus>
|
||||
|
||||
@@ -6,8 +6,19 @@ import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const native = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn() }))
|
||||
const electron = vi.hoisted(() => ({ handlers: new Map(), windows: [] as any[], focused: null as any, screenPermission: 'granted', directory: '' }))
|
||||
vi.mock('./command-screenshot-monitor', () => ({ CommandScreenshotMonitor: class { start = native.start; stop = native.stop } }))
|
||||
const electron = vi.hoisted(() => ({
|
||||
handlers: new Map(),
|
||||
windows: [] as any[],
|
||||
focused: null as any,
|
||||
screenPermission: 'granted',
|
||||
directory: ''
|
||||
}))
|
||||
vi.mock('./command-screenshot-monitor', () => ({
|
||||
CommandScreenshotMonitor: class {
|
||||
start = native.start
|
||||
stop = native.stop
|
||||
}
|
||||
}))
|
||||
vi.mock('electron', async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
const ipcMain = new EventEmitter() as any
|
||||
@@ -22,7 +33,11 @@ vi.mock('electron', async () => {
|
||||
getFocusedWindow: () => electron.focused,
|
||||
getAllWindows: () => electron.windows
|
||||
},
|
||||
desktopCapturer: { getSources: vi.fn(async () => [{ id: 'window:42:0', thumbnail: { isEmpty: () => false, toPNG: () => new Uint8Array([1]) } }]) },
|
||||
desktopCapturer: {
|
||||
getSources: vi.fn(async () => [
|
||||
{ id: 'window:42:0', thumbnail: { isEmpty: () => false, toPNG: () => new Uint8Array([1]) } }
|
||||
])
|
||||
},
|
||||
systemPreferences: { getMediaAccessStatus: () => electron.screenPermission },
|
||||
shell: { openExternal: vi.fn() }
|
||||
}
|
||||
@@ -44,7 +59,13 @@ afterEach(async () => {
|
||||
|
||||
function window(id: number, url = 'http://127.0.0.1:5174/') {
|
||||
const frame = { url }
|
||||
const wc = Object.assign(new EventEmitter(), { id, mainFrame: frame, getURL: () => url, isDestroyed: () => false, send: vi.fn() })
|
||||
const wc = Object.assign(new EventEmitter(), {
|
||||
id,
|
||||
mainFrame: frame,
|
||||
getURL: () => url,
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn()
|
||||
})
|
||||
const win = { webContents: wc, isDestroyed: () => false }
|
||||
electron.windows.push(win)
|
||||
|
||||
@@ -56,7 +77,8 @@ async function setup() {
|
||||
cleanups.push(installCommandScreenshot({ rendererUrl: 'http://127.0.0.1:5174/' }))
|
||||
}
|
||||
|
||||
const call = (channel: string, event: unknown, ...args: unknown[]) => electron.handlers.get(`hermes:screenshot:${channel}`)(event, ...args)
|
||||
const call = (channel: string, event: unknown, ...args: unknown[]) =>
|
||||
electron.handlers.get(`hermes:screenshot:${channel}`)(event, ...args)
|
||||
|
||||
describe.skipIf(process.platform !== 'darwin')('Command screenshot native bridge', () => {
|
||||
it('persists opt-in, routes to the last focused subscribed window while backgrounded, and revokes on disable', async () => {
|
||||
@@ -71,7 +93,9 @@ describe.skipIf(process.platform !== 'darwin')('Command screenshot native bridge
|
||||
app.emit('browser-window-focus', {}, second.win)
|
||||
electron.focused = null
|
||||
await call('settings:set', first.event, true)
|
||||
expect(JSON.parse(await readFile(path.join(electron.directory, 'screenshot.json'), 'utf8'))).toEqual({ enabled: true })
|
||||
expect(JSON.parse(await readFile(path.join(electron.directory, 'screenshot.json'), 'utf8'))).toEqual({
|
||||
enabled: true
|
||||
})
|
||||
const [capture, status] = native.start.mock.calls.at(-1)!
|
||||
status({ type: 'ready' })
|
||||
capture({ type: 'capture', windowId: 42, width: 600, height: 400 })
|
||||
@@ -82,10 +106,12 @@ describe.skipIf(process.platform !== 'darwin')('Command screenshot native bridge
|
||||
await call('settings:set', second.event, false)
|
||||
expect(await call('capture', second.event, requests[0]![1])).toEqual({ ok: false, reason: 'expired' })
|
||||
expect(native.stop).toHaveBeenCalled()
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
ipcMain.emit('hermes:screenshot:subscribe', second.event, false)
|
||||
ipcMain.emit('hermes:screenshot:subscribe', second.event, true)
|
||||
}
|
||||
|
||||
expect(second.wc.listenerCount('destroyed')).toBe(1)
|
||||
ipcMain.emit('hermes:screenshot:subscribe', second.event, false)
|
||||
expect(second.wc.listenerCount('destroyed')).toBe(0)
|
||||
@@ -96,13 +122,17 @@ describe.skipIf(process.platform !== 'darwin')('Command screenshot native bridge
|
||||
const trusted = window(3)
|
||||
const foreign = window(4, 'https://example.org/')
|
||||
await expect(call('settings:set', foreign.event, true)).rejects.toThrow()
|
||||
await expect(call('settings:set', { ...trusted.event, senderFrame: { url: 'https://example.org/' } }, true)).rejects.toThrow()
|
||||
await expect(
|
||||
call('settings:set', { ...trusted.event, senderFrame: { url: 'https://example.org/' } }, true)
|
||||
).rejects.toThrow()
|
||||
expect(native.start).not.toHaveBeenCalled()
|
||||
electron.screenPermission = 'denied'
|
||||
await call('settings:set', trusted.event, true)
|
||||
native.start.mock.calls.at(-1)
|
||||
expect(desktopCapturer.getSources).toHaveBeenCalledWith({
|
||||
types: ['window'], thumbnailSize: { width: 1, height: 1 }, fetchWindowIcons: false
|
||||
types: ['window'],
|
||||
thumbnailSize: { width: 1, height: 1 },
|
||||
fetchWindowIcons: false
|
||||
})
|
||||
expect(await call('settings:get', trusted.event)).toEqual({ enabled: true, state: 'screen-permission' })
|
||||
})
|
||||
|
||||
@@ -18,7 +18,10 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
|
||||
const expectedUrl = new URL(rendererUrl)
|
||||
const monitor = new CommandScreenshotMonitor({ appPath: app.getAppPath() })
|
||||
const hasScreenPermission = () => systemPreferences.getMediaAccessStatus('screen') === 'granted'
|
||||
const capture = createScreenshotCapture({ hasScreenPermission, getSources: options => desktopCapturer.getSources(options) })
|
||||
const capture = createScreenshotCapture({
|
||||
hasScreenPermission,
|
||||
getSources: options => desktopCapturer.getSources(options)
|
||||
})
|
||||
const recipients = new Map<number, () => void>()
|
||||
let lastRecipient: BrowserWindow | null = null
|
||||
let enabled = false
|
||||
@@ -34,7 +37,11 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
|
||||
|
||||
const status = (): ScreenshotStatus => ({
|
||||
enabled,
|
||||
state: !enabled ? 'disabled' : monitorState === 'ready' && !hasScreenPermission() ? 'screen-permission' : monitorState
|
||||
state: !enabled
|
||||
? 'disabled'
|
||||
: monitorState === 'ready' && !hasScreenPermission()
|
||||
? 'screen-permission'
|
||||
: monitorState
|
||||
})
|
||||
|
||||
const trustedWindow = (event: IpcMainEvent | IpcMainInvokeEvent): BrowserWindow | null => {
|
||||
@@ -47,7 +54,11 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
|
||||
try {
|
||||
const url = new URL(event.senderFrame.url)
|
||||
|
||||
return url.protocol === expectedUrl.protocol && url.host === expectedUrl.host && url.pathname === expectedUrl.pathname ? win : null
|
||||
return url.protocol === expectedUrl.protocol &&
|
||||
url.host === expectedUrl.host &&
|
||||
url.pathname === expectedUrl.pathname
|
||||
? win
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -63,35 +74,44 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
|
||||
|
||||
const start = (requestPermission = false) => {
|
||||
const current = ++generation
|
||||
monitor.start(window => {
|
||||
if (disposed || !enabled || current !== generation) {
|
||||
return
|
||||
}
|
||||
monitor.start(
|
||||
window => {
|
||||
if (disposed || !enabled || current !== generation) {
|
||||
return
|
||||
}
|
||||
|
||||
// Retain the last Hermes chat window when another application takes focus.
|
||||
// Closing it cancels this destination; never silently pick another chat.
|
||||
const focused = BrowserWindow.getFocusedWindow()
|
||||
const recipient = focused && recipients.has(focused.webContents.id) ? focused : lastRecipient
|
||||
// Retain the last Hermes chat window when another application takes focus.
|
||||
// Closing it cancels this destination; never silently pick another chat.
|
||||
const focused = BrowserWindow.getFocusedWindow()
|
||||
const recipient = focused && recipients.has(focused.webContents.id) ? focused : lastRecipient
|
||||
|
||||
if (!recipient || recipient.isDestroyed() || !recipients.has(recipient.webContents.id)) {
|
||||
return
|
||||
}
|
||||
if (!recipient || recipient.isDestroyed() || !recipients.has(recipient.webContents.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = capture.request(recipient.webContents.id, window)
|
||||
const requestId = capture.request(recipient.webContents.id, window)
|
||||
|
||||
if (requestId) {
|
||||
recipient.webContents.send('hermes:screenshot:request', requestId)
|
||||
}
|
||||
}, result => {
|
||||
if (disposed || current !== generation) {
|
||||
return
|
||||
}
|
||||
if (requestId) {
|
||||
recipient.webContents.send('hermes:screenshot:request', requestId)
|
||||
}
|
||||
},
|
||||
result => {
|
||||
if (disposed || current !== generation) {
|
||||
return
|
||||
}
|
||||
|
||||
monitorState = result.type === 'error'
|
||||
? result.code === 'permission-required' ? 'input-permission' : 'unavailable'
|
||||
: result.type === 'stopped' ? 'disabled' : result.type
|
||||
publish()
|
||||
}, requestPermission)
|
||||
monitorState =
|
||||
result.type === 'error'
|
||||
? result.code === 'permission-required'
|
||||
? 'input-permission'
|
||||
: 'unavailable'
|
||||
: result.type === 'stopped'
|
||||
? 'disabled'
|
||||
: result.type
|
||||
publish()
|
||||
},
|
||||
requestPermission
|
||||
)
|
||||
}
|
||||
|
||||
const onFocus = (_event: unknown, win: BrowserWindow) => {
|
||||
@@ -110,12 +130,15 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
|
||||
if (subscribed === true) {
|
||||
if (!recipients.has(event.sender.id)) {
|
||||
const id = event.sender.id
|
||||
|
||||
const onDestroyed = () => {
|
||||
recipients.delete(id)
|
||||
|
||||
if (lastRecipient === win) {
|
||||
lastRecipient = null
|
||||
}
|
||||
}
|
||||
|
||||
event.sender.once('destroyed', onDestroyed)
|
||||
recipients.set(id, () => event.sender.removeListener('destroyed', onDestroyed))
|
||||
}
|
||||
@@ -126,6 +149,7 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
|
||||
} else if (subscribed === false) {
|
||||
recipients.get(event.sender.id)?.()
|
||||
recipients.delete(event.sender.id)
|
||||
|
||||
if (lastRecipient === win) {
|
||||
lastRecipient = null
|
||||
}
|
||||
@@ -133,6 +157,7 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
|
||||
}
|
||||
|
||||
const channels: string[] = []
|
||||
|
||||
const handle = (name: string, callback: (event: IpcMainInvokeEvent, value: unknown) => unknown) => {
|
||||
const channel = `hermes:screenshot:${name}`
|
||||
channels.push(channel)
|
||||
@@ -162,11 +187,14 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
|
||||
|
||||
if (enabled) {
|
||||
start(true)
|
||||
|
||||
if (!hasScreenPermission()) {
|
||||
// Zero-size thumbnails skip content capture and may never request TCC
|
||||
// consent. Request the smallest thumbnail only on explicit opt-in;
|
||||
// discard it rather than retaining or attaching permission-probe pixels.
|
||||
await desktopCapturer.getSources({ types: ['window'], thumbnailSize: { width: 1, height: 1 }, fetchWindowIcons: false }).catch(() => undefined)
|
||||
await desktopCapturer
|
||||
.getSources({ types: ['window'], thumbnailSize: { width: 1, height: 1 }, fetchWindowIcons: false })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
} else {
|
||||
monitorState = 'disabled'
|
||||
@@ -178,6 +206,7 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
|
||||
})
|
||||
handle('capture', async (event, requestId) => {
|
||||
const result = await capture.take(event.sender.id, requestId)
|
||||
|
||||
if (result.ok === false && result.reason === 'screen-permission') {
|
||||
publish()
|
||||
}
|
||||
@@ -186,9 +215,11 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
|
||||
})
|
||||
handle('permission', async (_event, kind) => {
|
||||
const pane = kind === 'input' ? 'Privacy_ListenEvent' : kind === 'screen' ? 'Privacy_ScreenCapture' : null
|
||||
|
||||
if (!pane) {
|
||||
throw new Error('Unknown screenshot permission')
|
||||
}
|
||||
|
||||
await shell.openExternal(`x-apple.systempreferences:com.apple.preference.security?${pane}`)
|
||||
})
|
||||
ipcMain.on('hermes:screenshot:subscribe', onSubscribe)
|
||||
@@ -208,6 +239,7 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
|
||||
}
|
||||
|
||||
app.once('will-quit', dispose)
|
||||
|
||||
if (enabled) {
|
||||
start()
|
||||
}
|
||||
|
||||
@@ -24,11 +24,7 @@ export const sshInventoryAttemptedAt = new Map<string, number>()
|
||||
*/
|
||||
export const connectionInstallIds = new Map<string, { id?: string; ts: number }>()
|
||||
|
||||
const CONNECTION_SCOPED_CACHES: Map<string, unknown>[] = [
|
||||
sshRosterCache,
|
||||
sshInventoryAttemptedAt,
|
||||
connectionInstallIds
|
||||
]
|
||||
const CONNECTION_SCOPED_CACHES: Map<string, unknown>[] = [sshRosterCache, sshInventoryAttemptedAt, connectionInstallIds]
|
||||
|
||||
/**
|
||||
* Forget everything cached about a connection id. Call whenever that id stops naming the machine
|
||||
|
||||
@@ -48,7 +48,15 @@ describe('removeDesktopPlugin', () => {
|
||||
write(path.join(home, 'config.yaml'), 'model: x')
|
||||
write(path.join(root, 'hello', 'plugin.js'), 'export default {}')
|
||||
|
||||
for (const name of ['../config.yaml', '..', '.', '', 'hello/plugin.js', `..${path.sep}config.yaml`, path.join(root, 'hello')]) {
|
||||
for (const name of [
|
||||
'../config.yaml',
|
||||
'..',
|
||||
'.',
|
||||
'',
|
||||
'hello/plugin.js',
|
||||
`..${path.sep}config.yaml`,
|
||||
path.join(root, 'hello')
|
||||
]) {
|
||||
const result = await removeDesktopPlugin(root, name)
|
||||
|
||||
expect(result.ok, name).toBe(false)
|
||||
|
||||
@@ -202,6 +202,7 @@ describe('reconcileUnifiedDesktopHalves', () => {
|
||||
const appRoot = path.join(home, 'desktop-plugins')
|
||||
const packageDir = path.join(home, 'plugins', 'media')
|
||||
write(path.join(packageDir, 'desktop', 'plugin.js'), 'package half')
|
||||
|
||||
// Simulate a copy that dies partway: the destination already holds a marker-less
|
||||
// partial tree when the failure surfaces. A direct copy into the final target would
|
||||
// leave that half-tree behind; the staged copy must never let it reach `<appRoot>/media`.
|
||||
|
||||
@@ -17,6 +17,7 @@ test('failed authoritative writes leave the previous default and listeners untou
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-profile-write-'))
|
||||
const target = path.join(root, 'active-profile.json')
|
||||
const changes: unknown[] = []
|
||||
|
||||
const preferences = createDesktopProfilePreferences(target, {
|
||||
onDefaultChanged: route => changes.push(route),
|
||||
validateRoute: route => {
|
||||
@@ -29,6 +30,7 @@ test('failed authoritative writes leave the previous default and listeners untou
|
||||
try {
|
||||
const original = { connectionId: null, profile: 'work' }
|
||||
preferences.setDefault(original)
|
||||
|
||||
for (const invalid of [
|
||||
null,
|
||||
{},
|
||||
@@ -38,6 +40,7 @@ test('failed authoritative writes leave the previous default and listeners untou
|
||||
assert.throws(() => preferences.setDefault(invalid))
|
||||
assert.deepEqual(preferences.getDefault(), original)
|
||||
}
|
||||
|
||||
fs.mkdirSync(`${target}.tmp`)
|
||||
assert.throws(() => preferences.setDefault({ connectionId: 'remote', profile: 'personal' }))
|
||||
assert.deepEqual(preferences.getDefault(), original)
|
||||
@@ -96,6 +99,7 @@ test('boot and reconnect retain the window route rather than a later global defa
|
||||
profile: route.profile
|
||||
})
|
||||
}
|
||||
|
||||
assert.deepEqual(resolveDesktopConnectionRequest('other', routeA, 'last-used'), {
|
||||
connectionId: null,
|
||||
profile: 'other'
|
||||
|
||||
@@ -87,7 +87,11 @@ test('gh is found in the GUI-safe install locations a minimal launch PATH omits'
|
||||
assert.equal(macos, brewGh)
|
||||
|
||||
const winGh = 'C:\\Program Files\\GitHub CLI\\gh.exe'
|
||||
const windows = findGhCli({ Path: 'C:\\Windows\\System32', ProgramFiles: 'C:\\Program Files' }, 'win32', p => p === winGh)
|
||||
const windows = findGhCli(
|
||||
{ Path: 'C:\\Windows\\System32', ProgramFiles: 'C:\\Program Files' },
|
||||
'win32',
|
||||
p => p === winGh
|
||||
)
|
||||
|
||||
assert.equal(windows, winGh)
|
||||
|
||||
@@ -96,7 +100,10 @@ test('gh is found in the GUI-safe install locations a minimal launch PATH omits'
|
||||
const both = findGhCli({ PATH: '/usr/local/bin' }, 'darwin', p => p === pathGh || p === brewGh)
|
||||
|
||||
assert.equal(both, pathGh)
|
||||
assert.equal(findGhCli({ PATH: '/usr/bin' }, 'linux', () => false), null)
|
||||
assert.equal(
|
||||
findGhCli({ PATH: '/usr/bin' }, 'linux', () => false),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('gh rung: argv-only spawn, stdin closed, bounded, cached for the process, and after the env rung', async () => {
|
||||
@@ -136,18 +143,27 @@ test('gh rung: argv-only spawn, stdin closed, bounded, cached for the process, a
|
||||
forgetGhCliToken()
|
||||
const loggedOut = fakeExecFile({ error: Object.assign(new Error('exit 1'), { code: 1 }), stdout: '' })
|
||||
|
||||
assert.equal(await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: loggedOut.execFileFn }), null)
|
||||
assert.equal(
|
||||
await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: loggedOut.execFileFn }),
|
||||
null
|
||||
)
|
||||
|
||||
// "none" is not cached: a `gh auth login` after launch is picked up by the next check without a restart.
|
||||
const nowLogged = fakeExecFile({ stdout: 'gho_after_login\n' })
|
||||
|
||||
assert.deepEqual(await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: nowLogged.execFileFn }), {
|
||||
token: 'gho_after_login',
|
||||
source: 'gh-cli'
|
||||
})
|
||||
assert.deepEqual(
|
||||
await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: nowLogged.execFileFn }),
|
||||
{
|
||||
token: 'gho_after_login',
|
||||
source: 'gh-cli'
|
||||
}
|
||||
)
|
||||
|
||||
forgetGhCliToken()
|
||||
assert.equal(await resolveGitHubCredential({ env, platform: 'darwin', exists: () => false, execFileFn: logged.execFileFn }), null)
|
||||
assert.equal(
|
||||
await resolveGitHubCredential({ env, platform: 'darwin', exists: () => false, execFileFn: logged.execFileFn }),
|
||||
null
|
||||
)
|
||||
assert.equal(logged.calls.length, 1)
|
||||
})
|
||||
|
||||
|
||||
@@ -208,7 +208,9 @@ export function githubApiHeaders(base: Record<string, string>, token?: string |
|
||||
* before credentials were wired in. Anonymous 401s and every other status are
|
||||
* not the token's fault and are surfaced as-is.
|
||||
*/
|
||||
export function githubTokenRejected(error: { statusCode?: number; authenticated?: boolean } | null | undefined): boolean {
|
||||
export function githubTokenRejected(
|
||||
error: { statusCode?: number; authenticated?: boolean } | null | undefined
|
||||
): boolean {
|
||||
return error?.statusCode === 401 && error?.authenticated === true
|
||||
}
|
||||
|
||||
|
||||
@@ -1155,7 +1155,10 @@ test('homeRelativeAttachmentCandidates normalizes Windows backslashes before joi
|
||||
})
|
||||
|
||||
test('homeRelativeAttachmentCandidates returns nothing for an absolute path', () => {
|
||||
assert.deepEqual(homeRelativeAttachmentCandidates('/already/absolute/foo.xlsx', '/Users/alice', '/Users/alice/.hermes'), [])
|
||||
assert.deepEqual(
|
||||
homeRelativeAttachmentCandidates('/already/absolute/foo.xlsx', '/Users/alice', '/Users/alice/.hermes'),
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
test('homeRelativeAttachmentCandidates returns nothing for a file: URL', () => {
|
||||
|
||||
@@ -29,10 +29,34 @@ test('a proven sign-in wall never escalates to the hidden renderer', async () =>
|
||||
// The three measured shapes of the wall are all proven from the curl tier:
|
||||
// arrival URL (Apps Script → www.google.com/a/<domain>/ServiceLogin), sign-in
|
||||
// title (Drive → accounts.google.com), and markup (a Doc that stays on its host).
|
||||
assert.equal(isAuthWall({ body: '', effectiveUrl: 'https://www.google.com/a/x.org/ServiceLogin?c=1', title: '' }), true)
|
||||
assert.equal(isAuthWall({ body: '', effectiveUrl: 'https://accounts.google.com/v3/signin/identifier', title: 'Google Drive: Sign-in' }), true)
|
||||
assert.equal(isAuthWall({ body: '<a href="https://accounts.google.com/ServiceLogin">', effectiveUrl: 'https://docs.google.com/document/d/1/edit', title: '' }), true)
|
||||
assert.equal(isAuthWall({ body: '<title>Q3 plan</title>', effectiveUrl: 'https://docs.google.com/document/d/1/pub', title: 'Q3 plan' }), false)
|
||||
assert.equal(
|
||||
isAuthWall({ body: '', effectiveUrl: 'https://www.google.com/a/x.org/ServiceLogin?c=1', title: '' }),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isAuthWall({
|
||||
body: '',
|
||||
effectiveUrl: 'https://accounts.google.com/v3/signin/identifier',
|
||||
title: 'Google Drive: Sign-in'
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isAuthWall({
|
||||
body: '<a href="https://accounts.google.com/ServiceLogin">',
|
||||
effectiveUrl: 'https://docs.google.com/document/d/1/edit',
|
||||
title: ''
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isAuthWall({
|
||||
body: '<title>Q3 plan</title>',
|
||||
effectiveUrl: 'https://docs.google.com/document/d/1/pub',
|
||||
title: 'Q3 plan'
|
||||
}),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('an ordinary title-less page still escalates to the hidden renderer', async () => {
|
||||
|
||||
@@ -3,11 +3,7 @@ import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
CHROMIUM_LOG_FILENAME,
|
||||
enableLinuxCrashDiagnostics,
|
||||
linuxCrashDiagnostics
|
||||
} from './linux-crash-diagnostics'
|
||||
import { CHROMIUM_LOG_FILENAME, enableLinuxCrashDiagnostics, linuxCrashDiagnostics } from './linux-crash-diagnostics'
|
||||
|
||||
// Regression for #100573: the Linux shell died with SIGTRAP at Chromium's
|
||||
// shared fatal-handler address and no launcher kept the FATAL message. The
|
||||
|
||||
@@ -58,10 +58,7 @@ test('an under-cap or absent active log is left alone', () => {
|
||||
const touched: string[] = []
|
||||
const truncate = (f: string) => touched.push(f)
|
||||
|
||||
assert.equal(
|
||||
reclaimActiveLogIfOversized('/logs/x.log', { size: () => LOG_MAX_BYTES - 1, truncate }),
|
||||
false
|
||||
)
|
||||
assert.equal(reclaimActiveLogIfOversized('/logs/x.log', { size: () => LOG_MAX_BYTES - 1, truncate }), false)
|
||||
assert.equal(reclaimActiveLogIfOversized('/logs/x.log', { size: () => null, truncate }), false)
|
||||
assert.deepEqual(touched, [])
|
||||
})
|
||||
|
||||
@@ -255,7 +255,12 @@ import {
|
||||
tightenSecretFileMode,
|
||||
writeSecretFileAtomic
|
||||
} from './hardening'
|
||||
import { type AttachedBackend, attachOrReserveSpawn, spawnLedgerPath, type SpawnReservation } from './host-backend-attach'
|
||||
import {
|
||||
type AttachedBackend,
|
||||
attachOrReserveSpawn,
|
||||
spawnLedgerPath,
|
||||
type SpawnReservation
|
||||
} from './host-backend-attach'
|
||||
import { requestHudClose } from './hud-close'
|
||||
import { cursorPointInWindow } from './hud-cursor'
|
||||
import { startHudGameOverlayWatch } from './hud-game-overlay'
|
||||
@@ -269,11 +274,7 @@ import { resolveHudWindowing } from './hud-windowing'
|
||||
import { createIntroRevealWindowController } from './intro-reveal-window'
|
||||
import { isAuthWall, resolveLinkTitle } from './link-title-wall'
|
||||
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
|
||||
import {
|
||||
CHROMIUM_LOG_FILENAME,
|
||||
enableLinuxCrashDiagnostics,
|
||||
linuxCrashDiagnostics
|
||||
} from './linux-crash-diagnostics'
|
||||
import { CHROMIUM_LOG_FILENAME, enableLinuxCrashDiagnostics, linuxCrashDiagnostics } from './linux-crash-diagnostics'
|
||||
import { notifyLauncherWindowRevealed } from './linux-launcher-ready'
|
||||
import { createLocalBackendLifecycle, waitForTeardown } from './local-backend-lifecycle'
|
||||
import { ACTIVE_LOG_POLL_MS, planLogRotation, reclaimActiveLogIfOversized } from './log-rotation'
|
||||
@@ -912,7 +913,9 @@ const HERMES_HOME = resolveHermesHome()
|
||||
app.commandLine.appendSwitch(planned.name, planned.value)
|
||||
}
|
||||
|
||||
console.log(`[hermes] desktop launch switch from config.yaml: --${planned.name}${planned.value === undefined ? '' : `=${planned.value}`}`)
|
||||
console.log(
|
||||
`[hermes] desktop launch switch from config.yaml: --${planned.name}${planned.value === undefined ? '' : `=${planned.value}`}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -993,16 +996,12 @@ const CRASH_DIAGNOSTICS_LOGS_DIR = path.dirname(DESKTOP_LOG_PATH)
|
||||
const CRASH_DIAGNOSTICS = linuxCrashDiagnostics(CRASH_DIAGNOSTICS_LOGS_DIR)
|
||||
const CHROMIUM_LOG_PATH = path.join(CRASH_DIAGNOSTICS_LOGS_DIR, CHROMIUM_LOG_FILENAME)
|
||||
|
||||
enableLinuxCrashDiagnostics(
|
||||
CRASH_DIAGNOSTICS,
|
||||
CRASH_DIAGNOSTICS_LOGS_DIR,
|
||||
{
|
||||
ensureLogsDir: dir => fs.mkdirSync(dir, { recursive: true }),
|
||||
reclaimChromiumLog: file => rotateLogIfNeededSync(file),
|
||||
appendSwitch: (name, value) => app.commandLine.appendSwitch(name, value),
|
||||
startCrashReporter: options => crashReporter.start(options)
|
||||
}
|
||||
)
|
||||
enableLinuxCrashDiagnostics(CRASH_DIAGNOSTICS, CRASH_DIAGNOSTICS_LOGS_DIR, {
|
||||
ensureLogsDir: dir => fs.mkdirSync(dir, { recursive: true }),
|
||||
reclaimChromiumLog: file => rotateLogIfNeededSync(file),
|
||||
appendSwitch: (name, value) => app.commandLine.appendSwitch(name, value),
|
||||
startCrashReporter: options => crashReporter.start(options)
|
||||
})
|
||||
|
||||
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
|
||||
const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || ''
|
||||
@@ -1278,7 +1277,8 @@ function getTitleBarOverlayOptions() {
|
||||
darwinMajor: DARWIN_MAJOR,
|
||||
titlebarHeight: TITLEBAR_HEIGHT,
|
||||
color: TITLEBAR_OVERLAY_COLOR,
|
||||
foreground: rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.foreground) ? rendererTitleBarTheme.foreground : null,
|
||||
foreground:
|
||||
rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.foreground) ? rendererTitleBarTheme.foreground : null,
|
||||
dark: nativeTheme.shouldUseDarkColors
|
||||
})
|
||||
}
|
||||
@@ -3665,9 +3665,7 @@ function repairMacUpdaterHelper(updater) {
|
||||
function venvHermesShimPath(updateRoot) {
|
||||
const venvDir = resolveVenvDir(updateRoot)
|
||||
|
||||
return IS_WINDOWS
|
||||
? path.join(venvDir, 'Scripts', 'hermes.exe')
|
||||
: path.join(venvDir, 'bin', 'hermes')
|
||||
return IS_WINDOWS ? path.join(venvDir, 'Scripts', 'hermes.exe') : path.join(venvDir, 'bin', 'hermes')
|
||||
}
|
||||
|
||||
// Best-effort lock probe mirroring the Rust updater's is_locked(): a running
|
||||
@@ -4433,12 +4431,14 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) {
|
||||
'-RelaunchExe',
|
||||
process.execPath
|
||||
]
|
||||
|
||||
// Same remote-ownership rule as the posix hand-off (#117529): a
|
||||
// remote-served Desktop must not let the update (re)start a local
|
||||
// messaging gateway that competes with the remote host's polling.
|
||||
if (globalRemoteActive()) {
|
||||
wrappedArgs.push('-NoGateway')
|
||||
}
|
||||
|
||||
const wrapped = wrapHandoffForDetachedConsole(scriptHandoff, wrappedArgs)
|
||||
|
||||
child = spawnUpdaterProcess(wrapped.command, wrapped.args, {
|
||||
@@ -4823,6 +4823,7 @@ async function applyUpdatesPosixHandoff(opts: any) {
|
||||
}
|
||||
|
||||
const args = [...handoff.args, '--install-root', updateRoot, '--branch', branch, '--desktop-pid', String(process.pid)]
|
||||
|
||||
// A remote-served Desktop owns no local messaging gateway: `hermes update
|
||||
// --gateway` would (re)start one here anyway, and with the same channel
|
||||
// credentials as the remote host it becomes a competing long-poll consumer
|
||||
@@ -4830,6 +4831,7 @@ async function applyUpdatesPosixHandoff(opts: any) {
|
||||
if (globalRemoteActive()) {
|
||||
args.push('--no-gateway')
|
||||
}
|
||||
|
||||
const updateStartedAt = Math.floor(Date.now() / 1000)
|
||||
|
||||
// Relaunch target: the running .app bundle on mac (script swaps the
|
||||
@@ -6278,13 +6280,12 @@ function fetchLinkTitle(rawUrl) {
|
||||
curl: () => fetchHtmlTitleWithCurl(url),
|
||||
renderer: () => fetchHtmlTitleWithRenderer(url),
|
||||
url
|
||||
})
|
||||
.then(clean => {
|
||||
cacheTitle(key, clean)
|
||||
titleInflight.delete(key)
|
||||
}).then(clean => {
|
||||
cacheTitle(key, clean)
|
||||
titleInflight.delete(key)
|
||||
|
||||
return clean
|
||||
})
|
||||
return clean
|
||||
})
|
||||
|
||||
titleInflight.set(key, pending)
|
||||
|
||||
@@ -8674,14 +8675,15 @@ function resolvePortalBaseUrl() {
|
||||
return String(raw).trim().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const { hasLivePortalSession, hasPortalAccessToken, renewPortalAccessSilently, openPortalLoginWindow } = createPortalSession({
|
||||
isReady: () => app.isReady(),
|
||||
getOauthSession,
|
||||
resolvePortalBaseUrl,
|
||||
warmOauthCookieStore,
|
||||
createWindow: options => new BrowserWindow(options),
|
||||
rememberLog
|
||||
})
|
||||
const { hasLivePortalSession, hasPortalAccessToken, renewPortalAccessSilently, openPortalLoginWindow } =
|
||||
createPortalSession({
|
||||
isReady: () => app.isReady(),
|
||||
getOauthSession,
|
||||
resolvePortalBaseUrl,
|
||||
warmOauthCookieStore,
|
||||
createWindow: options => new BrowserWindow(options),
|
||||
rememberLog
|
||||
})
|
||||
|
||||
// Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls
|
||||
// the NAS trimmed-summary endpoint over the partition-bound net, so the portal
|
||||
@@ -8714,10 +8716,13 @@ async function discoverCloudAgents(org?: string) {
|
||||
const fetchAgents = () =>
|
||||
discoverWithTeamFallback(
|
||||
selectedOrg =>
|
||||
fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents${selectedOrg ? `?org=${encodeURIComponent(selectedOrg)}` : ''}`, {
|
||||
method: 'GET',
|
||||
timeoutMs: 15_000
|
||||
}),
|
||||
fetchJsonViaOauthSession(
|
||||
`${portalBaseUrl}/api/agents${selectedOrg ? `?org=${encodeURIComponent(selectedOrg)}` : ''}`,
|
||||
{
|
||||
method: 'GET',
|
||||
timeoutMs: 15_000
|
||||
}
|
||||
),
|
||||
org
|
||||
)
|
||||
|
||||
@@ -12418,9 +12423,7 @@ function startPoolIdleReaper() {
|
||||
if (now - (entry.lastActiveAt || 0) > poolIdleMs()) {
|
||||
// Remote descriptors hold no child/slot. Local children require the
|
||||
// same admission authority as foreground and LRU reclamation.
|
||||
const retiring = entry.process
|
||||
? poolRetirer.retireIdle(profile, poolIdleMs())
|
||||
: stopPoolBackend(profile)
|
||||
const retiring = entry.process ? poolRetirer.retireIdle(profile, poolIdleMs()) : stopPoolBackend(profile)
|
||||
|
||||
void retiring.catch(error => rememberLog(`Pool idle retirement failed: ${String(error)}`))
|
||||
}
|
||||
@@ -15346,7 +15349,10 @@ ipcMain.handle('hermes:connection:for', async (_event, payload) => {
|
||||
const id = String(connectionId || '').trim() || registry.primary
|
||||
const spawnPriority = spawnPriorityFrom(priority)
|
||||
|
||||
return connectDesktopProfileRoute({ connectionId: id, profile: String(profile ?? '').trim() || 'default' }, spawnPriority)
|
||||
return connectDesktopProfileRoute(
|
||||
{ connectionId: id, profile: String(profile ?? '').trim() || 'default' },
|
||||
spawnPriority
|
||||
)
|
||||
})
|
||||
|
||||
const windowConnectionRoutes = new WindowConnectionRouteRegistry()
|
||||
@@ -18702,8 +18708,7 @@ function heldQuitForActiveWork(event: Electron.Event): boolean {
|
||||
|
||||
// A hidden aux window must never parent the quit prompt: the dialog would
|
||||
// be invisible and the held quit unanswerable (#116376 §E).
|
||||
const parent =
|
||||
BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find(window => window.isVisible())
|
||||
const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find(window => window.isVisible())
|
||||
|
||||
if (!prompt || !parent || parent.isDestroyed()) {
|
||||
return false
|
||||
|
||||
@@ -45,8 +45,15 @@ it('returns native clicks and approval actions to the emitting window, not the p
|
||||
const source = windowStub()
|
||||
host.fromWebContents.mockReturnValue(source)
|
||||
const focusWindow = vi.fn()
|
||||
registerNativeNotifications({ getMainWindow: () => primary as unknown as BrowserWindow, focusWindow, platform: 'darwin' })
|
||||
const notify = host.handle.mock.calls[0][1] as (event: IpcMainInvokeEvent, payload: HermesNotification) => Promise<boolean>
|
||||
registerNativeNotifications({
|
||||
getMainWindow: () => primary as unknown as BrowserWindow,
|
||||
focusWindow,
|
||||
platform: 'darwin'
|
||||
})
|
||||
const notify = host.handle.mock.calls[0][1] as (
|
||||
event: IpcMainInvokeEvent,
|
||||
payload: HermesNotification
|
||||
) => Promise<boolean>
|
||||
|
||||
const payload = {
|
||||
kind: 'approval',
|
||||
@@ -85,8 +92,15 @@ it('delivers plugin callbacks to their source and falls back only for navigation
|
||||
const source = windowStub()
|
||||
host.fromWebContents.mockReturnValue(source)
|
||||
const focusWindow = vi.fn()
|
||||
registerNativeNotifications({ getMainWindow: () => primary as unknown as BrowserWindow, focusWindow, platform: 'darwin' })
|
||||
const notify = host.handle.mock.calls[0][1] as (event: IpcMainInvokeEvent, payload: HermesNotification) => Promise<boolean>
|
||||
registerNativeNotifications({
|
||||
getMainWindow: () => primary as unknown as BrowserWindow,
|
||||
focusWindow,
|
||||
platform: 'darwin'
|
||||
})
|
||||
const notify = host.handle.mock.calls[0][1] as (
|
||||
event: IpcMainInvokeEvent,
|
||||
payload: HermesNotification
|
||||
) => Promise<boolean>
|
||||
await notify({ sender: source.webContents } as unknown as IpcMainInvokeEvent, {
|
||||
kind: 'plugin',
|
||||
notifyId: 'source-callback',
|
||||
|
||||
@@ -34,6 +34,7 @@ export function registerNativeNotifications({
|
||||
|
||||
// Peer renderers share one OS notification for the same event.
|
||||
const key = `${payload?.kind ?? ''}:${payload?.sessionId ?? payload?.tag ?? ''}`
|
||||
|
||||
if (isDuplicateNotification(key)) {
|
||||
return deliveries.get(key) ?? false
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { registerNativeNotifications } from './notification-ipc'
|
||||
|
||||
function setup(alreadyRunning = false) {
|
||||
host.handle.mockClear()
|
||||
|
||||
const connection = Object.assign(new EventEmitter(), {
|
||||
stream: { destroy: vi.fn(() => connection.emit('close')), unref: vi.fn() }
|
||||
})
|
||||
@@ -44,6 +45,7 @@ function setup(alreadyRunning = false) {
|
||||
|
||||
const calls: Message[] = []
|
||||
const failures = new Map<string, string>()
|
||||
|
||||
const replaceOwner = () => {
|
||||
const old = owner
|
||||
owner = ':1.21'
|
||||
@@ -70,12 +72,16 @@ function setup(alreadyRunning = false) {
|
||||
// is answered with an error, not routed to the new owner.
|
||||
if (message.destination?.startsWith(':') && message.destination !== owner) {
|
||||
reject(Object.assign(new Error('no such name'), { dbusName: 'org.freedesktop.DBus.Error.NameHasNoOwner' }))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (failures.has(message.member ?? '')) {
|
||||
reject(Object.assign(new Error('fixture failure'), { dbusName: failures.get(message.member ?? '') }))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (message.member === stalled) {
|
||||
const fail = () => reject(new Error('service unavailable'))
|
||||
|
||||
@@ -90,15 +96,19 @@ function setup(alreadyRunning = false) {
|
||||
|
||||
if (message.member === 'GetNameOwner' && !activated) {
|
||||
reject(Object.assign(new Error('no owner'), { dbusName: 'org.freedesktop.DBus.Error.NameHasNoOwner' }))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (message.member === 'StartServiceByName') {
|
||||
if (alreadyRunning) {
|
||||
reject(
|
||||
Object.assign(new Error('no activation file'), { dbusName: 'org.freedesktop.DBus.Error.ServiceUnknown' })
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
activated = true
|
||||
}
|
||||
|
||||
@@ -116,7 +126,9 @@ function setup(alreadyRunning = false) {
|
||||
if (callback) {
|
||||
callback(null, value)
|
||||
}
|
||||
|
||||
resolve(value)
|
||||
|
||||
if (message.member === 'GetNameOwner' && raceOwnerReply) {
|
||||
raceOwnerReply = false
|
||||
replaceOwner() // Reply followed by owner replacement in the same read batch.
|
||||
@@ -135,6 +147,7 @@ function setup(alreadyRunning = false) {
|
||||
const source = { isDestroyed: vi.fn(() => false), webContents: { send: vi.fn() } }
|
||||
host.fromWebContents.mockReturnValue(source)
|
||||
const focusWindow = vi.fn()
|
||||
|
||||
const { dispose } = registerNativeNotifications({
|
||||
getMainWindow: () => primary as unknown as BrowserWindow,
|
||||
focusWindow,
|
||||
@@ -192,155 +205,146 @@ afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it(
|
||||
'bounds failed delivery without dropping older callbacks, and retries only on a later request',
|
||||
async () => {
|
||||
const unavailable = setup()
|
||||
unavailable.fail('StartServiceByName', 'org.freedesktop.DBus.Error.ServiceUnknown')
|
||||
expect(await unavailable.notify({ tag: 'test' })).toBe(false)
|
||||
expect(await unavailable.notify({ tag: 'test' })).toBe(false)
|
||||
expect(unavailable.calls.filter(call => call.member === 'StartServiceByName')).toHaveLength(1)
|
||||
unavailable.connection.emit('close')
|
||||
it('bounds failed delivery without dropping older callbacks, and retries only on a later request', async () => {
|
||||
const unavailable = setup()
|
||||
unavailable.fail('StartServiceByName', 'org.freedesktop.DBus.Error.ServiceUnknown')
|
||||
expect(await unavailable.notify({ tag: 'test' })).toBe(false)
|
||||
expect(await unavailable.notify({ tag: 'test' })).toBe(false)
|
||||
expect(unavailable.calls.filter(call => call.member === 'StartServiceByName')).toHaveLength(1)
|
||||
unavailable.connection.emit('close')
|
||||
|
||||
// libnotify's getenv guard disables actions even for an empty value.
|
||||
vi.stubEnv('ELECTRON_USE_UBUNTU_NOTIFIER', '')
|
||||
const unity = setup(true)
|
||||
expect(await unity.notify({ tag: 'unity', actions: [{ id: 'ok', text: 'OK' }] })).toBe(true)
|
||||
expect(unity.calls.find(call => call.member === 'Notify')?.body?.[5]).toEqual([])
|
||||
unity.connection.emit('close')
|
||||
vi.stubEnv('ELECTRON_USE_UBUNTU_NOTIFIER', undefined)
|
||||
// libnotify's getenv guard disables actions even for an empty value.
|
||||
vi.stubEnv('ELECTRON_USE_UBUNTU_NOTIFIER', '')
|
||||
const unity = setup(true)
|
||||
expect(await unity.notify({ tag: 'unity', actions: [{ id: 'ok', text: 'OK' }] })).toBe(true)
|
||||
expect(unity.calls.find(call => call.member === 'Notify')?.body?.[5]).toEqual([])
|
||||
unity.connection.emit('close')
|
||||
vi.stubEnv('ELECTRON_USE_UBUNTU_NOTIFIER', undefined)
|
||||
|
||||
for (const method of ['Hello', 'AddMatch', 'StartServiceByName', 'GetNameOwner', 'GetCapabilities']) {
|
||||
const startup = setup()
|
||||
startup.stall(method)
|
||||
const attempt = startup.notify({ tag: method })
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
expect(await attempt).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(11000)
|
||||
startup.stall('')
|
||||
expect(await startup.notify({ tag: 'startup-recovered' })).toBe(true)
|
||||
startup.connection.emit('close')
|
||||
}
|
||||
|
||||
const h = setup()
|
||||
expect(await h.notify({ tag: 'old', focusSessionId: 'old-session' })).toBe(true)
|
||||
expect(h.calls.some(call => call.member === 'StartServiceByName')).toBe(true)
|
||||
const oldId = h.lastId()
|
||||
h.stall('Notify')
|
||||
const pending = h.notify({ tag: 'stalled' })
|
||||
const duplicate = h.notify({ tag: 'stalled' })
|
||||
for (const method of ['Hello', 'AddMatch', 'StartServiceByName', 'GetNameOwner', 'GetCapabilities']) {
|
||||
const startup = setup()
|
||||
startup.stall(method)
|
||||
const attempt = startup.notify({ tag: method })
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
expect(await pending).toBe(false)
|
||||
expect(await duplicate).toBe(false)
|
||||
const attempts = h.calls.length
|
||||
expect(await h.notify({ tag: 'cooldown' })).toBe(false)
|
||||
expect(h.calls).toHaveLength(attempts)
|
||||
h.signal('ActionInvoked', [oldId, 'default'])
|
||||
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'old-session')
|
||||
const afterConsumption = h.calls.length
|
||||
expect(await attempt).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(11000)
|
||||
expect(h.calls).toHaveLength(afterConsumption) // No automatic replay of an ambiguous Notify.
|
||||
h.stall('')
|
||||
expect(await h.notify({ tag: 'recovered' })).toBe(true)
|
||||
h.stall('CloseNotification')
|
||||
const closesBeforeExpiry = h.calls.filter(call => call.member === 'CloseNotification').length
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000 + 6000)
|
||||
expect(h.calls.filter(call => call.member === 'CloseNotification')).toHaveLength(closesBeforeExpiry + 1)
|
||||
h.connection.emit('close')
|
||||
startup.stall('')
|
||||
expect(await startup.notify({ tag: 'startup-recovered' })).toBe(true)
|
||||
startup.connection.emit('close')
|
||||
}
|
||||
)
|
||||
|
||||
it(
|
||||
'releases naturally closed notifications while retaining other click targets',
|
||||
async () => {
|
||||
const h = setup(true)
|
||||
expect(await h.notify({ tag: 'closed', focusSessionId: 'closed-session' })).toBe(true)
|
||||
const closedId = h.lastId()
|
||||
expect(await h.notify({ tag: 'active', focusSessionId: 'active-session' })).toBe(true)
|
||||
const activeId = h.lastId()
|
||||
await vi.advanceTimersByTimeAsync(1100)
|
||||
const timersBeforeClose = vi.getTimerCount()
|
||||
const h = setup()
|
||||
expect(await h.notify({ tag: 'old', focusSessionId: 'old-session' })).toBe(true)
|
||||
expect(h.calls.some(call => call.member === 'StartServiceByName')).toBe(true)
|
||||
const oldId = h.lastId()
|
||||
h.stall('Notify')
|
||||
const pending = h.notify({ tag: 'stalled' })
|
||||
const duplicate = h.notify({ tag: 'stalled' })
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
expect(await pending).toBe(false)
|
||||
expect(await duplicate).toBe(false)
|
||||
const attempts = h.calls.length
|
||||
expect(await h.notify({ tag: 'cooldown' })).toBe(false)
|
||||
expect(h.calls).toHaveLength(attempts)
|
||||
h.signal('ActionInvoked', [oldId, 'default'])
|
||||
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'old-session')
|
||||
const afterConsumption = h.calls.length
|
||||
await vi.advanceTimersByTimeAsync(11000)
|
||||
expect(h.calls).toHaveLength(afterConsumption) // No automatic replay of an ambiguous Notify.
|
||||
h.stall('')
|
||||
expect(await h.notify({ tag: 'recovered' })).toBe(true)
|
||||
h.stall('CloseNotification')
|
||||
const closesBeforeExpiry = h.calls.filter(call => call.member === 'CloseNotification').length
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000 + 6000)
|
||||
expect(h.calls.filter(call => call.member === 'CloseNotification')).toHaveLength(closesBeforeExpiry + 1)
|
||||
h.connection.emit('close')
|
||||
})
|
||||
|
||||
h.signal('NotificationClosed', [closedId, 2], ':1.666')
|
||||
expect(vi.getTimerCount()).toBe(timersBeforeClose)
|
||||
h.signal('NotificationClosed', [closedId, 2])
|
||||
expect(vi.getTimerCount()).toBe(timersBeforeClose - 1)
|
||||
h.signal('ActionInvoked', [closedId, 'default'])
|
||||
expect(h.source.webContents.send).not.toHaveBeenCalled()
|
||||
it('releases naturally closed notifications while retaining other click targets', async () => {
|
||||
const h = setup(true)
|
||||
expect(await h.notify({ tag: 'closed', focusSessionId: 'closed-session' })).toBe(true)
|
||||
const closedId = h.lastId()
|
||||
expect(await h.notify({ tag: 'active', focusSessionId: 'active-session' })).toBe(true)
|
||||
const activeId = h.lastId()
|
||||
await vi.advanceTimersByTimeAsync(1100)
|
||||
const timersBeforeClose = vi.getTimerCount()
|
||||
|
||||
h.signal('ActionInvoked', [activeId, 'default'])
|
||||
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'active-session')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
h.connection.emit('close')
|
||||
h.signal('NotificationClosed', [closedId, 2], ':1.666')
|
||||
expect(vi.getTimerCount()).toBe(timersBeforeClose)
|
||||
h.signal('NotificationClosed', [closedId, 2])
|
||||
expect(vi.getTimerCount()).toBe(timersBeforeClose - 1)
|
||||
h.signal('ActionInvoked', [closedId, 'default'])
|
||||
expect(h.source.webContents.send).not.toHaveBeenCalled()
|
||||
|
||||
h.signal('ActionInvoked', [activeId, 'default'])
|
||||
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'active-session')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
h.connection.emit('close')
|
||||
})
|
||||
|
||||
it('preserves activation, dedupe and source ownership while fencing daemon ID reuse', async () => {
|
||||
const race = setup(true)
|
||||
race.raceOwnerReply()
|
||||
expect(await race.notify({ tag: 'obsolete-owner', focusSessionId: 'must-not-open' })).toBe(false)
|
||||
expect(race.calls.filter(call => call.member === 'Notify')).toHaveLength(0)
|
||||
// A daemon swap is not a daemon failure: the next notification goes to the
|
||||
// new owner right away instead of sitting out the failure cooldown.
|
||||
expect(await race.notify({ tag: 'after-race' })).toBe(true)
|
||||
expect(race.calls.filter(call => call.member === 'Notify')).toHaveLength(1)
|
||||
race.connection.emit('close')
|
||||
|
||||
const h = setup(true)
|
||||
|
||||
const payload = {
|
||||
kind: 'approval',
|
||||
sessionId: 'runtime',
|
||||
focusSessionId: 'stored',
|
||||
silent: true,
|
||||
actions: [
|
||||
{ id: 'approve', text: 'Approve' },
|
||||
{ id: 'reject', text: 'Reject' }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
it(
|
||||
'preserves activation, dedupe and source ownership while fencing daemon ID reuse',
|
||||
async () => {
|
||||
const race = setup(true)
|
||||
race.raceOwnerReply()
|
||||
expect(await race.notify({ tag: 'obsolete-owner', focusSessionId: 'must-not-open' })).toBe(false)
|
||||
expect(race.calls.filter(call => call.member === 'Notify')).toHaveLength(0)
|
||||
// A daemon swap is not a daemon failure: the next notification goes to the
|
||||
// new owner right away instead of sitting out the failure cooldown.
|
||||
expect(await race.notify({ tag: 'after-race' })).toBe(true)
|
||||
expect(race.calls.filter(call => call.member === 'Notify')).toHaveLength(1)
|
||||
race.connection.emit('close')
|
||||
expect(await h.notify(payload)).toBe(true)
|
||||
const firstId = h.lastId()
|
||||
expect(await h.notify(payload)).toBe(true)
|
||||
expect(h.calls.filter(call => call.member === 'Notify')).toHaveLength(1)
|
||||
h.signal('ActionInvoked', [firstId, '1'], ':1.666')
|
||||
expect(h.source.webContents.send).not.toHaveBeenCalled()
|
||||
h.fail('CloseNotification', 'org.freedesktop.DBus.Error.InvalidArgs')
|
||||
h.signal('ActionInvoked', [firstId, '1'])
|
||||
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:notification-action', {
|
||||
sessionId: 'runtime',
|
||||
actionId: 'reject'
|
||||
})
|
||||
expect(h.primary.webContents.send).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(await h.notify({ tag: 'plugin', notifyId: 'source-callback', activate: '/plugin' })).toBe(true)
|
||||
const pluginId = h.lastId()
|
||||
h.source.isDestroyed.mockReturnValue(true)
|
||||
h.signal('ActionInvoked', [pluginId, 'default'])
|
||||
expect(h.primary.webContents.send).toHaveBeenCalledWith('hermes:notification-activate', {
|
||||
activate: '/plugin',
|
||||
notifyId: undefined,
|
||||
tag: 'plugin'
|
||||
})
|
||||
h.connection.emit('close')
|
||||
|
||||
const h = setup(true)
|
||||
const reused = setup(true)
|
||||
expect(await reused.notify({ tag: 'old-owner', focusSessionId: 'must-not-open' })).toBe(true)
|
||||
const retainedId = reused.lastId()
|
||||
reused.replaceOwner()
|
||||
expect(await reused.notify({ tag: 'new-owner', focusSessionId: 'new-session' })).toBe(true)
|
||||
expect(reused.lastId()).toBe(retainedId)
|
||||
reused.signal('ActionInvoked', [retainedId, 'default'], ':1.20')
|
||||
expect(reused.source.webContents.send).not.toHaveBeenCalled()
|
||||
reused.signal('ActionInvoked', [retainedId, 'default'])
|
||||
expect(reused.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'new-session')
|
||||
|
||||
const payload = {
|
||||
kind: 'approval',
|
||||
sessionId: 'runtime',
|
||||
focusSessionId: 'stored',
|
||||
silent: true,
|
||||
actions: [
|
||||
{ id: 'approve', text: 'Approve' },
|
||||
{ id: 'reject', text: 'Reject' }
|
||||
]
|
||||
}
|
||||
|
||||
expect(await h.notify(payload)).toBe(true)
|
||||
const firstId = h.lastId()
|
||||
expect(await h.notify(payload)).toBe(true)
|
||||
expect(h.calls.filter(call => call.member === 'Notify')).toHaveLength(1)
|
||||
h.signal('ActionInvoked', [firstId, '1'], ':1.666')
|
||||
expect(h.source.webContents.send).not.toHaveBeenCalled()
|
||||
h.fail('CloseNotification', 'org.freedesktop.DBus.Error.InvalidArgs')
|
||||
h.signal('ActionInvoked', [firstId, '1'])
|
||||
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:notification-action', {
|
||||
sessionId: 'runtime',
|
||||
actionId: 'reject'
|
||||
})
|
||||
expect(h.primary.webContents.send).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(await h.notify({ tag: 'plugin', notifyId: 'source-callback', activate: '/plugin' })).toBe(true)
|
||||
const pluginId = h.lastId()
|
||||
h.source.isDestroyed.mockReturnValue(true)
|
||||
h.signal('ActionInvoked', [pluginId, 'default'])
|
||||
expect(h.primary.webContents.send).toHaveBeenCalledWith('hermes:notification-activate', {
|
||||
activate: '/plugin',
|
||||
notifyId: undefined,
|
||||
tag: 'plugin'
|
||||
})
|
||||
h.connection.emit('close')
|
||||
|
||||
const reused = setup(true)
|
||||
expect(await reused.notify({ tag: 'old-owner', focusSessionId: 'must-not-open' })).toBe(true)
|
||||
const retainedId = reused.lastId()
|
||||
reused.replaceOwner()
|
||||
expect(await reused.notify({ tag: 'new-owner', focusSessionId: 'new-session' })).toBe(true)
|
||||
expect(reused.lastId()).toBe(retainedId)
|
||||
reused.signal('ActionInvoked', [retainedId, 'default'], ':1.20')
|
||||
expect(reused.source.webContents.send).not.toHaveBeenCalled()
|
||||
reused.signal('ActionInvoked', [retainedId, 'default'])
|
||||
expect(reused.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'new-session')
|
||||
|
||||
// Quit teardown closes the bus; a delivered notification's callbacks die with it.
|
||||
reused.dispose()
|
||||
expect(reused.connection.stream.destroy).toHaveBeenCalled()
|
||||
reused.signal('ActionInvoked', [retainedId, 'default'])
|
||||
expect(reused.source.webContents.send).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
// Quit teardown closes the bus; a delivered notification's callbacks die with it.
|
||||
reused.dispose()
|
||||
expect(reused.connection.stream.destroy).toHaveBeenCalled()
|
||||
reused.signal('ActionInvoked', [retainedId, 'default'])
|
||||
expect(reused.source.webContents.send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -55,12 +55,14 @@ export function createLinuxNotifications() {
|
||||
if (connection !== state) {
|
||||
return
|
||||
}
|
||||
|
||||
connection = undefined
|
||||
state.generation++
|
||||
|
||||
for (const item of state.live.values()) {
|
||||
item.receive('failed', undefined)
|
||||
}
|
||||
|
||||
state.live.clear()
|
||||
bus.connection.stream.destroy()
|
||||
}
|
||||
@@ -92,6 +94,7 @@ export function createLinuxNotifications() {
|
||||
if (message.path !== PATH || message.interface !== SERVICE || !message.member) {
|
||||
return
|
||||
}
|
||||
|
||||
const [id, value] = message.body ?? []
|
||||
state.live.get(`${message.sender}:${id}`)?.receive(message.member, value)
|
||||
})
|
||||
@@ -99,6 +102,7 @@ export function createLinuxNotifications() {
|
||||
const startup = new AbortController()
|
||||
const timer = setTimeout(() => startup.abort(), DELIVERY_TIMEOUT_MS)
|
||||
const options = { signal: startup.signal, timeout: DELIVERY_TIMEOUT_MS }
|
||||
|
||||
try {
|
||||
bus.name = await bus.invokeDbus<string>({ member: 'Hello' }, options)
|
||||
|
||||
@@ -131,6 +135,7 @@ export function createLinuxNotifications() {
|
||||
if (delivered) {
|
||||
delivered.state.live.delete(`${delivered.owner}:${delivered.id}`)
|
||||
}
|
||||
|
||||
delivered = undefined
|
||||
}
|
||||
|
||||
@@ -142,6 +147,7 @@ export function createLinuxNotifications() {
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
const { state, owner, id } = target
|
||||
// The retention timer must never re-enter synchronous libnotify either.
|
||||
void state.bus
|
||||
@@ -181,6 +187,7 @@ export function createLinuxNotifications() {
|
||||
const state = connect()
|
||||
await state.ready
|
||||
const { bus } = state
|
||||
|
||||
const getOwner = () =>
|
||||
new Promise<{ owner: string; generation: number }>((resolve, reject) => {
|
||||
bus.invokeDbus(
|
||||
@@ -190,19 +197,23 @@ export function createLinuxNotifications() {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
|
||||
// Snapshot before another message in this read batch can replace the
|
||||
// owner. An await followed by reading generation pairs stale/new data.
|
||||
resolve({ owner, generation: state.generation })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
let destination: { owner: string; generation: number }
|
||||
|
||||
try {
|
||||
destination = await getOwner()
|
||||
} catch (error) {
|
||||
if ((error as { dbusName?: string })?.dbusName !== 'org.freedesktop.DBus.Error.NameHasNoOwner') {
|
||||
throw error
|
||||
}
|
||||
|
||||
// A running daemon need not have an activation file, but an unowned
|
||||
// activatable service is healthy too. Never make this an owner-only guard.
|
||||
await bus.invokeDbus({ member: 'StartServiceByName', signature: 'su', body: [SERVICE, 0] }, callOptions)
|
||||
@@ -211,6 +222,7 @@ export function createLinuxNotifications() {
|
||||
|
||||
const { owner, generation } = destination
|
||||
addressed = { state, generation }
|
||||
|
||||
if (state.generation !== generation) {
|
||||
throw new Error('Notification owner changed during lookup')
|
||||
}
|
||||
@@ -261,6 +273,7 @@ export function createLinuxNotifications() {
|
||||
if (state.generation !== generation) {
|
||||
return reject(new Error('Notification owner changed during delivery'))
|
||||
}
|
||||
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return reject(new Error(`Notify returned an invalid id: ${String(id)}`))
|
||||
}
|
||||
@@ -304,6 +317,7 @@ export function createLinuxNotifications() {
|
||||
if (!addressed || addressed.state.generation === addressed.generation) {
|
||||
retryAfter = Date.now() + RETRY_COOLDOWN_MS
|
||||
}
|
||||
|
||||
release()
|
||||
notification.emit('failed')
|
||||
|
||||
|
||||
@@ -24,15 +24,18 @@ function harness() {
|
||||
return prepare(key)
|
||||
},
|
||||
commit: async () => true,
|
||||
cancel: async key => { cancelled.push(key) },
|
||||
stopBackend: key => new Promise<void>(resolve => {
|
||||
stopped.push(key)
|
||||
pool.delete(key)
|
||||
exits.set(key, () => {
|
||||
releases.get(key)?.()
|
||||
resolve()
|
||||
cancel: async key => {
|
||||
cancelled.push(key)
|
||||
},
|
||||
stopBackend: key =>
|
||||
new Promise<void>(resolve => {
|
||||
stopped.push(key)
|
||||
pool.delete(key)
|
||||
exits.set(key, () => {
|
||||
releases.get(key)?.()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
async function seed() {
|
||||
@@ -42,8 +45,20 @@ function harness() {
|
||||
}
|
||||
}
|
||||
|
||||
return { coordinator, pool, releases, exits, stopped, cancelled, prepared, retirer, seed,
|
||||
setPrepare: (fn: typeof prepare) => { prepare = fn } }
|
||||
return {
|
||||
coordinator,
|
||||
pool,
|
||||
releases,
|
||||
exits,
|
||||
stopped,
|
||||
cancelled,
|
||||
prepared,
|
||||
retirer,
|
||||
seed,
|
||||
setPrepare: (fn: typeof prepare) => {
|
||||
prepare = fn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('reclamation sequences every foreground waiter, including an already queued background promotion', async () => {
|
||||
@@ -53,8 +68,16 @@ test('reclamation sequences every foreground waiter, including an already queued
|
||||
const second = h.coordinator.request('e', { priority: 'background' })
|
||||
second.promote('foreground')
|
||||
let granted = 0
|
||||
void first.acquired.then(() => { granted += 1 }).catch(() => undefined)
|
||||
void second.acquired.then(() => { granted += 1 }).catch(() => undefined)
|
||||
void first.acquired
|
||||
.then(() => {
|
||||
granted += 1
|
||||
})
|
||||
.catch(() => undefined)
|
||||
void second.acquired
|
||||
.then(() => {
|
||||
granted += 1
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
try {
|
||||
await vi.waitFor(() => assert.deepEqual(h.stopped, ['a']), { timeout: 2000 })
|
||||
@@ -83,7 +106,12 @@ test('withdrawn demand or replaced candidates cancel prepared authority without
|
||||
const h = harness()
|
||||
await h.seed()
|
||||
let finishPrepare!: (value: string) => void
|
||||
h.setPrepare(() => new Promise(resolve => { finishPrepare = resolve }))
|
||||
h.setPrepare(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishPrepare = resolve
|
||||
})
|
||||
)
|
||||
const ticket = h.coordinator.request('d', { priority: 'foreground' })
|
||||
void ticket.acquired.catch(() => undefined)
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ export function createPoolRetirementClient(requestJson: RequestJson) {
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestJson(`http://127.0.0.1:${entry.port}/api/health/retirement`, entry.token, {
|
||||
return (await requestJson(`http://127.0.0.1:${entry.port}/api/health/retirement`, entry.token, {
|
||||
method: 'POST',
|
||||
body: { action, ...(token ? { token } : {}) },
|
||||
timeoutMs: 3000,
|
||||
}) as RetirementReply | null
|
||||
timeoutMs: 3000
|
||||
})) as RetirementReply | null
|
||||
} catch {
|
||||
// An older runtime, transport failure or unreadable reply grants no
|
||||
// authority. Prepared permits expire; committed ones remain recoverable
|
||||
@@ -42,12 +42,13 @@ export function createPoolRetirementClient(requestJson: RequestJson) {
|
||||
const reply = await request(entry, 'prepare')
|
||||
|
||||
return reply?.ok === true && reply.idle === true && typeof reply.token === 'string' && reply.token
|
||||
? reply.token : null
|
||||
? reply.token
|
||||
: null
|
||||
},
|
||||
commit: async (_key: string, entry: PoolBackend, token: string): Promise<boolean> =>
|
||||
(await request(entry, 'commit', token))?.ok === true,
|
||||
cancel: async (_key: string, entry: PoolBackend, token: string): Promise<void> => {
|
||||
await request(entry, 'cancel', token)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,13 @@ test('idle and LRU retirement require backend authority, unchanged identity and
|
||||
pool,
|
||||
coordinator: new LocalBackendSpawnCoordinator(3),
|
||||
prepare: async () => {
|
||||
if (outcome === 'replaced') {pool.set('a', { process: {}, lastActiveAt: 1 })}
|
||||
if (outcome === 'replaced') {
|
||||
pool.set('a', { process: {}, lastActiveAt: 1 })
|
||||
}
|
||||
|
||||
if (outcome === 'fresh') {entry.lastActiveAt = Date.now()}
|
||||
if (outcome === 'fresh') {
|
||||
entry.lastActiveAt = Date.now()
|
||||
}
|
||||
|
||||
return outcome === 'busy' || outcome === 'unknown' ? null : 'permit'
|
||||
},
|
||||
@@ -29,20 +33,35 @@ test('idle and LRU retirement require backend authority, unchanged identity and
|
||||
|
||||
return outcome !== 'expired'
|
||||
},
|
||||
cancel: async key => { cancelled.push(key) },
|
||||
onRetiring: () => { events.push('park') },
|
||||
stopBackend: async key => { events.push('stop'); stopped.push(key); pool.delete(key) },
|
||||
cancel: async key => {
|
||||
cancelled.push(key)
|
||||
},
|
||||
onRetiring: () => {
|
||||
events.push('park')
|
||||
},
|
||||
stopBackend: async key => {
|
||||
events.push('stop')
|
||||
stopped.push(key)
|
||||
pool.delete(key)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
if (path === 'idle') {await retirer.retireIdle('a', 1000)}
|
||||
else {await retirer.evictTo(0, 1000)}
|
||||
if (path === 'idle') {
|
||||
await retirer.retireIdle('a', 1000)
|
||||
} else {
|
||||
await retirer.evictTo(0, 1000)
|
||||
}
|
||||
|
||||
assert.deepEqual(stopped, outcome === 'idle' ? ['a'] : [], `${path}: ${outcome}`)
|
||||
|
||||
if (outcome === 'idle') {assert.deepEqual(events, ['commit', 'park', 'stop'])}
|
||||
if (outcome === 'idle') {
|
||||
assert.deepEqual(events, ['commit', 'park', 'stop'])
|
||||
}
|
||||
|
||||
if (['expired', 'replaced', 'fresh'].includes(outcome)) {assert.deepEqual(cancelled, ['a'])}
|
||||
if (['expired', 'replaced', 'fresh'].includes(outcome)) {
|
||||
assert.deepEqual(cancelled, ['a'])
|
||||
}
|
||||
} finally {
|
||||
retirer.dispose()
|
||||
}
|
||||
@@ -56,8 +75,11 @@ test('candidate selection excludes processless descriptors, renderer-leased work
|
||||
['old', { process: {}, lastActiveAt: 1 }],
|
||||
['busy', { process: {}, lastActiveAt: 0, activeTurn: true }],
|
||||
['descriptor', { process: null }],
|
||||
['target', { process: {}, lastActiveAt: 0 }],
|
||||
['target', { process: {}, lastActiveAt: 0 }]
|
||||
])
|
||||
|
||||
assert.deepEqual(selectRetirementCandidates(pool, new Set(['target'])).map(([key]) => key), ['old', 'fresh'])
|
||||
assert.deepEqual(
|
||||
selectRetirementCandidates(pool, new Set(['target'])).map(([key]) => key),
|
||||
['old', 'fresh']
|
||||
)
|
||||
})
|
||||
|
||||
@@ -47,8 +47,8 @@ export function createPoolRetirer<E extends PoolRetireEntry>(deps: PoolRetirerDe
|
||||
return result
|
||||
}
|
||||
|
||||
const needsCapacity = () => !disposed && deps.coordinator.foregroundWaiters.size > 0 &&
|
||||
deps.coordinator.activeCount >= deps.coordinator.limit
|
||||
const needsCapacity = () =>
|
||||
!disposed && deps.coordinator.foregroundWaiters.size > 0 && deps.coordinator.activeCount >= deps.coordinator.limit
|
||||
|
||||
async function retire(key: string, entry: E, needed: () => boolean): Promise<boolean> {
|
||||
const eligible = () => !disposed && deps.pool.get(key) === entry && entry.activeTurn !== true && needed()
|
||||
@@ -122,16 +122,18 @@ export function createPoolRetirer<E extends PoolRetireEntry>(deps: PoolRetirerDe
|
||||
|
||||
clearTimeout(retry)
|
||||
scheduled = true
|
||||
void enqueue(reclaim).catch(report).finally(() => {
|
||||
scheduled = false
|
||||
void enqueue(reclaim)
|
||||
.catch(report)
|
||||
.finally(() => {
|
||||
scheduled = false
|
||||
|
||||
// Busy work may finish without a renderer touch (cron / side agents).
|
||||
// The coordinator's existing ticket deadline bounds these retries.
|
||||
if (needsCapacity()) {
|
||||
retry = setTimeout(wake, 1000)
|
||||
retry.unref?.()
|
||||
}
|
||||
})
|
||||
// Busy work may finish without a renderer touch (cron / side agents).
|
||||
// The coordinator's existing ticket deadline bounds these retries.
|
||||
if (needsCapacity()) {
|
||||
retry = setTimeout(wake, 1000)
|
||||
retry.unref?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribe = deps.coordinator.onChange(wake)
|
||||
@@ -145,27 +147,29 @@ export function createPoolRetirer<E extends PoolRetireEntry>(deps: PoolRetirerDe
|
||||
throw new Error(`Backend for "${key}" was retired; open it explicitly to reconnect.`)
|
||||
}
|
||||
},
|
||||
retireIdle: (key: string, idleMs: number) => enqueue(async () => {
|
||||
const entry = deps.pool.get(key)
|
||||
retireIdle: (key: string, idleMs: number) =>
|
||||
enqueue(async () => {
|
||||
const entry = deps.pool.get(key)
|
||||
|
||||
return entry ? retire(key, entry, () => Date.now() - (entry.lastActiveAt || 0) > idleMs) : false
|
||||
}),
|
||||
evictTo: (keep: number, freshMs: number) => enqueue(async () => {
|
||||
const retired: string[] = []
|
||||
const overCap = () => [...deps.pool.values()].filter(entry => entry.process).length > Math.max(0, keep)
|
||||
return entry ? retire(key, entry, () => Date.now() - (entry.lastActiveAt || 0) > idleMs) : false
|
||||
}),
|
||||
evictTo: (keep: number, freshMs: number) =>
|
||||
enqueue(async () => {
|
||||
const retired: string[] = []
|
||||
const overCap = () => [...deps.pool.values()].filter(entry => entry.process).length > Math.max(0, keep)
|
||||
|
||||
for (const [key, entry] of selectRetirementCandidates(deps.pool, deps.coordinator.foregroundWaiters)) {
|
||||
if (await retire(key, entry, () => overCap() && Date.now() - (entry.lastActiveAt || 0) > freshMs)) {
|
||||
retired.push(key)
|
||||
for (const [key, entry] of selectRetirementCandidates(deps.pool, deps.coordinator.foregroundWaiters)) {
|
||||
if (await retire(key, entry, () => overCap() && Date.now() - (entry.lastActiveAt || 0) > freshMs)) {
|
||||
retired.push(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return retired
|
||||
}),
|
||||
return retired
|
||||
}),
|
||||
dispose: () => {
|
||||
disposed = true
|
||||
clearTimeout(retry)
|
||||
unsubscribe()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { stopBackendChild, waitForBackendExit } from '../backend-child'
|
||||
import { createPoolRetirer } from '../pool-retire'
|
||||
import { createPoolRetirementClient } from '../pool-retire-http'
|
||||
import { LocalBackendSpawnCoordinator, type LocalBackendSpawnRequest, registerLocalBackendExitFinalizer } from '../pool-spawn-coordinator'
|
||||
import {
|
||||
LocalBackendSpawnCoordinator,
|
||||
type LocalBackendSpawnRequest,
|
||||
registerLocalBackendExitFinalizer
|
||||
} from '../pool-spawn-coordinator'
|
||||
|
||||
interface Resident {
|
||||
process: ChildProcessWithoutNullStreams
|
||||
@@ -56,17 +60,25 @@ let window: BrowserWindow | undefined
|
||||
let descriptorCalls = 0
|
||||
let rendererParking: { parked: Record<string, string[]>; redials: number; open: number } | undefined
|
||||
|
||||
const stopDeps = { forceKillProcessTree: () => { throw new Error('POSIX fixture only') } }
|
||||
const stopDeps = {
|
||||
forceKillProcessTree: () => {
|
||||
throw new Error('POSIX fixture only')
|
||||
}
|
||||
}
|
||||
|
||||
const record = (event: string, key?: string, details: Record<string, unknown> = {}) => {
|
||||
receipts.push({ event, key, active: coordinator.activeCount, live: live.size, ...details })
|
||||
}
|
||||
|
||||
const alive = (pid: number) => {
|
||||
try { process.kill(pid, 0);
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
|
||||
return true } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {return false}
|
||||
return true
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -74,9 +86,11 @@ const alive = (pid: number) => {
|
||||
async function until(description: string, predicate: () => boolean | Promise<boolean>, timeoutMs = 30_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (!await predicate()) {
|
||||
while (!(await predicate())) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`Timed out: ${description}\n${JSON.stringify(receipts)}\n${[...children].map(([key, entry]) => `${key}: ${entry.output}`).join('\n')}`)
|
||||
throw new Error(
|
||||
`Timed out: ${description}\n${JSON.stringify(receipts)}\n${[...children].map(([key, entry]) => `${key}: ${entry.output}`).join('\n')}`
|
||||
)
|
||||
}
|
||||
|
||||
await delay(30)
|
||||
@@ -85,23 +99,32 @@ async function until(description: string, predicate: () => boolean | Promise<boo
|
||||
|
||||
async function http(entry: Resident, route: string, body?: Record<string, string>, token = entry.token) {
|
||||
const response = await fetch(`http://127.0.0.1:${entry.port}${route}`, {
|
||||
method: body ? 'POST' : 'GET', headers: {
|
||||
'Content-Type': 'application/json', ...(token ? { 'X-Hermes-Session-Token': token } : {}),
|
||||
}, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(5000),
|
||||
method: body ? 'POST' : 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'X-Hermes-Session-Token': token } : {})
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(5000)
|
||||
})
|
||||
|
||||
return { status: response.status, body: await response.json() as Record<string, unknown> }
|
||||
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
|
||||
}
|
||||
|
||||
const client = createPoolRetirementClient(async (url, token, options) => {
|
||||
const response = await fetch(url, { method: options.method,
|
||||
const response = await fetch(url, {
|
||||
method: options.method,
|
||||
headers: { 'X-Hermes-Session-Token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options.body), signal: AbortSignal.timeout(options.timeoutMs) })
|
||||
body: JSON.stringify(options.body),
|
||||
signal: AbortSignal.timeout(options.timeoutMs)
|
||||
})
|
||||
|
||||
const reply = await response.json() as Record<string, unknown>
|
||||
const reply = (await response.json()) as Record<string, unknown>
|
||||
record('http', undefined, { action: options.body.action, status: response.status, ok: reply.ok, idle: reply.idle })
|
||||
|
||||
if (!response.ok) {throw new Error(`Retirement HTTP ${response.status}: ${JSON.stringify(reply)}`)}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Retirement HTTP ${response.status}: ${JSON.stringify(reply)}`)
|
||||
}
|
||||
|
||||
return reply
|
||||
})
|
||||
@@ -113,24 +136,46 @@ async function spawnResident(key: string, release: () => void): Promise<Resident
|
||||
const token = randomUUID()
|
||||
|
||||
const child = spawn(python, ['-u', join(fixture, 'serve.py'), repo, key], {
|
||||
cwd: join(root, key), detached: true, stdio: 'pipe',
|
||||
env: { ...process.env, HOME: join(root, key), USERPROFILE: join(root, key), HERMES_HOME: home,
|
||||
HERMES_DESKTOP: '1', HERMES_SERVE_HEADLESS: '1', HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
PYTHONUNBUFFERED: '1', PYTHONDONTWRITEBYTECODE: '1', PYTHONNOUSERSITE: '1',
|
||||
cwd: join(root, key),
|
||||
detached: true,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: join(root, key),
|
||||
USERPROFILE: join(root, key),
|
||||
HERMES_HOME: home,
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_SERVE_HEADLESS: '1',
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONDONTWRITEBYTECODE: '1',
|
||||
PYTHONNOUSERSITE: '1'
|
||||
// Environment contains only the launch allowlist and fixture values.
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const entry: Resident = { process: child, home,
|
||||
token, port: null, activeTurn: false,
|
||||
const entry: Resident = {
|
||||
process: child,
|
||||
home,
|
||||
token,
|
||||
port: null,
|
||||
activeTurn: false,
|
||||
// All residents are renderer-occupied/recent; backend-only work is the authority.
|
||||
lastActiveAt: Date.now(), output: '' }
|
||||
lastActiveAt: Date.now(),
|
||||
output: ''
|
||||
}
|
||||
|
||||
children.set(key, entry)
|
||||
pool.set(key, entry)
|
||||
child.on('error', error => { entry.output += String(error) })
|
||||
child.stdout.on('data', chunk => { entry.output += String(chunk) })
|
||||
child.stderr.on('data', chunk => { entry.output += String(chunk) })
|
||||
child.on('error', error => {
|
||||
entry.output += String(error)
|
||||
})
|
||||
child.stdout.on('data', chunk => {
|
||||
entry.output += String(chunk)
|
||||
})
|
||||
child.stderr.on('data', chunk => {
|
||||
entry.output += String(chunk)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
live.delete(child.pid!)
|
||||
record('exit', key, { pid: child.pid, code, signal })
|
||||
@@ -141,20 +186,31 @@ async function spawnResident(key: string, release: () => void): Promise<Resident
|
||||
})
|
||||
await new Promise<void>((resolveSpawn, reject) => {
|
||||
child.once('spawn', resolveSpawn)
|
||||
child.once('error', error => { release(); reject(error) })
|
||||
child.once('error', error => {
|
||||
release()
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
live.add(child.pid!)
|
||||
maxLiveServeChildren = Math.max(maxLiveServeChildren, live.size)
|
||||
record('spawn', key, { pid: child.pid })
|
||||
assert.ok(live.size <= coordinator.limit, 'Live OS children exceeded the pool cap')
|
||||
await until(`${key} HERMES_BACKEND_READY`, () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {throw new Error(`${key} exited before ready:\n${entry.output}`)}
|
||||
const match = entry.output.match(/HERMES_BACKEND_READY[^\n]*port=(\d+)/)
|
||||
await until(
|
||||
`${key} HERMES_BACKEND_READY`,
|
||||
() => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(`${key} exited before ready:\n${entry.output}`)
|
||||
}
|
||||
const match = entry.output.match(/HERMES_BACKEND_READY[^\n]*port=(\d+)/)
|
||||
|
||||
if (match) {entry.port = Number(match[1])}
|
||||
if (match) {
|
||||
entry.port = Number(match[1])
|
||||
}
|
||||
|
||||
return Boolean(match)
|
||||
}, 60_000)
|
||||
return Boolean(match)
|
||||
},
|
||||
60_000
|
||||
)
|
||||
record('ready', key, { pid: child.pid, port: entry.port })
|
||||
|
||||
return entry
|
||||
@@ -187,20 +243,36 @@ async function run() {
|
||||
const entry = children.get(key)
|
||||
assert.ok(entry?.port, `No real backend for ${key}`)
|
||||
|
||||
return { mode: 'local', authMode: 'token', profile: key, token: entry.token,
|
||||
return {
|
||||
mode: 'local',
|
||||
authMode: 'token',
|
||||
profile: key,
|
||||
token: entry.token,
|
||||
baseUrl: `http://127.0.0.1:${entry.port}`,
|
||||
wsUrl: `ws://127.0.0.1:${entry.port}/api/ws?token=${encodeURIComponent(entry.token)}` }
|
||||
wsUrl: `ws://127.0.0.1:${entry.port}/api/ws?token=${encodeURIComponent(entry.token)}`
|
||||
}
|
||||
})
|
||||
window = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: join(root, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
window = new BrowserWindow({ show: false, webPreferences: {
|
||||
preload: join(root, 'preload.cjs'), contextIsolation: true, nodeIntegration: false,
|
||||
sandbox: true, backgroundThrottling: false,
|
||||
} })
|
||||
await window.loadFile(join(root, 'renderer.html'))
|
||||
assert.equal(await window.webContents.executeJavaScript('window.poolRetirementRenderer.open()'), 4,
|
||||
'Real renderer opens legacy and registry WebSockets for both idle children')
|
||||
assert.equal(
|
||||
await window.webContents.executeJavaScript('window.poolRetirementRenderer.open()'),
|
||||
4,
|
||||
'Real renderer opens legacy and registry WebSockets for both idle children'
|
||||
)
|
||||
const dialsBeforeRetirement = descriptorCalls
|
||||
|
||||
retirer = createPoolRetirer<Resident>({ pool, coordinator,
|
||||
retirer = createPoolRetirer<Resident>({
|
||||
pool,
|
||||
coordinator,
|
||||
prepare: async (key, entry) => {
|
||||
const token = await client.prepare(key, entry)
|
||||
record(token ? 'prepared' : 'busy', key)
|
||||
@@ -212,8 +284,11 @@ async function run() {
|
||||
|
||||
if (committed) {
|
||||
assert.equal(await client.prepare(key, entry), token, 'A lost commit reply must recover the committed permit')
|
||||
assert.equal((await http(entry, '/api/health/retirement', { action: 'cancel', token })).body.ok, false,
|
||||
'Committed retirement cannot reopen admission')
|
||||
assert.equal(
|
||||
(await http(entry, '/api/health/retirement', { action: 'cancel', token })).body.ok,
|
||||
false,
|
||||
'Committed retirement cannot reopen admission'
|
||||
)
|
||||
record('committed', key)
|
||||
}
|
||||
|
||||
@@ -242,7 +317,9 @@ async function run() {
|
||||
await exit
|
||||
assert.equal(alive(entry.process.pid!), false)
|
||||
},
|
||||
log: message => { errors.push(message) },
|
||||
log: message => {
|
||||
errors.push(message)
|
||||
}
|
||||
})
|
||||
const direct = coordinator.request('foreground-d', { timeoutMs: 45_000, priority: 'foreground' })
|
||||
const promoted = coordinator.request('promoted-e', { timeoutMs: 45_000, priority: 'background' })
|
||||
@@ -252,19 +329,30 @@ async function run() {
|
||||
assert.equal(promoted.promote('foreground'), true)
|
||||
assert.deepEqual([...coordinator.foregroundWaiters], ['foreground-d', 'promoted-e'])
|
||||
record('waiters', undefined, { keys: [...coordinator.foregroundWaiters] })
|
||||
await Promise.all(([['foreground-d', direct], ['promoted-e', promoted]] as const).map(async ([name, ticket]) => {
|
||||
const release = await ticket.acquired
|
||||
opened.push(name)
|
||||
record('acquired', name)
|
||||
await spawnResident(name, release)
|
||||
}))
|
||||
await Promise.all(
|
||||
(
|
||||
[
|
||||
['foreground-d', direct],
|
||||
['promoted-e', promoted]
|
||||
] as const
|
||||
).map(async ([name, ticket]) => {
|
||||
const release = await ticket.acquired
|
||||
opened.push(name)
|
||||
record('acquired', name)
|
||||
await spawnResident(name, release)
|
||||
})
|
||||
)
|
||||
assert.deepEqual(errors, [])
|
||||
assert.deepEqual(retired, ['idle-a', 'idle-b'])
|
||||
assert.equal(coordinator.queuedCount, 0)
|
||||
assert.equal(coordinator.activeCount, 3)
|
||||
|
||||
for (const [oldKey, newKey] of [['idle-a', 'foreground-d'], ['idle-b', 'promoted-e']]) {
|
||||
const index = (event: string, key: string) => receipts.findIndex(receipt => receipt.event === event && receipt.key === key)
|
||||
for (const [oldKey, newKey] of [
|
||||
['idle-a', 'foreground-d'],
|
||||
['idle-b', 'promoted-e']
|
||||
]) {
|
||||
const index = (event: string, key: string) =>
|
||||
receipts.findIndex(receipt => receipt.event === event && receipt.key === key)
|
||||
assert.ok(index('committed', oldKey) < index('park', oldKey))
|
||||
assert.ok(index('park', oldKey) < index('signal', oldKey))
|
||||
assert.ok(index('signal', oldKey) < index('shutdown-held', oldKey))
|
||||
@@ -277,7 +365,10 @@ async function run() {
|
||||
assert.ok(receipts.filter(receipt => receipt.event === 'release').every(receipt => receipt.osAlive === false))
|
||||
assert.ok(alive(cron.process.pid!) && alive(cronPid))
|
||||
assert.equal((await http(cron, '/api/health/idle')).body.idle, false)
|
||||
await until('cron made progress after reclamation', () => readFileSync(join(cron.home, 'cron-heartbeat'), 'utf8') !== heartbeat)
|
||||
await until(
|
||||
'cron made progress after reclamation',
|
||||
() => readFileSync(join(cron.home, 'cron-heartbeat'), 'utf8') !== heartbeat
|
||||
)
|
||||
assert.equal(retired.includes('cron-busy'), false)
|
||||
busyCronSurvived = true
|
||||
const parked = await window.webContents.executeJavaScript('window.poolRetirementRenderer.wake()')
|
||||
@@ -288,21 +379,32 @@ async function run() {
|
||||
assert.equal(rendererParking?.redials, 0, 'Socket close and wake sweeps must not redial parked scopes')
|
||||
record('renderer-parked', undefined, rendererParking)
|
||||
writeFileSync(join(cron.home, 'finish-cron'), '')
|
||||
await until('real cron completed and released backend activity', async () =>
|
||||
existsSync(join(cron.home, 'cron-finished')) && !alive(cronPid) && (await http(cron, '/api/health/idle')).body.idle === true)
|
||||
await until(
|
||||
'real cron completed and released backend activity',
|
||||
async () =>
|
||||
existsSync(join(cron.home, 'cron-finished')) &&
|
||||
!alive(cronPid) &&
|
||||
(await http(cron, '/api/health/idle')).body.idle === true
|
||||
)
|
||||
record('cron-completed', 'cron-busy', { pid: cronPid })
|
||||
}
|
||||
|
||||
async function finish(error?: unknown) {
|
||||
if (finishing) {return}
|
||||
if (finishing) {
|
||||
return
|
||||
}
|
||||
finishing = true
|
||||
retirer?.dispose()
|
||||
|
||||
for (const ticket of tickets) {ticket.cancel()}
|
||||
for (const ticket of tickets) {
|
||||
ticket.cancel()
|
||||
}
|
||||
let cleanupError: unknown
|
||||
|
||||
try {
|
||||
if (window && !window.isDestroyed()) {window.destroy()}
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.destroy()
|
||||
}
|
||||
|
||||
for (const entry of children.values()) {
|
||||
writeFileSync(join(entry.home, 'finish-cron'), '')
|
||||
@@ -313,28 +415,56 @@ async function finish(error?: unknown) {
|
||||
|
||||
if (cron && existsSync(join(cron.home, 'cron-started'))) {
|
||||
const pid = JSON.parse(readFileSync(join(cron.home, 'cron-started'), 'utf8')).pid as number
|
||||
await until('cron script cleanup', () => !alive(pid), 5000).catch(() => { process.kill(pid, 'SIGKILL') })
|
||||
await until('cron script cleanup', () => !alive(pid), 5000).catch(() => {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.all([...children.values()].map(async entry => {
|
||||
stopBackendChild(entry.process, stopDeps)
|
||||
await waitForBackendExit(entry.process, stopDeps, 5000)
|
||||
}))
|
||||
await Promise.all(
|
||||
[...children.values()].map(async entry => {
|
||||
stopBackendChild(entry.process, stopDeps)
|
||||
await waitForBackendExit(entry.process, stopDeps, 5000)
|
||||
})
|
||||
)
|
||||
assert.equal(live.size, 0)
|
||||
assert.equal(coordinator.activeCount, 0)
|
||||
} catch (caught) { cleanupError = caught }
|
||||
} catch (caught) {
|
||||
cleanupError = caught
|
||||
}
|
||||
|
||||
const failure = error || cleanupError
|
||||
writeFileSync(join(root, 'result.json'), JSON.stringify({
|
||||
ok: !failure, error: failure ? String((failure as Error).stack || failure) : undefined,
|
||||
cleanupError: cleanupError ? String(cleanupError) : undefined, cleanedUp: !cleanupError,
|
||||
electronVersion: process.versions.electron, processType: process.type,
|
||||
capacity: coordinator.limit, maxLiveServeChildren, retired, opened, busyCronSurvived,
|
||||
rendererParking, receipts,
|
||||
...(failure ? { backendOutput: Object.fromEntries([...children].map(([key, entry]) => [key, entry.output])) } : {}),
|
||||
}, null, 2))
|
||||
writeFileSync(
|
||||
join(root, 'result.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: !failure,
|
||||
error: failure ? String((failure as Error).stack || failure) : undefined,
|
||||
cleanupError: cleanupError ? String(cleanupError) : undefined,
|
||||
cleanedUp: !cleanupError,
|
||||
electronVersion: process.versions.electron,
|
||||
processType: process.type,
|
||||
capacity: coordinator.limit,
|
||||
maxLiveServeChildren,
|
||||
retired,
|
||||
opened,
|
||||
busyCronSurvived,
|
||||
rendererParking,
|
||||
receipts,
|
||||
...(failure
|
||||
? { backendOutput: Object.fromEntries([...children].map(([key, entry]) => [key, entry.output])) }
|
||||
: {})
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
app.exit(failure ? 1 : 0)
|
||||
}
|
||||
|
||||
process.once('SIGTERM', () => { void finish(new Error('Native fixture interrupted')) })
|
||||
app.whenReady().then(run).then(() => finish(), finish)
|
||||
process.once('SIGTERM', () => {
|
||||
void finish(new Error('Native fixture interrupted'))
|
||||
})
|
||||
app
|
||||
.whenReady()
|
||||
.then(run)
|
||||
.then(() => finish(), finish)
|
||||
|
||||
@@ -3,10 +3,10 @@ import { contextBridge, ipcRenderer } from 'electron'
|
||||
// Deliberate fixture IPC only; the renderer still uses production gateway/WS code.
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getConnection: (profile: string) => ipcRenderer.invoke('retirement-fixture:descriptor', profile),
|
||||
getConnectionFor: ({ profile }: { profile: string }) => ipcRenderer.invoke('retirement-fixture:descriptor', profile),
|
||||
getConnectionFor: ({ profile }: { profile: string }) => ipcRenderer.invoke('retirement-fixture:descriptor', profile)
|
||||
})
|
||||
contextBridge.exposeInMainWorld('retirementFixture', {
|
||||
onRetiring: (handler: (key: string) => void) => {
|
||||
ipcRenderer.on('retirement-fixture:retiring', (_event, key: string) => handler(key))
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,5 +5,5 @@ import { defineConfig } from 'vitest/config'
|
||||
// Targeted native seam proof: no unrelated renderer Vite plugins or full app build.
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL('../../', import.meta.url)),
|
||||
test: { environment: 'node', include: ['electron/pool-retirement-live.test.ts'] },
|
||||
test: { environment: 'node', include: ['electron/pool-retirement-live.test.ts'] }
|
||||
})
|
||||
|
||||
@@ -23,20 +23,39 @@ function isolatedEnv(root: string): NodeJS.ProcessEnv {
|
||||
|
||||
// Allowlist rather than trying to enumerate provider secrets and overrides.
|
||||
for (const name of ['PATH', 'SystemRoot', 'WINDIR', 'DISPLAY', 'WAYLAND_DISPLAY', 'XDG_RUNTIME_DIR']) {
|
||||
if (process.env[name]) {env[name] = process.env[name]}
|
||||
if (process.env[name]) {
|
||||
env[name] = process.env[name]
|
||||
}
|
||||
}
|
||||
|
||||
return { ...env, HOME: root, USERPROFILE: root, HERMES_HOME: join(root, '.hermes'),
|
||||
XDG_CONFIG_HOME: join(root, 'config'), XDG_CACHE_HOME: join(root, 'cache'),
|
||||
TMPDIR: root, TEMP: root, TMP: root, TZ: 'UTC', LANG: 'C.UTF-8',
|
||||
HERMES_DESKTOP_CDP_PORT: 'off', HERMES_DESKTOP_USER_DATA_DIR: join(root, 'user-data') }
|
||||
return {
|
||||
...env,
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
HERMES_HOME: join(root, '.hermes'),
|
||||
XDG_CONFIG_HOME: join(root, 'config'),
|
||||
XDG_CACHE_HOME: join(root, 'cache'),
|
||||
TMPDIR: root,
|
||||
TEMP: root,
|
||||
TMP: root,
|
||||
TZ: 'UTC',
|
||||
LANG: 'C.UTF-8',
|
||||
HERMES_DESKTOP_CDP_PORT: 'off',
|
||||
HERMES_DESKTOP_USER_DATA_DIR: join(root, 'user-data')
|
||||
}
|
||||
}
|
||||
|
||||
function waitForExit(child: ChildProcess, timeoutMs: number): Promise<number | null> {
|
||||
return new Promise((resolveExit, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Native retirement fixture timed out')), timeoutMs)
|
||||
child.once('error', error => { clearTimeout(timer); reject(error) })
|
||||
child.once('exit', code => { clearTimeout(timer); resolveExit(code) })
|
||||
child.once('error', error => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.once('exit', code => {
|
||||
clearTimeout(timer)
|
||||
resolveExit(code)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,7 +64,7 @@ test.skipIf(process.env.HERMES_TEST_REAL_SERVE !== '1' || process.platform === '
|
||||
async () => {
|
||||
const python = process.env.HERMES_TEST_PYTHON
|
||||
assert.ok(python && existsSync(python), 'Set HERMES_TEST_PYTHON to an installed Hermes Python environment')
|
||||
const electron = process.env.HERMES_TEST_ELECTRON || require('electron') as string
|
||||
const electron = process.env.HERMES_TEST_ELECTRON || (require('electron') as string)
|
||||
assert.ok(existsSync(electron), 'HERMES_TEST_ELECTRON must name a real native Electron executable')
|
||||
const root = mkdtempSync(join(tmpdir(), 'hermes-pool-retirement-live-'))
|
||||
const resultPath = join(root, 'result.json')
|
||||
@@ -55,21 +74,48 @@ test.skipIf(process.env.HERMES_TEST_REAL_SERVE !== '1' || process.platform === '
|
||||
try {
|
||||
mkdirSync(join(root, '.hermes'))
|
||||
const bundle = join(root, 'main.cjs')
|
||||
await build({ entryPoints: [join(fixture, 'main.ts')], outfile: bundle,
|
||||
bundle: true, platform: 'node', format: 'cjs', target: 'node22', external: ['electron'] })
|
||||
await build({ entryPoints: [join(fixture, 'preload.ts')], outfile: join(root, 'preload.cjs'),
|
||||
bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
|
||||
await build({ entryPoints: [join(desktop, 'src/test/pool-retirement-renderer.ts')], outfile: join(root, 'renderer.js'),
|
||||
bundle: true, platform: 'browser', format: 'iife',
|
||||
await build({
|
||||
entryPoints: [join(fixture, 'main.ts')],
|
||||
outfile: bundle,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node22',
|
||||
external: ['electron']
|
||||
})
|
||||
await build({
|
||||
entryPoints: [join(fixture, 'preload.ts')],
|
||||
outfile: join(root, 'preload.cjs'),
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
external: ['electron']
|
||||
})
|
||||
await build({
|
||||
entryPoints: [join(desktop, 'src/test/pool-retirement-renderer.ts')],
|
||||
outfile: join(root, 'renderer.js'),
|
||||
bundle: true,
|
||||
platform: 'browser',
|
||||
format: 'iife',
|
||||
alias: { '@': join(desktop, 'src'), '@hermes/shared': join(repo, 'apps/shared/src') },
|
||||
define: { 'import.meta.env': '{}', 'import.meta.hot': 'undefined' } })
|
||||
writeFileSync(join(root, 'renderer.html'), '<!doctype html><meta charset="utf-8"><title>Retirement seam fixture</title><script src="./renderer.js"></script>')
|
||||
define: { 'import.meta.env': '{}', 'import.meta.hot': 'undefined' }
|
||||
})
|
||||
writeFileSync(
|
||||
join(root, 'renderer.html'),
|
||||
'<!doctype html><meta charset="utf-8"><title>Retirement seam fixture</title><script src="./renderer.js"></script>'
|
||||
)
|
||||
child = spawn(electron, [bundle, root, repo, python, fixture], {
|
||||
cwd: root, env: isolatedEnv(root), stdio: ['ignore', 'pipe', 'pipe'],
|
||||
cwd: root,
|
||||
env: isolatedEnv(root),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
// Capture the OS handle now; do not ask a disposed Electron dispatcher after quit.
|
||||
child.stdout!.on('data', chunk => { output += String(chunk) })
|
||||
child.stderr!.on('data', chunk => { output += String(chunk) })
|
||||
child.stdout!.on('data', chunk => {
|
||||
output += String(chunk)
|
||||
})
|
||||
child.stderr!.on('data', chunk => {
|
||||
output += String(chunk)
|
||||
})
|
||||
const code = await waitForExit(child, 180_000)
|
||||
assert.ok(existsSync(resultPath), `No native result (exit ${code}):\n${output}`)
|
||||
const result = JSON.parse(readFileSync(resultPath, 'utf8'))
|
||||
@@ -97,5 +143,6 @@ test.skipIf(process.env.HERMES_TEST_REAL_SERVE !== '1' || process.platform === '
|
||||
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 210_000,
|
||||
},
|
||||
210_000
|
||||
)
|
||||
|
||||
@@ -160,7 +160,9 @@ export class LocalBackendSpawnCoordinator {
|
||||
onChange(listener: () => void): () => void {
|
||||
this.#listeners.add(listener)
|
||||
|
||||
return () => { this.#listeners.delete(listener) }
|
||||
return () => {
|
||||
this.#listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
#changed(): void {
|
||||
|
||||
@@ -151,9 +151,11 @@ test('afterStop holds inFlight until extra teardown finishes (process-less SSH)'
|
||||
const pool = new Map<string, PoolStopEntry>()
|
||||
const events: string[] = []
|
||||
let releaseAfter: (() => void) | undefined
|
||||
|
||||
const afterGate = new Promise<void>(resolve => {
|
||||
releaseAfter = resolve
|
||||
})
|
||||
|
||||
const stopper = createPoolStopper({
|
||||
pool,
|
||||
stopChild: () => {
|
||||
@@ -178,6 +180,7 @@ test('afterStop holds inFlight until extra teardown finishes (process-less SSH)'
|
||||
assert.deepEqual(events, ['stop', 'exit', 'after-start'])
|
||||
|
||||
let spawned = false
|
||||
|
||||
const respawn = (async () => {
|
||||
const dying = stopper.inFlight('ssh')
|
||||
|
||||
@@ -225,11 +228,15 @@ test('a respawn can await the in-flight stop before reusing the key', async () =
|
||||
test('failed teardown blocks same-profile respawn until the actual late exit', async () => {
|
||||
const child = Object.assign(new EventEmitter(), { exitCode: null, signalCode: null })
|
||||
const pool = new Map([['profile', { process: child }]])
|
||||
|
||||
const stopper = createPoolStopper({
|
||||
pool,
|
||||
stopChild: () => {},
|
||||
waitForExit: async () => { throw new Error('child did not exit') }
|
||||
waitForExit: async () => {
|
||||
throw new Error('child did not exit')
|
||||
}
|
||||
})
|
||||
|
||||
const stopping = stopper.stop('profile')
|
||||
|
||||
await assert.rejects(stopping, /did not exit/)
|
||||
|
||||
@@ -78,9 +78,11 @@ export function createPoolStopper(deps: PoolStopperDeps): PoolStopper {
|
||||
stops.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const stopping = (async () => {
|
||||
deps.stopChild(entry.process)
|
||||
await deps.waitForExit(entry.process)
|
||||
|
||||
if (deps.afterStop) {
|
||||
await deps.afterStop(key)
|
||||
}
|
||||
|
||||
@@ -122,7 +122,10 @@ async function run() {
|
||||
await jar.cookies.remove(base, accessName())
|
||||
mode = 'renew'
|
||||
const before = refreshes
|
||||
const [first, second] = await Promise.all([portal.renewPortalAccessSilently(), portal.renewPortalAccessSilently()])
|
||||
const [first, second] = await Promise.all([
|
||||
portal.renewPortalAccessSilently(),
|
||||
portal.renewPortalAccessSilently()
|
||||
])
|
||||
assert.equal(first, true)
|
||||
assert.equal(second, true)
|
||||
assert.equal(refreshes, before + 1)
|
||||
@@ -192,7 +195,9 @@ async function run() {
|
||||
assert.equal(await portal.renewPortalAccessSilently(), false)
|
||||
console.log('PORTAL_SESSION_LIVE_OK')
|
||||
} finally {
|
||||
for (const window of BrowserWindow.getAllWindows()) {window.destroy()}
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.destroy()
|
||||
}
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ const displayPrefix = (() => {
|
||||
return []
|
||||
}
|
||||
|
||||
const xvfbRun = (process.env.PATH ?? '').split(delimiter).map(dir => join(dir, 'xvfb-run')).find(existsSync)
|
||||
const xvfbRun = (process.env.PATH ?? '')
|
||||
.split(delimiter)
|
||||
.map(dir => join(dir, 'xvfb-run'))
|
||||
.find(existsSync)
|
||||
|
||||
return xvfbRun ? [xvfbRun, '-a'] : null
|
||||
})()
|
||||
@@ -42,7 +45,9 @@ test.skipIf(displayPrefix === null)(
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
for (const name of ['PATH', 'SystemRoot', 'WINDIR', 'DISPLAY', 'WAYLAND_DISPLAY', 'XDG_RUNTIME_DIR']) {
|
||||
if (process.env[name]) {env[name] = process.env[name]}
|
||||
if (process.env[name]) {
|
||||
env[name] = process.env[name]
|
||||
}
|
||||
}
|
||||
|
||||
const electron: string = createRequire(import.meta.url)('electron')
|
||||
@@ -66,7 +71,9 @@ test.skipIf(displayPrefix === null)(
|
||||
// execFile's rejection carries only "Command failed"; Electron's real
|
||||
// reason (sandbox abort, missing libs, fixture assertion) is on stderr.
|
||||
const { stderr = '', stdout: partial = '' } = error as { stderr?: string; stdout?: string }
|
||||
throw new Error(`Electron fixture failed.\n--- stdout ---\n${partial}\n--- stderr ---\n${stderr}`, { cause: error })
|
||||
throw new Error(`Electron fixture failed.\n--- stdout ---\n${partial}\n--- stderr ---\n${stderr}`, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
|
||||
expect(stdout).toContain('PORTAL_SESSION_LIVE_OK')
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('readPreUpdateBackupEnabled', () => {
|
||||
['an environment-expanded off alias', '"off"', false]
|
||||
])('uses the effective config result for %s', async (_name, stdout, expected) => {
|
||||
const run = vi.fn().mockResolvedValue({ stdout })
|
||||
|
||||
const runtime = {
|
||||
command: '/runtime/python',
|
||||
args: ['-m', 'hermes_cli.main', 'config', 'get', 'updates.pre_update_backup', '--json'],
|
||||
|
||||
@@ -182,33 +182,39 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
}
|
||||
},
|
||||
// macOS native screenshot gesture; captures require a main-issued request.
|
||||
screenshot: process.platform === 'darwin' ? {
|
||||
getSettings: () => ipcRenderer.invoke('hermes:screenshot:settings:get'),
|
||||
setEnabled: enabled => ipcRenderer.invoke('hermes:screenshot:settings:set', enabled),
|
||||
openPermissionSettings: kind => ipcRenderer.invoke('hermes:screenshot:permission', kind),
|
||||
capture: requestId => ipcRenderer.invoke('hermes:screenshot:capture', requestId),
|
||||
onStatus: callback => {
|
||||
const listener = (_event, status) => callback(status)
|
||||
ipcRenderer.on('hermes:screenshot:status', listener)
|
||||
screenshot:
|
||||
process.platform === 'darwin'
|
||||
? {
|
||||
getSettings: () => ipcRenderer.invoke('hermes:screenshot:settings:get'),
|
||||
setEnabled: enabled => ipcRenderer.invoke('hermes:screenshot:settings:set', enabled),
|
||||
openPermissionSettings: kind => ipcRenderer.invoke('hermes:screenshot:permission', kind),
|
||||
capture: requestId => ipcRenderer.invoke('hermes:screenshot:capture', requestId),
|
||||
onStatus: callback => {
|
||||
const listener = (_event, status) => callback(status)
|
||||
ipcRenderer.on('hermes:screenshot:status', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:screenshot:status', listener)
|
||||
},
|
||||
onRequest: callback => {
|
||||
const channel = 'hermes:screenshot:request'
|
||||
const listener = (_event, requestId) => callback(requestId)
|
||||
if (ipcRenderer.listenerCount(channel) === 0) {
|
||||
ipcRenderer.send('hermes:screenshot:subscribe', true)
|
||||
}
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => ipcRenderer.removeListener('hermes:screenshot:status', listener)
|
||||
},
|
||||
onRequest: callback => {
|
||||
const channel = 'hermes:screenshot:request'
|
||||
const listener = (_event, requestId) => callback(requestId)
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, listener)
|
||||
if (ipcRenderer.listenerCount(channel) === 0) {
|
||||
ipcRenderer.send('hermes:screenshot:subscribe', false)
|
||||
if (ipcRenderer.listenerCount(channel) === 0) {
|
||||
ipcRenderer.send('hermes:screenshot:subscribe', true)
|
||||
}
|
||||
|
||||
ipcRenderer.on(channel, listener)
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, listener)
|
||||
|
||||
if (ipcRenderer.listenerCount(channel) === 0) {
|
||||
ipcRenderer.send('hermes:screenshot:subscribe', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} : undefined,
|
||||
: undefined,
|
||||
// Quick Entry: the global-hotkey mini composer window. Main owns the OS
|
||||
// shortcut + the persisted preference; the quick window only captures text
|
||||
// and hands it back, and the primary renderer submits it through the normal
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createQuitFinalization } from './quit-finalization'
|
||||
test('forces a single Windows exit once the admitted quit exceeds its deadline; never arms off Windows', () => {
|
||||
let onTimeout: (() => void) | undefined
|
||||
const hardExit = vi.fn()
|
||||
|
||||
const finalization = createQuitFinalization({
|
||||
isWindows: true,
|
||||
schedule: callback => {
|
||||
@@ -42,11 +43,13 @@ test('a completed quit cancels the fallback and it never re-arms', () => {
|
||||
let onTimeout: (() => void) | undefined
|
||||
const cancel = vi.fn()
|
||||
const hardExit = vi.fn()
|
||||
|
||||
const schedule = vi.fn((callback: () => void) => {
|
||||
onTimeout = callback
|
||||
|
||||
return 'timer'
|
||||
})
|
||||
|
||||
const finalization = createQuitFinalization({ isWindows: true, schedule, cancel, hardExit })
|
||||
|
||||
finalization.arm()
|
||||
|
||||
@@ -1579,15 +1579,8 @@ test('buildSpawnCommand scopes umask 077 to the mkdir subshell (no leak into ser
|
||||
// umask 077 must apply only to the reservation-parent mkdir. A bare
|
||||
// umask call at the top level leaks 077 into the rest of the payload,
|
||||
// so the detached setsid backend inherits 077 instead of the login umask.
|
||||
assert.ok(
|
||||
cmd.includes('(umask 077 && mkdir -p'),
|
||||
'umask 077 must be scoped to a mkdir subshell'
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
cmd,
|
||||
/(^|[^(])umask 077/,
|
||||
'no bare umask 077 may leak into the spawn chain'
|
||||
)
|
||||
assert.ok(cmd.includes('(umask 077 && mkdir -p'), 'umask 077 must be scoped to a mkdir subshell')
|
||||
assert.doesNotMatch(cmd, /(^|[^(])umask 077/, 'no bare umask 077 may leak into the spawn chain')
|
||||
assert.ok(
|
||||
cmd.indexOf('(umask 077') < cmd.indexOf('serve --isolated'),
|
||||
'scoped mkdir must still precede the serve spawn'
|
||||
@@ -1889,33 +1882,36 @@ test('remote SSH ownership capability requires both secure bootstrap flags', asy
|
||||
assert.equal(await remoteSupportsSshOwnership(unsupported, '/x/hermes'), false)
|
||||
})
|
||||
|
||||
test.skipIf(process.platform === 'win32')('capability probe survives a zsh login shell on the remote (#111949)', async t => {
|
||||
// sshd runs the remote command under the account's LOGIN shell. A bare
|
||||
// `set -m` is fatal in a non-interactive zsh, so the watchdog-wrapped probe
|
||||
// used to return nothing and a current remote was reported as unsupported.
|
||||
const zsh = await exec('command -v zsh || true').then(r => r.stdout.trim())
|
||||
test.skipIf(process.platform === 'win32')(
|
||||
'capability probe survives a zsh login shell on the remote (#111949)',
|
||||
async t => {
|
||||
// sshd runs the remote command under the account's LOGIN shell. A bare
|
||||
// `set -m` is fatal in a non-interactive zsh, so the watchdog-wrapped probe
|
||||
// used to return nothing and a current remote was reported as unsupported.
|
||||
const zsh = await exec('command -v zsh || true').then(r => r.stdout.trim())
|
||||
|
||||
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
|
||||
// skip, not a pass, or a wrapper regression stays green unnoticed.
|
||||
if (!zsh) {
|
||||
t.skip('zsh not installed')
|
||||
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
|
||||
// skip, not a pass, or a wrapper regression stays green unnoticed.
|
||||
if (!zsh) {
|
||||
t.skip('zsh not installed')
|
||||
|
||||
return
|
||||
return
|
||||
}
|
||||
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'hermes-zsh-probe-'))
|
||||
|
||||
try {
|
||||
const hermes = path.join(dir, 'hermes')
|
||||
await writeFile(hermes, '#!/bin/sh\necho "--ssh-session-token-file --ssh-owner-nonce"\n', { mode: 0o700 })
|
||||
|
||||
const ssh = { exec: async (command: string) => (await exec(command, { shell: zsh })).stdout }
|
||||
|
||||
assert.equal(await remoteSupportsSshOwnership(ssh, hermes), true)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'hermes-zsh-probe-'))
|
||||
|
||||
try {
|
||||
const hermes = path.join(dir, 'hermes')
|
||||
await writeFile(hermes, '#!/bin/sh\necho "--ssh-session-token-file --ssh-owner-nonce"\n', { mode: 0o700 })
|
||||
|
||||
const ssh = { exec: async (command: string) => (await exec(command, { shell: zsh })).stdout }
|
||||
|
||||
assert.equal(await remoteSupportsSshOwnership(ssh, hermes), true)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
test('probes run under the remote watchdog so a hung CLI cannot orphan (#110478)', async () => {
|
||||
let versionProbe = ''
|
||||
@@ -1949,7 +1945,10 @@ test('probes run under the remote watchdog so a hung CLI cannot orphan (#110478)
|
||||
|
||||
assert.equal(await remoteSupportsSshOwnership(helpSsh, '/x/hermes'), true)
|
||||
assert.ok(helpProbe.includes('kill -9'), 'ownership probe wrapped in the remote watchdog')
|
||||
assert.ok(/\$\(.*\(.*serve --help.*\) <\/dev\/null &/.test(helpProbe), 'watchdog nested around the inner serve --help')
|
||||
assert.ok(
|
||||
/\$\(.*\(.*serve --help.*\) <\/dev\/null &/.test(helpProbe),
|
||||
'watchdog nested around the inner serve --help'
|
||||
)
|
||||
})
|
||||
|
||||
test('cleanupStale escalates to SIGKILL when the backend survives the graceful wait (#91668 quit-during-active-turn)', async () => {
|
||||
@@ -2088,7 +2087,10 @@ test('connect() does not declare a live dashboard dead when the liveness probe a
|
||||
|
||||
assert.equal(result.reused, false)
|
||||
assert.equal(result.pid, 777)
|
||||
assert.ok(!ssh.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)), 'must not reap a live backend')
|
||||
assert.ok(
|
||||
!ssh.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)),
|
||||
'must not reap a live backend'
|
||||
)
|
||||
})
|
||||
|
||||
test('cleanupStale reaps after one lost ownership answer and keeps the lockfile when none ever settles', async () => {
|
||||
@@ -2100,7 +2102,10 @@ test('cleanupStale reaps after one lost ownership answer and keeps the lockfile
|
||||
])
|
||||
|
||||
await cleanupStale(flaky, OWNERSHIP_ID, ownedLock({ pid: 777 }))
|
||||
assert.ok(flaky.calls.some(c => /kill 777 &&/.test(c)), 'must reap the owned backend')
|
||||
assert.ok(
|
||||
flaky.calls.some(c => /kill 777 &&/.test(c)),
|
||||
'must reap the owned backend'
|
||||
)
|
||||
assert.ok(flaky.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)))
|
||||
|
||||
const silent = fakeSsh([[/print\("OWNED"/, '']])
|
||||
@@ -2110,8 +2115,14 @@ test('cleanupStale reaps after one lost ownership answer and keeps the lockfile
|
||||
(error: any) => error.kind === 'transient-transport-error'
|
||||
)
|
||||
|
||||
assert.ok(!silent.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)), 'must not kill unproven')
|
||||
assert.ok(!silent.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)), 'record must survive for the next connect to reap')
|
||||
assert.ok(
|
||||
!silent.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)),
|
||||
'must not kill unproven'
|
||||
)
|
||||
assert.ok(
|
||||
!silent.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)),
|
||||
'record must survive for the next connect to reap'
|
||||
)
|
||||
})
|
||||
|
||||
test('connect() post-spawn cleanup that cannot prove ownership keeps the original boot error', async () => {
|
||||
@@ -2132,9 +2143,19 @@ test('connect() post-spawn cleanup that cannot prove ownership keeps the origina
|
||||
])
|
||||
|
||||
await assert.rejects(
|
||||
connect(connectDeps(ssh, { platform: { os: 'Linux', arch: 'x86_64' }, waitForHermes: async () => { throw boot } })),
|
||||
connect(
|
||||
connectDeps(ssh, {
|
||||
platform: { os: 'Linux', arch: 'x86_64' },
|
||||
waitForHermes: async () => {
|
||||
throw boot
|
||||
}
|
||||
})
|
||||
),
|
||||
(error: any) => error === boot && error.cleanupCause?.kind === 'transient-transport-error'
|
||||
)
|
||||
|
||||
assert.ok(!ssh.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)), 'record must survive for the next connect to reap')
|
||||
assert.ok(
|
||||
!ssh.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)),
|
||||
'record must survive for the next connect to reap'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -408,7 +408,13 @@ async function readRemoteInstallId(ssh) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const id = String(out || '').trim().split('\n').pop()?.trim().toLowerCase() ?? ''
|
||||
const id =
|
||||
String(out || '')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.pop()
|
||||
?.trim()
|
||||
.toLowerCase() ?? ''
|
||||
|
||||
// Same shape check the minting side guarantees; anything else is not an identity.
|
||||
return /^[0-9a-f]{32}$/.test(id) ? id : undefined
|
||||
@@ -650,51 +656,51 @@ async function pidIsOurDashboard(
|
||||
}
|
||||
|
||||
const script =
|
||||
'import os,shlex,subprocess,sys\n' +
|
||||
`pid=${Number(pid)}\n` +
|
||||
`expected=os.path.expanduser(${shq(hermesPath)})\n` +
|
||||
// The installer-facing launcher is intentionally preserved for invocation
|
||||
// (#74411), but it may `exec python <install-dir>/hermes`, leaving neither
|
||||
// launcher nor HERMES_HOME-derived entrypoint in argv. The ownership-scoped
|
||||
// token path + random nonce + exact profile below are the alternative proof.
|
||||
`hermes_home=os.path.expanduser(${shq(hermesHome)}) if ${shq(hermesHome)} else ""\n` +
|
||||
'expected_entries={expected}\n' +
|
||||
'if hermes_home:\n' +
|
||||
' expected_entries.add(os.path.join(hermes_home,"hermes-agent","venv","bin","hermes"))\n' +
|
||||
`expected_token=os.path.expanduser(${shq(ownershipId ? spawnTokenPath(ownershipId, spawnNonce) : '')})\n` +
|
||||
`expected_profile=${shq(profile)}\n` +
|
||||
`nonce=${shq(spawnNonce)}\n` +
|
||||
'try:\n' +
|
||||
' raw=open(f"/proc/{pid}/cmdline","rb").read()\n' +
|
||||
' args=[x.decode("utf-8","surrogateescape") for x in raw.split(b"\\0") if x]\n' +
|
||||
'except OSError:\n' +
|
||||
' try:\n' +
|
||||
' line=subprocess.check_output(["ps","-ww","-o","command=","-p",str(pid)],text=True).strip()\n' +
|
||||
' except subprocess.CalledProcessError:\n' +
|
||||
' # pid already gone — a dead process is FOREIGN, not a transport error\n' +
|
||||
' print("FOREIGN");sys.exit(0)\n' +
|
||||
' args=shlex.split(line)\n' +
|
||||
'ok=False\n' +
|
||||
'try:\n' +
|
||||
' serve=args.index("serve")\n' +
|
||||
' owner=args.index("--ssh-owner-nonce",serve+1)\n' +
|
||||
' token=args.index("--ssh-session-token-file",serve+1) if expected_token else -1\n' +
|
||||
' isolated=args.index("--isolated",serve+1)\n' +
|
||||
' profile_arg=args.index("--profile") if expected_profile else -1\n' +
|
||||
' serve_count=args.count("serve")\n' +
|
||||
' owner_count=args.count("--ssh-owner-nonce")\n' +
|
||||
' token_count=args.count("--ssh-session-token-file")\n' +
|
||||
' isolated_count=args.count("--isolated")\n' +
|
||||
' profile_count=args.count("--profile")\n' +
|
||||
' direct=args[0] in expected_entries\n' +
|
||||
' python_entry=len(args)>1 and args[1] in expected_entries and os.path.basename(args[0]).startswith("python")\n' +
|
||||
' token_ok=not expected_token or args[token+1]==expected_token\n' +
|
||||
' isolated_ok=isolated_count==1 and isolated>serve\n' +
|
||||
' profile_ok=(profile_count==1 and profile_arg<serve and args[profile_arg+1]==expected_profile) if expected_profile else profile_count==0\n' +
|
||||
' spawn_proof=bool(expected_token) and owner_count==1 and token_count==1 and token_ok and profile_ok\n' +
|
||||
' ok=(direct or python_entry or spawn_proof) and serve_count==1 and isolated_ok and owner_count==1 and args[owner+1]==nonce and token_ok and profile_ok\n' +
|
||||
'except (ValueError,IndexError):pass\n' +
|
||||
'print("OWNED" if ok else "FOREIGN")'
|
||||
'import os,shlex,subprocess,sys\n' +
|
||||
`pid=${Number(pid)}\n` +
|
||||
`expected=os.path.expanduser(${shq(hermesPath)})\n` +
|
||||
// The installer-facing launcher is intentionally preserved for invocation
|
||||
// (#74411), but it may `exec python <install-dir>/hermes`, leaving neither
|
||||
// launcher nor HERMES_HOME-derived entrypoint in argv. The ownership-scoped
|
||||
// token path + random nonce + exact profile below are the alternative proof.
|
||||
`hermes_home=os.path.expanduser(${shq(hermesHome)}) if ${shq(hermesHome)} else ""\n` +
|
||||
'expected_entries={expected}\n' +
|
||||
'if hermes_home:\n' +
|
||||
' expected_entries.add(os.path.join(hermes_home,"hermes-agent","venv","bin","hermes"))\n' +
|
||||
`expected_token=os.path.expanduser(${shq(ownershipId ? spawnTokenPath(ownershipId, spawnNonce) : '')})\n` +
|
||||
`expected_profile=${shq(profile)}\n` +
|
||||
`nonce=${shq(spawnNonce)}\n` +
|
||||
'try:\n' +
|
||||
' raw=open(f"/proc/{pid}/cmdline","rb").read()\n' +
|
||||
' args=[x.decode("utf-8","surrogateescape") for x in raw.split(b"\\0") if x]\n' +
|
||||
'except OSError:\n' +
|
||||
' try:\n' +
|
||||
' line=subprocess.check_output(["ps","-ww","-o","command=","-p",str(pid)],text=True).strip()\n' +
|
||||
' except subprocess.CalledProcessError:\n' +
|
||||
' # pid already gone — a dead process is FOREIGN, not a transport error\n' +
|
||||
' print("FOREIGN");sys.exit(0)\n' +
|
||||
' args=shlex.split(line)\n' +
|
||||
'ok=False\n' +
|
||||
'try:\n' +
|
||||
' serve=args.index("serve")\n' +
|
||||
' owner=args.index("--ssh-owner-nonce",serve+1)\n' +
|
||||
' token=args.index("--ssh-session-token-file",serve+1) if expected_token else -1\n' +
|
||||
' isolated=args.index("--isolated",serve+1)\n' +
|
||||
' profile_arg=args.index("--profile") if expected_profile else -1\n' +
|
||||
' serve_count=args.count("serve")\n' +
|
||||
' owner_count=args.count("--ssh-owner-nonce")\n' +
|
||||
' token_count=args.count("--ssh-session-token-file")\n' +
|
||||
' isolated_count=args.count("--isolated")\n' +
|
||||
' profile_count=args.count("--profile")\n' +
|
||||
' direct=args[0] in expected_entries\n' +
|
||||
' python_entry=len(args)>1 and args[1] in expected_entries and os.path.basename(args[0]).startswith("python")\n' +
|
||||
' token_ok=not expected_token or args[token+1]==expected_token\n' +
|
||||
' isolated_ok=isolated_count==1 and isolated>serve\n' +
|
||||
' profile_ok=(profile_count==1 and profile_arg<serve and args[profile_arg+1]==expected_profile) if expected_profile else profile_count==0\n' +
|
||||
' spawn_proof=bool(expected_token) and owner_count==1 and token_count==1 and token_ok and profile_ok\n' +
|
||||
' ok=(direct or python_entry or spawn_proof) and serve_count==1 and isolated_ok and owner_count==1 and args[owner+1]==nonce and token_ok and profile_ok\n' +
|
||||
'except (ValueError,IndexError):pass\n' +
|
||||
'print("OWNED" if ok else "FOREIGN")'
|
||||
|
||||
const verdict = await execProbeVerdict(
|
||||
ssh,
|
||||
|
||||
@@ -32,9 +32,7 @@ export interface RemoteHeaderSource {
|
||||
|
||||
interface SessionLike {
|
||||
webRequest?: {
|
||||
onBeforeSendHeaders?: (
|
||||
listener: (details: RemoteRequestDetails, callback: RemoteRequestCallback) => void
|
||||
) => void
|
||||
onBeforeSendHeaders?: (listener: (details: RemoteRequestDetails, callback: RemoteRequestCallback) => void) => void
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,12 +87,16 @@ function manifestAssetRefs(
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(path.join(path.dirname(indexPath), 'renderer-manifest.json'), 'utf8'))
|
||||
|
||||
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {return null}
|
||||
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entry = manifest['index.html']
|
||||
const normalize = (ref: string) => ref.replace(/^\.?\//, '')
|
||||
|
||||
if (!entry?.isEntry || !bootRefs.map(normalize).includes(entry.file)) {return null}
|
||||
if (!entry?.isEntry || !bootRefs.map(normalize).includes(entry.file)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const refs = new Set<string>()
|
||||
|
||||
@@ -100,7 +104,9 @@ function manifestAssetRefs(
|
||||
typeof ref === 'string' && ref.length > 0 && !/^[a-z]+:|^[/\\]/i.test(ref) && !ref.split(/[/\\]/).includes('..')
|
||||
|
||||
for (const chunk of Object.values(manifest) as Record<string, unknown>[]) {
|
||||
if (!chunk || typeof chunk !== 'object' || !localRef(chunk.file)) {return null}
|
||||
if (!chunk || typeof chunk !== 'object' || !localRef(chunk.file)) {
|
||||
return null
|
||||
}
|
||||
|
||||
refs.add(chunk.file)
|
||||
|
||||
@@ -115,13 +121,19 @@ function manifestAssetRefs(
|
||||
for (const key of ['css', 'assets']) {
|
||||
const assets = chunk[key] ?? []
|
||||
|
||||
if (!Array.isArray(assets) || !assets.every(localRef)) {return null}
|
||||
if (!Array.isArray(assets) || !assets.every(localRef)) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const ref of assets) {refs.add(ref)}
|
||||
for (const ref of assets) {
|
||||
refs.add(ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bootRefs.every(ref => refs.has(normalize(ref)))) {return null}
|
||||
if (!bootRefs.every(ref => refs.has(normalize(ref)))) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [...refs]
|
||||
} catch {
|
||||
|
||||
@@ -22,7 +22,10 @@ describe('renderer heap flags', () => {
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
expect(cfg).toEqual({ electronFlags: ['--ozone-platform=x11', '--js-flags=--expose-gc'], rendererMaxOldSpaceMb: 2048 })
|
||||
expect(cfg).toEqual({
|
||||
electronFlags: ['--ozone-platform=x11', '--js-flags=--expose-gc'],
|
||||
rendererMaxOldSpaceMb: 2048
|
||||
})
|
||||
expect(planLaunchSwitches(cfg)).toEqual([
|
||||
{ name: 'ozone-platform', value: 'x11' },
|
||||
{ name: 'js-flags', value: '--expose-gc --max-old-space-size=2048' }
|
||||
@@ -32,7 +35,9 @@ describe('renderer heap flags', () => {
|
||||
})
|
||||
|
||||
it('merges with a js-flags switch already on argv instead of overwriting it', () => {
|
||||
const cfg = readDesktopLaunchConfig('desktop:\n electron_flags: [--disable-gpu]\n renderer_max_old_space_mb: 1536\n')
|
||||
const cfg = readDesktopLaunchConfig(
|
||||
'desktop:\n electron_flags: [--disable-gpu]\n renderer_max_old_space_mb: 1536\n'
|
||||
)
|
||||
const planned = planLaunchSwitches(cfg, ['/app/hermes', '--js-flags=--expose-gc', '--disable-gpu'])
|
||||
|
||||
// --disable-gpu is already on the launcher's argv: not re-applied.
|
||||
|
||||
@@ -42,12 +42,7 @@ export function readDesktopLaunchConfig(yamlText: string): DesktopLaunchConfig {
|
||||
|
||||
const blockLines: string[] = []
|
||||
|
||||
const splitFlow = (raw: string) =>
|
||||
raw
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map(unquote)
|
||||
.filter(Boolean)
|
||||
const splitFlow = (raw: string) => raw.slice(1, -1).split(',').map(unquote).filter(Boolean)
|
||||
|
||||
for (let i = start + 1; i < lines.length; i += 1) {
|
||||
const line = lines[i]
|
||||
@@ -138,7 +133,11 @@ export function planLaunchSwitches(cfg: DesktopLaunchConfig, argv: readonly stri
|
||||
const [, name, value] = match
|
||||
|
||||
if (name === 'js-flags') {
|
||||
jsFlagParts.push(...String(value ?? '').split(/\s+/).filter(Boolean))
|
||||
jsFlagParts.push(
|
||||
...String(value ?? '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,7 +95,13 @@ interface InstanceWindowUrlOptions extends Partial<DesktopWindowLaunch> {
|
||||
rendererIndexPath?: string
|
||||
}
|
||||
|
||||
function buildInstanceWindowUrl({ connectionId, devServer, profile, profileWindow, rendererIndexPath }: InstanceWindowUrlOptions = {}) {
|
||||
function buildInstanceWindowUrl({
|
||||
connectionId,
|
||||
devServer,
|
||||
profile,
|
||||
profileWindow,
|
||||
rendererIndexPath
|
||||
}: InstanceWindowUrlOptions = {}) {
|
||||
const query = `?peer=1${profile ? `&profile=${encodeURIComponent(profile)}&connectionId=${encodeURIComponent(connectionId ?? '')}${profileWindow ? '&profileWindow=1' : ''}` : ''}`
|
||||
|
||||
if (devServer) {
|
||||
|
||||
@@ -212,10 +212,13 @@ test('cancelAndWait keeps the drain up through afterCancel teardown', async () =
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const events: string[] = []
|
||||
let releaseAfter: (() => void) | undefined
|
||||
|
||||
const afterGate = new Promise<void>(resolve => {
|
||||
releaseAfter = resolve
|
||||
})
|
||||
|
||||
let teardownStarted: (() => void) | undefined
|
||||
|
||||
const started = new Promise<void>(resolve => {
|
||||
teardownStarted = resolve
|
||||
})
|
||||
@@ -228,6 +231,7 @@ test('cancelAndWait keeps the drain up through afterCancel teardown', async () =
|
||||
})
|
||||
|
||||
await started
|
||||
|
||||
const next = coordinator.start('scope', 'new', async () => {
|
||||
events.push('new-start')
|
||||
|
||||
@@ -289,6 +293,7 @@ test('a second cancelAndWait on the same scope composes with the teardown still
|
||||
|
||||
await teardownStarted.promise
|
||||
const apply = coordinator.cancelAndWait('scope').then(() => events.push('apply-drained'))
|
||||
|
||||
const next = coordinator.start('scope', 'new', async () => {
|
||||
events.push('new-start')
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ function createBootstrapCoordinator() {
|
||||
const own = new Promise<void>(resolve => {
|
||||
release = resolve
|
||||
})
|
||||
|
||||
// Compose with any drain already in flight for this scope (a pool stop
|
||||
// still tearing down SSH while a connection apply cancels the same scope):
|
||||
// start() must wait for every active teardown, and the map entry is
|
||||
@@ -126,6 +127,7 @@ function createBootstrapCoordinator() {
|
||||
// drain barrier still prevents stale resurrection.
|
||||
await Promise.allSettled(entries.flatMap(entry => [...entry.forceCleanups]).map(cleanup => cleanup()))
|
||||
await Promise.allSettled(entries.map(entry => entry.promise))
|
||||
|
||||
// Keep the drain up through caller teardown (SSH keepalive / tunnel)
|
||||
// so a replacement start() cannot publish before the old scope is gone.
|
||||
if (afterCancel) {
|
||||
|
||||
@@ -1074,24 +1074,27 @@ test('stopTunnelChild waits for process exit', async () => {
|
||||
assert.equal(stopped, true)
|
||||
})
|
||||
|
||||
test.skipIf(process.platform === 'win32')('withRemoteTimeout runs a healthy probe under a zsh login shell (#111949)', async t => {
|
||||
// SSH runs the remote command through the account's login shell. In
|
||||
// non-interactive zsh, a bare `set -m` is fatal, so the wrapper must still
|
||||
// run a healthy probe rather than reporting the remote as unsupported.
|
||||
const zsh = await execFileAsync('sh', ['-c', 'command -v zsh || true']).then(r => r.stdout.trim())
|
||||
test.skipIf(process.platform === 'win32')(
|
||||
'withRemoteTimeout runs a healthy probe under a zsh login shell (#111949)',
|
||||
async t => {
|
||||
// SSH runs the remote command through the account's login shell. In
|
||||
// non-interactive zsh, a bare `set -m` is fatal, so the wrapper must still
|
||||
// run a healthy probe rather than reporting the remote as unsupported.
|
||||
const zsh = await execFileAsync('sh', ['-c', 'command -v zsh || true']).then(r => r.stdout.trim())
|
||||
|
||||
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
|
||||
// skip, not a pass, or a wrapper regression stays green unnoticed.
|
||||
if (!zsh) {
|
||||
t.skip('zsh not installed')
|
||||
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
|
||||
// skip, not a pass, or a wrapper regression stays green unnoticed.
|
||||
if (!zsh) {
|
||||
t.skip('zsh not installed')
|
||||
|
||||
return
|
||||
return
|
||||
}
|
||||
|
||||
const { stdout: zshStdout } = await execFileAsync(zsh, ['-fc', withRemoteTimeout('echo zsh-ok', 5)])
|
||||
|
||||
assert.equal(zshStdout, 'zsh-ok\n')
|
||||
}
|
||||
|
||||
const { stdout: zshStdout } = await execFileAsync(zsh, ['-fc', withRemoteTimeout('echo zsh-ok', 5)])
|
||||
|
||||
assert.equal(zshStdout, 'zsh-ok\n')
|
||||
})
|
||||
)
|
||||
|
||||
test('withRemoteTimeout kills a hung probe remotely instead of orphaning it (#110478)', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
@@ -1158,7 +1161,10 @@ test('withRemoteTimeout kills a hung probe remotely instead of orphaning it (#11
|
||||
|
||||
assert.ok(err2 && err2.code !== 0, 'hung launcher must exit non-zero')
|
||||
|
||||
const { stdout: grandStrays } = await execFileAsync('sh', ['-c', `ps -eo args | grep "[s]leep ${grandSecs}$" || true`])
|
||||
const { stdout: grandStrays } = await execFileAsync('sh', [
|
||||
'-c',
|
||||
`ps -eo args | grep "[s]leep ${grandSecs}$" || true`
|
||||
])
|
||||
|
||||
assert.equal(grandStrays.trim(), '', 'watchdog killed the launcher’s grandchild too')
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('ssh-isolated keep-alive registry (#106935)', () => {
|
||||
it('holds an open WebSocket per scope until that scope stops, then never reconnects it', async () => {
|
||||
const { createSshIsolatedKeepaliveRegistry } = await import('./ssh-isolated-keepalive')
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
|
||||
const registry = createSshIsolatedKeepaliveRegistry({
|
||||
WebSocketImpl: FakeWs,
|
||||
reconnectDelayMs: 25
|
||||
|
||||
@@ -40,6 +40,7 @@ const MAX_RECONNECT_DELAY_MS = 30_000
|
||||
function addListener(socket: any, type: string, handler: (event?: any) => void) {
|
||||
if (typeof socket?.addEventListener === 'function') {
|
||||
socket.addEventListener(type, handler)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ function addListener(socket: any, type: string, handler: (event?: any) => void)
|
||||
export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepaliveOptions = {}) {
|
||||
const WebSocketImpl =
|
||||
'WebSocketImpl' in options ? options.WebSocketImpl : (globalThis as { WebSocket?: unknown }).WebSocket
|
||||
|
||||
const buildWsUrl = options.buildWsUrl ?? buildGatewayWsUrl
|
||||
const log = options.log
|
||||
const reconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS
|
||||
@@ -111,6 +113,7 @@ export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepalive
|
||||
} catch (error) {
|
||||
log?.(`[ssh] keep-alive WS failed to open for ${entry.scope}: ${error instanceof Error ? error.message : error}`)
|
||||
scheduleReconnect(entry)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -157,6 +160,7 @@ export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepalive
|
||||
socket: null,
|
||||
target: { baseUrl, token }
|
||||
}
|
||||
|
||||
entries.set(scope, entry)
|
||||
connect(entry)
|
||||
}
|
||||
@@ -185,6 +189,7 @@ export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepalive
|
||||
|
||||
function openUrl(scope: string) {
|
||||
const url = entries.get(scope)?.socket?.url
|
||||
|
||||
return typeof url === 'string' ? url : null
|
||||
}
|
||||
|
||||
|
||||
@@ -136,17 +136,26 @@ test('resolveBehindLocally: unreachable tip is unknown, reachable tip is ahead,
|
||||
// A tip missing from the object database (truly stale checkout) stays unknown.
|
||||
const stale = fakeGit({ 'cat-file': { code: 1 } })
|
||||
assert.equal(await resolveBehindLocally(stale.runGit, '/repo', SHA_A, SHA_B), null)
|
||||
assert.deepEqual(stale.calls.map(args => args[0]), ['cat-file'])
|
||||
assert.deepEqual(
|
||||
stale.calls.map(args => args[0]),
|
||||
['cat-file']
|
||||
)
|
||||
|
||||
// The remote tip reachable from HEAD is a local commit AHEAD, not an update.
|
||||
const ahead = fakeGit({})
|
||||
assert.equal(await resolveBehindLocally(ahead.runGit, '/repo', SHA_A, SHA_B), 0)
|
||||
assert.deepEqual(ahead.calls.map(args => args[0]), ['cat-file', 'merge-base'])
|
||||
assert.deepEqual(
|
||||
ahead.calls.map(args => args[0]),
|
||||
['cat-file', 'merge-base']
|
||||
)
|
||||
|
||||
// Otherwise the honest local count of HEAD..tip (merge-base must fail first).
|
||||
const behind = fakeGit({ 'merge-base': { code: 1 }, 'rev-list': { code: 0, stdout: '3\n' } })
|
||||
assert.equal(await resolveBehindLocally(behind.runGit, '/repo', SHA_A, SHA_B), 3)
|
||||
assert.deepEqual(behind.calls.map(args => args[0]), ['cat-file', 'merge-base', 'rev-list'])
|
||||
assert.deepEqual(
|
||||
behind.calls.map(args => args[0]),
|
||||
['cat-file', 'merge-base', 'rev-list']
|
||||
)
|
||||
|
||||
// A git failure mid-walk is never silently read as zero.
|
||||
const broken = fakeGit({ 'merge-base': { code: 1 }, 'rev-list': { code: 128 } })
|
||||
@@ -156,6 +165,7 @@ test('resolveBehindLocally: unreachable tip is unknown, reachable tip is ahead,
|
||||
test('listLocalCommits renders the local gap newest-first in the parseCompare shape', async () => {
|
||||
const OLDEST = '1'.repeat(40)
|
||||
const NEWEST = '2'.repeat(40)
|
||||
|
||||
const gitLog = fakeGit({
|
||||
log: {
|
||||
code: 0,
|
||||
|
||||
@@ -157,7 +157,8 @@ export function describeUpdateCheckFailure(error: UpdateCheckFailure | null | un
|
||||
if ((status === 403 || status === 429) && error?.rateLimitRemaining === 0) {
|
||||
const resetMs = typeof error.rateLimitReset === 'number' ? error.rateLimitReset * 1000 - now : NaN
|
||||
const minutes = Number.isFinite(resetMs) && resetMs > 0 ? Math.ceil(resetMs / 60_000) : null
|
||||
const when = minutes === null ? 'within an hour' : minutes === 1 ? 'in about a minute' : `in about ${minutes} minutes`
|
||||
const when =
|
||||
minutes === null ? 'within an hour' : minutes === 1 ? 'in about a minute' : `in about ${minutes} minutes`
|
||||
|
||||
return error.authenticated
|
||||
? `GitHub API rate limit reached for your GITHUB_TOKEN (HTTP ${status}) — it resets ${when}.`
|
||||
@@ -194,7 +195,10 @@ export function describeUpdateCheckFailure(error: UpdateCheckFailure | null | un
|
||||
}
|
||||
|
||||
/** A git command runner — main.ts injects its runGit; tests inject a fake. */
|
||||
export type GitRunner = (args: string[], options?: { cwd?: string }) => Promise<{
|
||||
export type GitRunner = (
|
||||
args: string[],
|
||||
options?: { cwd?: string }
|
||||
) => Promise<{
|
||||
code: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
|
||||
@@ -152,14 +152,22 @@ describe('resolveOutsideAsar', () => {
|
||||
// land on the unpacked copy electron-builder ships beside it.
|
||||
it('redirects a packaged specifier into app.asar.unpacked', () => {
|
||||
expect(
|
||||
resolveOutsideAsar('file:///Applications/Hermes.app/Contents/Resources/app.asar/dist/node_modules/get-windows/index.js')
|
||||
).toBe('file:///Applications/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/get-windows/index.js')
|
||||
resolveOutsideAsar(
|
||||
'file:///Applications/Hermes.app/Contents/Resources/app.asar/dist/node_modules/get-windows/index.js'
|
||||
)
|
||||
).toBe(
|
||||
'file:///Applications/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/get-windows/index.js'
|
||||
)
|
||||
})
|
||||
|
||||
// The staged specifier is built with path.join, so on Windows the archive
|
||||
// segment is delimited by backslashes, not the slashes a file: URL has.
|
||||
it('redirects a Windows packaged path built with backslashes', () => {
|
||||
expect(resolveOutsideAsar('C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar\\dist\\node_modules\\get-windows\\index.js')).toBe(
|
||||
expect(
|
||||
resolveOutsideAsar(
|
||||
'C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar\\dist\\node_modules\\get-windows\\index.js'
|
||||
)
|
||||
).toBe(
|
||||
'C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar.unpacked\\dist\\node_modules\\get-windows\\index.js'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -189,9 +189,8 @@ const loadGetWindows = (): Promise<GetWindowsModule | EnumerationFailure> => {
|
||||
// the CJS loader, which this ESM main process never does (every import
|
||||
// here is `from 'node:child_process'`). Pointing the import at the
|
||||
// unpacked copy gives get-windows a real path to derive from.
|
||||
const staged = resolveOutsideAsar(
|
||||
path.join(app.getAppPath(), 'dist', 'node_modules', 'get-windows', 'index.js')
|
||||
)
|
||||
const staged = resolveOutsideAsar(path.join(app.getAppPath(), 'dist', 'node_modules', 'get-windows', 'index.js'))
|
||||
|
||||
let stagedError = 'not staged in this build'
|
||||
|
||||
if (fs.existsSync(staged)) {
|
||||
|
||||
@@ -117,10 +117,7 @@ function computeWindowOptions(state, displays): WindowOptions {
|
||||
}
|
||||
|
||||
if (state && finite(state.x) && finite(state.y)) {
|
||||
const workArea = matchingWorkArea(
|
||||
{ x: state.x, y: state.y, width: opts.width, height: opts.height },
|
||||
displays
|
||||
)
|
||||
const workArea = matchingWorkArea({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays)
|
||||
|
||||
if (workArea) {
|
||||
opts.width = clamp(opts.width, MIN_WIDTH, workArea.width)
|
||||
|
||||
@@ -76,8 +76,7 @@ test('every emitted PowerShell script keeps try blocks attached to their catch/f
|
||||
// (MissingCatchOrFinally), so no probe may join a handler onto a separate
|
||||
// statement. The line-oriented builders join with `;`; the pair must live
|
||||
// in one array element.
|
||||
const decode = (command: string) =>
|
||||
Buffer.from(command.split(' ').at(-1) || '', 'base64').toString('utf16le')
|
||||
const decode = (command: string) => Buffer.from(command.split(' ').at(-1) || '', 'base64').toString('utf16le')
|
||||
|
||||
const scripts: string[] = []
|
||||
|
||||
@@ -102,6 +101,7 @@ test('every emitted PowerShell script keeps try blocks attached to their catch/f
|
||||
)
|
||||
|
||||
assert.equal(scripts.length, 4)
|
||||
|
||||
for (const script of scripts) {
|
||||
assert.doesNotMatch(script, /}\s*;\s*(?:catch|finally)\b/)
|
||||
// `$HOME`, `$HOST`, `$PID`, ... are read-only automatic variables: assigning
|
||||
@@ -109,7 +109,10 @@ test('every emitted PowerShell script keeps try blocks attached to their catch/f
|
||||
// and the marker gate never observes CLEAR.
|
||||
assert.doesNotMatch(script, /\$(?:home|host|pid|profile|pwd|input|args|error)\s*=/i)
|
||||
}
|
||||
assert.ok(scripts.slice(0, 2).every(script => /}catch \[Management\.Automation\.ItemNotFoundException\]/.test(script)))
|
||||
|
||||
assert.ok(
|
||||
scripts.slice(0, 2).every(script => /}catch \[Management\.Automation\.ItemNotFoundException\]/.test(script))
|
||||
)
|
||||
})
|
||||
|
||||
test('Windows relaunch gate refuses live and uncertain markers before executing the remote runtime', async () => {
|
||||
|
||||
@@ -14,13 +14,7 @@ import type {
|
||||
StatusResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import {
|
||||
capabilityScoped,
|
||||
hermesApi,
|
||||
type ProfileScope,
|
||||
profileScoped,
|
||||
STARTUP_REQUEST_TIMEOUT_MS
|
||||
} from './client'
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
||||
|
||||
export function getStatus(): Promise<StatusResponse> {
|
||||
return hermesApi<StatusResponse>({
|
||||
@@ -163,12 +157,14 @@ export function validateProviderCredential(
|
||||
value: string,
|
||||
apiKey?: string
|
||||
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[]; resolved_base_url?: string }> {
|
||||
return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[]; resolved_base_url?: string }>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/validate',
|
||||
method: 'POST',
|
||||
body: { key, value, api_key: apiKey ?? '' }
|
||||
})
|
||||
return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[]; resolved_base_url?: string }>(
|
||||
{
|
||||
...profileScoped(),
|
||||
path: '/api/providers/validate',
|
||||
method: 'POST',
|
||||
body: { key, value, api_key: apiKey ?? '' }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function getCustomEndpoints(profile?: null | string): Promise<CustomEndpointsResponse> {
|
||||
|
||||
@@ -9,13 +9,7 @@ import type {
|
||||
ModelInfoResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import {
|
||||
capabilityScoped,
|
||||
hermesApi,
|
||||
type ProfileScope,
|
||||
profileScoped,
|
||||
STARTUP_REQUEST_TIMEOUT_MS
|
||||
} from './client'
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
||||
|
||||
export function getGlobalModelInfo(profile?: null | string): Promise<ModelInfoResponse> {
|
||||
return hermesApi<ModelInfoResponse>({
|
||||
|
||||
@@ -11,8 +11,14 @@ vi.mock('./client', () => ({
|
||||
|
||||
const client = await import('./client')
|
||||
|
||||
const { deleteSession, getSession, setSessionArchived, setSessionPinnedRemote, setSessionUnreadRemote, listSidebarSessions } =
|
||||
await import('./sessions')
|
||||
const {
|
||||
deleteSession,
|
||||
getSession,
|
||||
setSessionArchived,
|
||||
setSessionPinnedRemote,
|
||||
setSessionUnreadRemote,
|
||||
listSidebarSessions
|
||||
} = await import('./sessions')
|
||||
|
||||
const hermesApi = vi.mocked(client.hermesApi)
|
||||
|
||||
|
||||
@@ -122,19 +122,19 @@ export function CapabilitiesView({
|
||||
const pending = gated && !(skills && toolsets)
|
||||
|
||||
const loadGate = !pending ? null : skillsFailed || toolsetsFailed ? (
|
||||
<PanelEmpty
|
||||
action={
|
||||
<Button onClick={() => void refreshCapabilities()} size="sm">
|
||||
{t.skills.refresh}
|
||||
</Button>
|
||||
}
|
||||
description={skillsError instanceof Error ? skillsError.message : undefined}
|
||||
icon="error"
|
||||
title={t.skills.skillsLoadFailed}
|
||||
/>
|
||||
) : (
|
||||
<PageLoader label={t.skills.loading} />
|
||||
)
|
||||
<PanelEmpty
|
||||
action={
|
||||
<Button onClick={() => void refreshCapabilities()} size="sm">
|
||||
{t.skills.refresh}
|
||||
</Button>
|
||||
}
|
||||
description={skillsError instanceof Error ? skillsError.message : undefined}
|
||||
icon="error"
|
||||
title={t.skills.skillsLoadFailed}
|
||||
/>
|
||||
) : (
|
||||
<PageLoader label={t.skills.loading} />
|
||||
)
|
||||
|
||||
// One entry per tab. Each is keyed on the scope so switching profile or
|
||||
// connection is a fresh tab — never one profile's selection, open editor or
|
||||
@@ -200,7 +200,11 @@ export function CapabilitiesView({
|
||||
{loadGate ?? tabContent[mode]()}
|
||||
</div>
|
||||
{hubMounted && (
|
||||
<EmbeddedHubPicker hidden={mode !== 'skills'} installedNames={installedSkillNames} profile={scope.profile} />
|
||||
<EmbeddedHubPicker
|
||||
hidden={mode !== 'skills'}
|
||||
installedNames={installedSkillNames}
|
||||
profile={scope.profile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,8 +197,15 @@ export function CapabilityScopeSelector({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-w-0 items-center gap-2', compact ? 'flex-1' : 'border-b border-(--ui-stroke-secondary) px-3 py-2')}>
|
||||
{!compact && <span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{t.skills.configuringProfile}</span>}
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2',
|
||||
compact ? 'flex-1' : 'border-b border-(--ui-stroke-secondary) px-3 py-2'
|
||||
)}
|
||||
>
|
||||
{!compact && (
|
||||
<span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{t.skills.configuringProfile}</span>
|
||||
)}
|
||||
<Select onValueChange={scope.onChange} value={scope.value}>
|
||||
<SelectTrigger className={cn('text-xs', compact ? 'h-6 w-full max-w-64 px-2' : 'h-7 w-56')}>
|
||||
<SelectValue />
|
||||
|
||||
@@ -7,7 +7,13 @@ import { useI18n } from '@/i18n'
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hubActions, installHubSkill, notifyHubActionFailed, UPDATE_ALL_KEY, updateHubSkills } from '@/store/hub-actions'
|
||||
import {
|
||||
$hubActions,
|
||||
installHubSkill,
|
||||
notifyHubActionFailed,
|
||||
UPDATE_ALL_KEY,
|
||||
updateHubSkills
|
||||
} from '@/store/hub-actions'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
|
||||
|
||||
|
||||
@@ -7,7 +7,14 @@ import { ArchiveSkillConfirmDialog } from '@/app/learning/archive-skill-confirm-
|
||||
import { CodeEditor } from '@/components/chat/code-editor'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { editLearningNode, getLearningNode, getOfficialSkills, type ProfileScope, profileScopeKey, setSkillEnabled } from '@/hermes'
|
||||
import {
|
||||
editLearningNode,
|
||||
getLearningNode,
|
||||
getOfficialSkills,
|
||||
type ProfileScope,
|
||||
profileScopeKey,
|
||||
setSkillEnabled
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
@@ -109,7 +116,9 @@ export function SkillsTab({ onRefresh, profile, query, skills }: SkillsTabProps)
|
||||
const installedSkillNames = useMemo(() => new Set(skills.map(s => s.name)), [skills])
|
||||
|
||||
const visibleOfficial = useMemo(() => {
|
||||
const catalog = (officialData?.skills ?? []).filter(skill => !skill.installed && !installedSkillNames.has(skill.name))
|
||||
const catalog = (officialData?.skills ?? []).filter(
|
||||
skill => !skill.installed && !installedSkillNames.has(skill.name)
|
||||
)
|
||||
|
||||
return filteredOfficial(catalog, query)
|
||||
}, [installedSkillNames, officialData, query])
|
||||
@@ -195,12 +204,20 @@ export function SkillsTab({ onRefresh, profile, query, skills }: SkillsTabProps)
|
||||
checked: allEnabled,
|
||||
disabled: bulkBusy,
|
||||
label: t.skills.all,
|
||||
onToggle: checked => void bulkApply(skills.filter(row => row.enabled !== checked), checked)
|
||||
onToggle: checked =>
|
||||
void bulkApply(
|
||||
skills.filter(row => row.enabled !== checked),
|
||||
checked
|
||||
)
|
||||
}
|
||||
|
||||
// "Never used" = zero recorded activity. The pruning move for a 100+ skill
|
||||
// install: keep the workhorses, shed the noise.
|
||||
const disableUnused = () => bulkApply(skills.filter(skill => skill.enabled && usageOf(skill) === 0), false)
|
||||
const disableUnused = () =>
|
||||
bulkApply(
|
||||
skills.filter(skill => skill.enabled && usageOf(skill) === 0),
|
||||
false
|
||||
)
|
||||
|
||||
const openSkillEditor = async (name: string) => {
|
||||
try {
|
||||
@@ -312,12 +329,7 @@ export function SkillsTab({ onRefresh, profile, query, skills }: SkillsTabProps)
|
||||
return (
|
||||
<CapRow
|
||||
action={
|
||||
<Button
|
||||
disabled={installing}
|
||||
onClick={() => handleInstallOfficial(skill)}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
<Button disabled={installing} onClick={() => handleInstallOfficial(skill)} size="xs" variant="text">
|
||||
{installing && <Loader2 className="size-3 animate-spin" />}
|
||||
{installing ? t.skills.hub.installing : t.skills.hub.install}
|
||||
</Button>
|
||||
|
||||
@@ -132,7 +132,11 @@ export function ToolsetsTab({ profile, query, toolsets }: ToolsetsTabProps) {
|
||||
checked: bulkToolsets.length > 0 && bulkToolsets.every(ts => ts.enabled),
|
||||
disabled: bulkBusy,
|
||||
label: t.skills.all,
|
||||
onToggle: checked => void bulkApply(bulkToolsets.filter(row => row.enabled !== checked), checked)
|
||||
onToggle: checked =>
|
||||
void bulkApply(
|
||||
bulkToolsets.filter(row => row.enabled !== checked),
|
||||
checked
|
||||
)
|
||||
}
|
||||
|
||||
if (visibleToolsets.length === 0) {
|
||||
|
||||
@@ -55,8 +55,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
|
||||
|
||||
const detail =
|
||||
attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||
|
||||
// Keep full image bytes out of composer state. New chips read their path only
|
||||
// when clicked; previewUrl remains a compatibility fallback for older drafts.
|
||||
|
||||
@@ -67,7 +67,7 @@ function selectionOutsideComposer(): boolean {
|
||||
}
|
||||
|
||||
const { anchorNode } = selection
|
||||
const anchorEl = anchorNode instanceof Element ? anchorNode : anchorNode?.parentElement ?? null
|
||||
const anchorEl = anchorNode instanceof Element ? anchorNode : (anchorNode?.parentElement ?? null)
|
||||
|
||||
return !anchorEl?.closest('[data-slot="composer-rich-input"]')
|
||||
}
|
||||
|
||||
@@ -120,7 +120,10 @@ describe('useComposerDraft — attachment scope stays coherent with the committe
|
||||
})
|
||||
|
||||
expect(mainComposerScope.$attachments.get()).toEqual([preSessionAttachment])
|
||||
expect(takeSessionDraft('session-created')).toEqual({ attachments: [preSessionAttachment], text: 'do not lose this draft' })
|
||||
expect(takeSessionDraft('session-created')).toEqual({
|
||||
attachments: [preSessionAttachment],
|
||||
text: 'do not lose this draft'
|
||||
})
|
||||
expect(takeSessionDraft(null)).toEqual({ attachments: [], text: '' })
|
||||
clearSessionDraft('session-created')
|
||||
})
|
||||
@@ -150,7 +153,9 @@ describe('useComposerDraft — attachment scope stays coherent with the committe
|
||||
// and does not re-publish the notice the user already dismissed.
|
||||
dismissRestoredDraftNotice()
|
||||
act(() => {
|
||||
rerender(<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={() => undefined} sessionId="session-A" />)
|
||||
rerender(
|
||||
<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={() => undefined} sessionId="session-A" />
|
||||
)
|
||||
})
|
||||
act(() => {
|
||||
rerender(<ProbeHarness activeQueueSessionKey={null} onLayoutSnapshot={() => undefined} sessionId="" />)
|
||||
@@ -169,7 +174,9 @@ describe('useComposerDraft — attachment scope stays coherent with the committe
|
||||
)
|
||||
|
||||
act(() => {
|
||||
rerender(<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={() => undefined} sessionId="session-A" />)
|
||||
rerender(
|
||||
<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={() => undefined} sessionId="session-A" />
|
||||
)
|
||||
})
|
||||
|
||||
expect(takeSessionDraft('session-A')).toEqual({ attachments: [], text: '' })
|
||||
@@ -559,7 +566,9 @@ describe('useComposerDraft — a hidden keep-alive tab never auto-focuses its co
|
||||
act(() => {
|
||||
lateRestore('rejected draft', [])
|
||||
|
||||
if (hidden) {lateFocus()}
|
||||
if (hidden) {
|
||||
lateFocus()
|
||||
}
|
||||
})
|
||||
|
||||
expect(composerPlainText(draft.editorRef.current!)).toBe('rejected draft')
|
||||
|
||||
@@ -17,27 +17,41 @@ const onAttach = vi.fn(async (_blob: Blob, isCurrent?: () => boolean) => isCurre
|
||||
|
||||
function mount(target: string, surfaceId: string, key = 'draft-a') {
|
||||
const scope = { ...MAIN_COMPOSER_SCOPE, target, attachments: createComposerAttachmentScope() }
|
||||
|
||||
const Wrapper = ({ children }: PropsWithChildren) => (
|
||||
<I18nProvider configClient={null}>
|
||||
<ComposerScopeProvider value={scope}>
|
||||
<ComposerSurfaceProvider value={surfaceId}>
|
||||
<div data-composer-surface-id={surfaceId} data-composer-target={target}>{children}</div>
|
||||
<div data-composer-surface-id={surfaceId} data-composer-target={target}>
|
||||
{children}
|
||||
</div>
|
||||
</ComposerSurfaceProvider>
|
||||
</ComposerScopeProvider>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
return renderHook(({ sessionKey }) => useComposerScreenshot({ sessionKey, onAttachImageBlob: onAttach }), {
|
||||
initialProps: { sessionKey: key }, wrapper: Wrapper
|
||||
initialProps: { sessionKey: key },
|
||||
wrapper: Wrapper
|
||||
})
|
||||
}
|
||||
|
||||
function bridge() {
|
||||
window.hermesDesktop = { ...window.hermesDesktop, screenshot: {
|
||||
getSettings: vi.fn(), setEnabled: vi.fn(), openPermissionSettings: vi.fn(), onStatus: () => () => undefined,
|
||||
capture,
|
||||
onRequest: callback => { listeners.add(callback); return () => listeners.delete(callback) }
|
||||
} as ScreenshotApi }
|
||||
window.hermesDesktop = {
|
||||
...window.hermesDesktop,
|
||||
screenshot: {
|
||||
getSettings: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
openPermissionSettings: vi.fn(),
|
||||
onStatus: () => () => undefined,
|
||||
capture,
|
||||
onRequest: callback => {
|
||||
listeners.add(callback)
|
||||
|
||||
return () => listeners.delete(callback)
|
||||
}
|
||||
} as ScreenshotApi
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -65,7 +79,12 @@ describe('screenshot composer routing', () => {
|
||||
it('rejects a late screenshot after a draft round trip and invalidates the image-write continuation', async () => {
|
||||
bridge()
|
||||
let finish!: (value: Awaited<ReturnType<ScreenshotApi['capture']>>) => void
|
||||
capture.mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
capture.mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const hook = mount('main', 'primary')
|
||||
act(() => listeners.forEach(listener => listener('first')))
|
||||
hook.rerender({ sessionKey: 'draft-b' })
|
||||
@@ -75,7 +94,11 @@ describe('screenshot composer routing', () => {
|
||||
|
||||
capture.mockResolvedValue({ ok: true, png: new Uint8Array([1]) })
|
||||
let isCurrent!: () => boolean
|
||||
onAttach.mockImplementation(async (_blob, guard) => { isCurrent = guard!; return true })
|
||||
onAttach.mockImplementation(async (_blob, guard) => {
|
||||
isCurrent = guard!
|
||||
|
||||
return true
|
||||
})
|
||||
act(() => listeners.forEach(listener => listener('second')))
|
||||
await waitFor(() => expect(onAttach).toHaveBeenCalledOnce())
|
||||
expect(isCurrent()).toBe(true)
|
||||
|
||||
@@ -26,6 +26,7 @@ export function useComposerScreenshot({ sessionKey, focusKey, onAttachImageBlob
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const api = window.hermesDesktop?.screenshot
|
||||
|
||||
if (!api || !surfaceId || !visible) {
|
||||
return
|
||||
}
|
||||
@@ -33,19 +34,24 @@ export function useComposerScreenshot({ sessionKey, focusKey, onAttachImageBlob
|
||||
let generation = 0
|
||||
let mounted = true
|
||||
let busy = false
|
||||
|
||||
const offRoute = $activeGatewayRoute.listen(() => {
|
||||
generation += 1
|
||||
})
|
||||
|
||||
const offStatus = api.onStatus(status => {
|
||||
if (!status.enabled) {
|
||||
generation += 1
|
||||
}
|
||||
})
|
||||
|
||||
const offRequest = api.onRequest(requestId => {
|
||||
if (busy || getActiveComposer() !== scope.target || getVisibleComposerSurfaceId(scope.target) !== surfaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const attach = latest.current.onAttachImageBlob
|
||||
|
||||
if (!attach) {
|
||||
return
|
||||
}
|
||||
@@ -58,13 +64,16 @@ export function useComposerScreenshot({ sessionKey, focusKey, onAttachImageBlob
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await api.capture(requestId)
|
||||
|
||||
if (!isCurrent()) {
|
||||
report(latest.current.copy.contextChanged)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
report(latest.current.copy.captureFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,6 +81,7 @@ export function useComposerScreenshot({ sessionKey, focusKey, onAttachImageBlob
|
||||
// native bytes, before adding a chip, so a session swap cannot leak it.
|
||||
const blob = new Blob([new Uint8Array(result.png)], { type: 'image/png' })
|
||||
await attach(blob, isCurrent)
|
||||
|
||||
if (!isCurrent()) {
|
||||
report(latest.current.copy.contextChanged)
|
||||
}
|
||||
|
||||
@@ -79,9 +79,7 @@ export function useComposerVoice({
|
||||
|
||||
// Runs on every streamed flush: test the parts in place instead of
|
||||
// joining the whole reply into a string just to check it is non-blank.
|
||||
return last?.pending && last.parts.some(part => part.type === 'text' && /\S/.test(part.text))
|
||||
? last.id
|
||||
: null
|
||||
return last?.pending && last.parts.some(part => part.type === 'text' && /\S/.test(part.text)) ? last.id : null
|
||||
}),
|
||||
[$messages]
|
||||
)
|
||||
|
||||
@@ -13,7 +13,9 @@ import { useVoiceLiveConversation } from './use-voice-live-conversation'
|
||||
// — the same owner route the TTS legs already trust (#117014) — so the voice
|
||||
// configured on the Bot's profile is the voice that answers (#117401).
|
||||
|
||||
const constructed = vi.hoisted(() => ({ owners: [] as Array<null | { connectionId?: null | string; profile?: null | string }> }))
|
||||
const constructed = vi.hoisted(() => ({
|
||||
owners: [] as Array<null | { connectionId?: null | string; profile?: null | string }>
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/voice-live', async importOriginal => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
@@ -23,7 +25,10 @@ vi.mock('@/lib/voice-live', async importOriginal => {
|
||||
VoiceLiveSession: class {
|
||||
close = vi.fn()
|
||||
|
||||
constructor(_handlers: VoiceLiveHandlers, owner: null | { connectionId?: null | string; profile?: null | string } = null) {
|
||||
constructor(
|
||||
_handlers: VoiceLiveHandlers,
|
||||
owner: null | { connectionId?: null | string; profile?: null | string } = null
|
||||
) {
|
||||
constructed.owners.push(owner)
|
||||
}
|
||||
|
||||
|
||||
@@ -246,95 +246,98 @@ export function useVoiceLiveConversation({
|
||||
return
|
||||
}
|
||||
|
||||
const session = new VoiceLiveSession({
|
||||
// The voice model answers a bare "stop" itself (it just goes quiet) and
|
||||
// never delegates it, so the spoken stop phrase is judged on the user
|
||||
// transcript once the utterance settles.
|
||||
onTranscript: fragment => {
|
||||
if (fragment.speaker !== 'user') {
|
||||
return
|
||||
}
|
||||
const session = new VoiceLiveSession(
|
||||
{
|
||||
// The voice model answers a bare "stop" itself (it just goes quiet) and
|
||||
// never delegates it, so the spoken stop phrase is judged on the user
|
||||
// transcript once the utterance settles.
|
||||
onTranscript: fragment => {
|
||||
if (fragment.speaker !== 'user') {
|
||||
return
|
||||
}
|
||||
|
||||
userUtteranceRef.current += fragment.text
|
||||
userUtteranceRef.current += fragment.text
|
||||
|
||||
if (utteranceTimerRef.current) {
|
||||
window.clearTimeout(utteranceTimerRef.current)
|
||||
}
|
||||
if (utteranceTimerRef.current) {
|
||||
window.clearTimeout(utteranceTimerRef.current)
|
||||
}
|
||||
|
||||
utteranceTimerRef.current = window.setTimeout(() => {
|
||||
utteranceTimerRef.current = null
|
||||
const utterance = userUtteranceRef.current
|
||||
userUtteranceRef.current = ''
|
||||
utteranceTimerRef.current = window.setTimeout(() => {
|
||||
utteranceTimerRef.current = null
|
||||
const utterance = userUtteranceRef.current
|
||||
userUtteranceRef.current = ''
|
||||
|
||||
if (sessionRef.current === session && isVoiceStopCommand(utterance)) {
|
||||
if (sessionRef.current === session && isVoiceStopCommand(utterance)) {
|
||||
void end()
|
||||
latest.current.onStopWord?.()
|
||||
}
|
||||
}, UTTERANCE_SETTLE_MS)
|
||||
},
|
||||
onClosed: (reason, usageSeconds) => {
|
||||
if (sessionRef.current !== session) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionRef.current = null
|
||||
setDelegation(null)
|
||||
setStatus('idle')
|
||||
|
||||
if (reason !== 'close_requested') {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
message: liveEndedMessage(reason, usageSeconds, voiceCopy),
|
||||
title: voiceCopy.liveEnded
|
||||
})
|
||||
latest.current.onFatalError?.()
|
||||
}
|
||||
},
|
||||
onDelegation: (delegationId, context) => {
|
||||
if (sessionRef.current !== session) {
|
||||
return
|
||||
}
|
||||
|
||||
const { context: voiceContext, prompt } = delegationPrompt(context)
|
||||
|
||||
// A spoken stop command ends the conversation instead of becoming a turn.
|
||||
if (prompt && isVoiceStopCommand(prompt)) {
|
||||
void end()
|
||||
latest.current.onStopWord?.()
|
||||
|
||||
return
|
||||
}
|
||||
}, UTTERANCE_SETTLE_MS)
|
||||
},
|
||||
onClosed: (reason, usageSeconds) => {
|
||||
if (sessionRef.current !== session) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionRef.current = null
|
||||
setDelegation(null)
|
||||
setStatus('idle')
|
||||
// A newer request supersedes an in-flight turn: stop it so the answer
|
||||
// the voice speaks is for what the user asked last.
|
||||
if (busyRef.current) {
|
||||
void latest.current.onInterrupt?.()
|
||||
}
|
||||
|
||||
if (reason !== 'close_requested') {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
message: liveEndedMessage(reason, usageSeconds, voiceCopy),
|
||||
title: voiceCopy.liveEnded
|
||||
})
|
||||
latest.current.onFatalError?.()
|
||||
}
|
||||
},
|
||||
onDelegation: (delegationId, context) => {
|
||||
if (sessionRef.current !== session) {
|
||||
return
|
||||
}
|
||||
|
||||
const { context: voiceContext, prompt } = delegationPrompt(context)
|
||||
|
||||
// A spoken stop command ends the conversation instead of becoming a turn.
|
||||
if (prompt && isVoiceStopCommand(prompt)) {
|
||||
void end()
|
||||
latest.current.onStopWord?.()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// A newer request supersedes an in-flight turn: stop it so the answer
|
||||
// the voice speaks is for what the user asked last.
|
||||
if (busyRef.current) {
|
||||
void latest.current.onInterrupt?.()
|
||||
}
|
||||
|
||||
setDelegation(delegationId)
|
||||
spokenResponseIdRef.current = null
|
||||
spokenLengthRef.current = 0
|
||||
lastToolLabelRef.current = null
|
||||
turnObservedRef.current = false
|
||||
submittedAtRef.current = Date.now()
|
||||
latest.current.consumePendingResponse()
|
||||
refreshStatus()
|
||||
void Promise.resolve(latest.current.onSubmit(prompt, voiceContext)).catch(error => {
|
||||
notifyError(error, voiceCopy.liveDelegationFailed)
|
||||
session.speak(delegationId, 'Sorry, I could not reach Hermes for that request.')
|
||||
setDelegation(null)
|
||||
setDelegation(delegationId)
|
||||
spokenResponseIdRef.current = null
|
||||
spokenLengthRef.current = 0
|
||||
lastToolLabelRef.current = null
|
||||
turnObservedRef.current = false
|
||||
submittedAtRef.current = Date.now()
|
||||
latest.current.consumePendingResponse()
|
||||
refreshStatus()
|
||||
})
|
||||
void Promise.resolve(latest.current.onSubmit(prompt, voiceContext)).catch(error => {
|
||||
notifyError(error, voiceCopy.liveDelegationFailed)
|
||||
session.speak(delegationId, 'Sorry, I could not reach Hermes for that request.')
|
||||
setDelegation(null)
|
||||
refreshStatus()
|
||||
})
|
||||
},
|
||||
onError: (message, fatal) => {
|
||||
notify({ kind: fatal ? 'error' : 'warning', message, title: voiceCopy.liveError })
|
||||
},
|
||||
onSpeakingChange: speaking => {
|
||||
speakingRef.current = speaking
|
||||
setLevel(speaking ? 0.6 : 0)
|
||||
refreshStatus()
|
||||
}
|
||||
},
|
||||
onError: (message, fatal) => {
|
||||
notify({ kind: fatal ? 'error' : 'warning', message, title: voiceCopy.liveError })
|
||||
},
|
||||
onSpeakingChange: speaking => {
|
||||
speakingRef.current = speaking
|
||||
setLevel(speaking ? 0.6 : 0)
|
||||
refreshStatus()
|
||||
}
|
||||
}, ownerRef.current)
|
||||
ownerRef.current
|
||||
)
|
||||
|
||||
sessionRef.current = session
|
||||
startingRef.current = false
|
||||
|
||||
@@ -1414,10 +1414,7 @@ export function ChatBar({
|
||||
data-slot="composer-surface"
|
||||
ref={composerSurfaceRef}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className={composerInputBacking}
|
||||
/>
|
||||
<div aria-hidden className={composerInputBacking} />
|
||||
{!guidedChat && (
|
||||
<CodingStatusRow
|
||||
onBranchOff={handleBranchOff}
|
||||
@@ -1540,10 +1537,7 @@ export function ChatBarFallback() {
|
||||
data-slot="composer-root"
|
||||
>
|
||||
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]">
|
||||
<div
|
||||
aria-hidden
|
||||
className={composerInputBacking}
|
||||
/>
|
||||
<div aria-hidden className={composerInputBacking} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -77,7 +77,8 @@ export function QueuePanel({
|
||||
return (
|
||||
<StatusRow
|
||||
className={cn(
|
||||
isEditing && 'ring-1 ring-inset ring-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
isEditing &&
|
||||
'ring-1 ring-inset ring-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
dismiss={{ label: c.queueDelete, onDismiss: () => onDelete(entry.id) }}
|
||||
key={entry.id}
|
||||
|
||||
@@ -339,9 +339,7 @@ export const SessionControlGoalSection = memo(function SessionControlGoalSection
|
||||
: ctrl.waitBarrierTitle}
|
||||
</StatusControlRow>
|
||||
)}
|
||||
{!goal.wait_barrier && goal.paused_reason && (
|
||||
<StatusControlRow>{goal.paused_reason}</StatusControlRow>
|
||||
)}
|
||||
{!goal.wait_barrier && goal.paused_reason && <StatusControlRow>{goal.paused_reason}</StatusControlRow>}
|
||||
{!goal.wait_barrier && !goal.paused_reason && goal.last_reason && (
|
||||
<StatusControlRow>{goal.last_reason}</StatusControlRow>
|
||||
)}
|
||||
@@ -364,7 +362,11 @@ export const SessionControlGoalSection = memo(function SessionControlGoalSection
|
||||
<StatusRow
|
||||
className="text-[0.68rem] font-medium text-muted-foreground/75"
|
||||
leading={
|
||||
<span aria-hidden="true" className="inline-flex text-muted-foreground/70" data-slot="criteria-state-marker">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex text-muted-foreground/70"
|
||||
data-slot="criteria-state-marker"
|
||||
>
|
||||
<Codicon name="checklist" size="0.8rem" />
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -190,9 +190,7 @@ export const SessionControlHeartbeatSection = memo(function SessionControlHeartb
|
||||
<StatusControlRow className="text-[0.73rem] leading-4 text-foreground/92 break-words" icon="bell">
|
||||
{heartbeat.prompt}
|
||||
</StatusControlRow>
|
||||
<StatusControlRow icon="history">
|
||||
{ctrl.heartbeatFiredCount(heartbeat.fire_count)}
|
||||
</StatusControlRow>
|
||||
<StatusControlRow icon="history">{ctrl.heartbeatFiredCount(heartbeat.fire_count)}</StatusControlRow>
|
||||
</div>
|
||||
</StatusSection>
|
||||
</div>
|
||||
|
||||
@@ -210,18 +210,10 @@ export const SessionControlLoopSection = memo(function SessionControlLoopSection
|
||||
</span>
|
||||
</StatusControlRow>
|
||||
)}
|
||||
{loop.deferred_by_goal && (
|
||||
<StatusControlRow>{ctrl.loopDeferredNotice}</StatusControlRow>
|
||||
)}
|
||||
{loop.awaiting_response && (
|
||||
<StatusControlRow>{ctrl.loopAwaitingResponse}</StatusControlRow>
|
||||
)}
|
||||
{loop.paused_reason && (
|
||||
<StatusControlRow>{loop.paused_reason}</StatusControlRow>
|
||||
)}
|
||||
{loop.last_stop_reason && (
|
||||
<StatusControlRow>{loop.last_stop_reason}</StatusControlRow>
|
||||
)}
|
||||
{loop.deferred_by_goal && <StatusControlRow>{ctrl.loopDeferredNotice}</StatusControlRow>}
|
||||
{loop.awaiting_response && <StatusControlRow>{ctrl.loopAwaitingResponse}</StatusControlRow>}
|
||||
{loop.paused_reason && <StatusControlRow>{loop.paused_reason}</StatusControlRow>}
|
||||
{loop.last_stop_reason && <StatusControlRow>{loop.last_stop_reason}</StatusControlRow>}
|
||||
</div>
|
||||
</StatusSection>
|
||||
</div>
|
||||
|
||||
@@ -200,12 +200,7 @@ export function ComposerTriggerPopover({
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
{showHeader && <div className={cn(GROUP_HEADER_CLASS, isFirstHeader ? 'pt-0.5' : 'pt-2')}>{group}</div>}
|
||||
<Tip
|
||||
delayDuration={400}
|
||||
label={kind === '/' ? description : undefined}
|
||||
placement="row"
|
||||
sideOffset={4}
|
||||
>
|
||||
<Tip delayDuration={400} label={kind === '/' ? description : undefined} placement="row" sideOffset={4}>
|
||||
<button
|
||||
className={ROW_CLASS}
|
||||
data-highlighted={active ? '' : undefined}
|
||||
|
||||
@@ -15,14 +15,20 @@ import { ChatRuntimeBoundary } from '.'
|
||||
stubThreadEnvironment()
|
||||
|
||||
const message = (rowId: number): ChatMessage => ({
|
||||
id: `live-${rowId}`, rowId, role: 'user', parts: [{ type: 'text', text: `prompt ${rowId}` }]
|
||||
id: `live-${rowId}`,
|
||||
rowId,
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: `prompt ${rowId}` }]
|
||||
})
|
||||
|
||||
const page = (rowId: number) => ({
|
||||
session_id: 'stored',
|
||||
pagination: { has_older: true, has_newer: true, limit: 120, offset: 40, returned: 120, order: 'oldest' },
|
||||
messages: Array.from({ length: 120 }, (_, index) => ({
|
||||
id: rowId + index, role: 'user' as const, content: `prompt ${rowId + index}`, timestamp: rowId + index
|
||||
id: rowId + index,
|
||||
role: 'user' as const,
|
||||
content: `prompt ${rowId + index}`,
|
||||
timestamp: rowId + index
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -35,8 +41,10 @@ function mount(storedId = 'stored') {
|
||||
const $messages = atom(Array.from({ length: 120 }, (_, index) => message(10_000 + index)))
|
||||
|
||||
const view = {
|
||||
...PRIMARY_SESSION_VIEW, $messages,
|
||||
$runtimeId: atom<string | null>('runtime'), $storedId: atom<string | null>(storedId)
|
||||
...PRIMARY_SESSION_VIEW,
|
||||
$messages,
|
||||
$runtimeId: atom<string | null>('runtime'),
|
||||
$storedId: atom<string | null>(storedId)
|
||||
}
|
||||
|
||||
let window!: Required<TranscriptWindowValue>
|
||||
@@ -59,7 +67,17 @@ function mount(storedId = 'stored') {
|
||||
</SessionViewProvider>
|
||||
)
|
||||
|
||||
return { view, mutations, ...rendered, get window() { return window }, get runtime() { return runtime } }
|
||||
return {
|
||||
view,
|
||||
mutations,
|
||||
...rendered,
|
||||
get window() {
|
||||
return window
|
||||
},
|
||||
get runtime() {
|
||||
return runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('bounded direct history runtime', () => {
|
||||
@@ -68,7 +86,9 @@ describe('bounded direct history runtime', () => {
|
||||
const mounted = mount()
|
||||
const live = mounted.view.$messages.get()
|
||||
let id: string | null = null
|
||||
await act(async () => { id = await mounted.window.revealRow(40, new AbortController().signal) })
|
||||
await act(async () => {
|
||||
id = await mounted.window.revealRow(40, new AbortController().signal)
|
||||
})
|
||||
|
||||
expect(api).toHaveBeenCalledTimes(1)
|
||||
const url = new URL(api.mock.calls[0][0].path, 'http://test')
|
||||
@@ -81,14 +101,18 @@ describe('bounded direct history runtime', () => {
|
||||
expect(mounted.window.currentMessages?.find(message => message.rowId === 40)?.id).toBe(id)
|
||||
expect(mounted.window.isHistorical).toBe(true)
|
||||
expect(mounted.window.newerAvailable).toBe(true)
|
||||
act(() => { mounted.window.returnToLatest() })
|
||||
act(() => {
|
||||
mounted.window.returnToLatest()
|
||||
})
|
||||
expect(mounted.window.isHistorical).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps history static during streaming and restores the newest live tail and capabilities', async () => {
|
||||
vi.spyOn(window.hermesDesktop, 'api').mockResolvedValue(page(40))
|
||||
const mounted = mount()
|
||||
await act(async () => { await mounted.window.revealRow(40, new AbortController().signal) })
|
||||
await act(async () => {
|
||||
await mounted.window.revealRow(40, new AbortController().signal)
|
||||
})
|
||||
const historical = mounted.runtime.thread.getState().messages
|
||||
const snapshot = mounted.window
|
||||
// `edit` is the one capability that survives on a bounded page: the rail
|
||||
@@ -98,24 +122,32 @@ describe('bounded direct history runtime', () => {
|
||||
expect(mounted.runtime.thread.getState().capabilities.reload).toBe(false)
|
||||
expect(mounted.runtime.thread.getState().capabilities.switchToBranch).toBe(false)
|
||||
expect(mounted.runtime.thread.getState().isDisabled).toBe(true)
|
||||
act(() => { mounted.view.$messages.set([...mounted.view.$messages.get(), message(20_000)]) })
|
||||
act(() => {
|
||||
mounted.view.$messages.set([...mounted.view.$messages.get(), message(20_000)])
|
||||
})
|
||||
expect(mounted.runtime.thread.getState().messages).toBe(historical)
|
||||
expect(mounted.window).toBe(snapshot)
|
||||
expect(await mounted.window.expandWindow()).toBe(false)
|
||||
act(() => { mounted.window.returnToLatest() })
|
||||
act(() => {
|
||||
mounted.window.returnToLatest()
|
||||
})
|
||||
expect(mounted.runtime.thread.getState().messages.at(-1)?.id).toBe('live-20000')
|
||||
expect(mounted.runtime.thread.getState().capabilities.edit).toBe(true)
|
||||
expect(mounted.runtime.thread.getState().capabilities.reload).toBe(true)
|
||||
expect(mounted.runtime.thread.getState().isDisabled).toBe(false)
|
||||
expect(mounted.window.isHistorical).toBe(false)
|
||||
|
||||
for (const callback of Object.values(mounted.mutations)) {expect(callback).not.toHaveBeenCalled()}
|
||||
for (const callback of Object.values(mounted.mutations)) {
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the edit composer available after a rail jump selects a history page', async () => {
|
||||
vi.spyOn(window.hermesDesktop, 'api').mockResolvedValue(page(40))
|
||||
const mounted = mount()
|
||||
await act(async () => { await mounted.window.revealRow(40, new AbortController().signal) })
|
||||
await act(async () => {
|
||||
await mounted.window.revealRow(40, new AbortController().signal)
|
||||
})
|
||||
|
||||
expect(mounted.window.isHistorical).toBe(true)
|
||||
expect(mounted.runtime.thread.getState().capabilities.edit).toBe(true)
|
||||
@@ -127,11 +159,19 @@ describe('bounded direct history runtime', () => {
|
||||
// only by the floating jump button's returnToLatest.
|
||||
const composer = mounted.runtime.thread.getMessageByIndex(0).composer
|
||||
|
||||
expect(() => act(() => { composer.beginEdit() })).not.toThrow()
|
||||
expect(() =>
|
||||
act(() => {
|
||||
composer.beginEdit()
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(composer.getState().isEditing).toBe(true)
|
||||
|
||||
act(() => { composer.cancel() })
|
||||
act(() => { mounted.window.returnToLatest() })
|
||||
act(() => {
|
||||
composer.cancel()
|
||||
})
|
||||
act(() => {
|
||||
mounted.window.returnToLatest()
|
||||
})
|
||||
expect(composer.getState().isEditing).toBe(false)
|
||||
})
|
||||
|
||||
@@ -146,24 +186,41 @@ describe('bounded direct history runtime', () => {
|
||||
second = mounted.window.revealRow(400, new AbortController().signal)
|
||||
})
|
||||
expect(await first).toBeNull()
|
||||
await act(async () => { resolves[1](page(400)); await second })
|
||||
await act(async () => {
|
||||
resolves[1](page(400))
|
||||
await second
|
||||
})
|
||||
const selected = mounted.window.currentMessages
|
||||
await act(async () => { resolves[0](page(40)); await Promise.resolve() })
|
||||
await act(async () => {
|
||||
resolves[0](page(40))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(mounted.window.currentMessages).toBe(selected)
|
||||
expect(selected[0].rowId).toBe(400)
|
||||
})
|
||||
|
||||
it.each(['abort', 'latest', 'session', 'unmount'] as const)('discards pending reads on %s', async action => {
|
||||
let resolve!: (value: ReturnType<typeof page>) => void
|
||||
vi.spyOn(window.hermesDesktop, 'api').mockImplementation(() => new Promise(done => { resolve = done }))
|
||||
vi.spyOn(window.hermesDesktop, 'api').mockImplementation(
|
||||
() =>
|
||||
new Promise(done => {
|
||||
resolve = done
|
||||
})
|
||||
)
|
||||
const mounted = mount()
|
||||
const signal = new AbortController()
|
||||
let pending!: Promise<string | null>
|
||||
act(() => { pending = mounted.window.revealRow(40, signal.signal) })
|
||||
act(() => {
|
||||
if (action === 'abort') {signal.abort()}
|
||||
pending = mounted.window.revealRow(40, signal.signal)
|
||||
})
|
||||
act(() => {
|
||||
if (action === 'abort') {
|
||||
signal.abort()
|
||||
}
|
||||
|
||||
if (action === 'latest') {mounted.window.returnToLatest()}
|
||||
if (action === 'latest') {
|
||||
mounted.window.returnToLatest()
|
||||
}
|
||||
|
||||
if (action === 'session') {
|
||||
mounted.view.$storedId.set('next-session')
|
||||
@@ -171,10 +228,15 @@ describe('bounded direct history runtime', () => {
|
||||
mounted.view.$messages.set([message(30_000)])
|
||||
}
|
||||
|
||||
if (action === 'unmount') {mounted.unmount()}
|
||||
if (action === 'unmount') {
|
||||
mounted.unmount()
|
||||
}
|
||||
})
|
||||
expect(await pending).toBeNull()
|
||||
await act(async () => { resolve(page(40)); await Promise.resolve() })
|
||||
await act(async () => {
|
||||
resolve(page(40))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(mounted.view.$messages.get().some(message => message.rowId === 40)).toBe(false)
|
||||
expect(mounted.window.isHistorical).toBe(false)
|
||||
})
|
||||
@@ -182,14 +244,25 @@ describe('bounded direct history runtime', () => {
|
||||
it('rejects oversized, missing-target and failed responses without losing the selected page', async () => {
|
||||
const api = vi.spyOn(window.hermesDesktop, 'api').mockResolvedValue(page(40))
|
||||
const mounted = mount()
|
||||
await act(async () => { await mounted.window.revealRow(40, new AbortController().signal) })
|
||||
await act(async () => {
|
||||
await mounted.window.revealRow(40, new AbortController().signal)
|
||||
})
|
||||
const selected = mounted.window.currentMessages
|
||||
|
||||
for (const response of [{ ...page(400), messages: [...page(400).messages, ...page(600).messages] }, page(800), null]) {
|
||||
if (response) {api.mockResolvedValueOnce(response)}
|
||||
else {api.mockRejectedValueOnce(new Error('offline'))}
|
||||
for (const response of [
|
||||
{ ...page(400), messages: [...page(400).messages, ...page(600).messages] },
|
||||
page(800),
|
||||
null
|
||||
]) {
|
||||
if (response) {
|
||||
api.mockResolvedValueOnce(response)
|
||||
} else {
|
||||
api.mockRejectedValueOnce(new Error('offline'))
|
||||
}
|
||||
|
||||
await act(async () => { expect(await mounted.window.revealRow(400, new AbortController().signal)).toBeNull() })
|
||||
await act(async () => {
|
||||
expect(await mounted.window.revealRow(400, new AbortController().signal)).toBeNull()
|
||||
})
|
||||
expect(mounted.window.currentMessages).toBe(selected)
|
||||
}
|
||||
})
|
||||
@@ -212,13 +285,17 @@ describe('paging earlier from an open history window', () => {
|
||||
const mounted = mount()
|
||||
const live = mounted.view.$messages.get()
|
||||
|
||||
await act(async () => { await mounted.window.revealRow(4000, new AbortController().signal) })
|
||||
await act(async () => {
|
||||
await mounted.window.revealRow(4000, new AbortController().signal)
|
||||
})
|
||||
// The row was reached from the rail, but everything before it is still
|
||||
// back there: the transcript's own entry point must not retire.
|
||||
expect(mounted.window.olderAvailable).toBe(true)
|
||||
|
||||
let grew = false
|
||||
await act(async () => { grew = (await mounted.window.expandWindow()) === true })
|
||||
await act(async () => {
|
||||
grew = (await mounted.window.expandWindow()) === true
|
||||
})
|
||||
expect(grew).toBe(true)
|
||||
|
||||
const rows = mounted.window.currentMessages?.map(message => message.rowId) ?? []
|
||||
@@ -246,10 +323,14 @@ describe('paging earlier from an open history window', () => {
|
||||
.mockResolvedValueOnce(page(3700))
|
||||
const mounted = mount('stored-gap')
|
||||
|
||||
await act(async () => { await mounted.window.revealRow(4000, new AbortController().signal) })
|
||||
await act(async () => {
|
||||
await mounted.window.revealRow(4000, new AbortController().signal)
|
||||
})
|
||||
const beforePrepend = vi.fn()
|
||||
let grew = false
|
||||
await act(async () => { grew = (await mounted.window.expandWindow(beforePrepend)) === true })
|
||||
await act(async () => {
|
||||
grew = (await mounted.window.expandWindow(beforePrepend)) === true
|
||||
})
|
||||
|
||||
expect(grew).toBe(true)
|
||||
const rows = mounted.window.currentMessages?.map(message => message.rowId) ?? []
|
||||
@@ -267,11 +348,15 @@ describe('paging earlier from an open history window', () => {
|
||||
|
||||
const mounted = mount('stored-unlisted')
|
||||
|
||||
await act(async () => { await mounted.window.revealRow(4000, new AbortController().signal) })
|
||||
await act(async () => {
|
||||
await mounted.window.revealRow(4000, new AbortController().signal)
|
||||
})
|
||||
expect(mounted.window.olderAvailable).toBe(true)
|
||||
|
||||
let grew = true
|
||||
await act(async () => { grew = (await mounted.window.expandWindow()) === true })
|
||||
await act(async () => {
|
||||
grew = (await mounted.window.expandWindow()) === true
|
||||
})
|
||||
expect(grew).toBe(false)
|
||||
// No page can ever arrive: stop offering one instead of failing forever.
|
||||
expect(mounted.window.olderAvailable).toBe(false)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope } from '@/api/client'
|
||||
import { cachedTimelineIndex, previousPromptRowId, timelineIndexKey } from '@/components/assistant-ui/thread/timeline-index'
|
||||
import {
|
||||
cachedTimelineIndex,
|
||||
previousPromptRowId,
|
||||
timelineIndexKey
|
||||
} from '@/components/assistant-ui/thread/timeline-index'
|
||||
import { type ChatMessage, toChatMessages } from '@/lib/chat-messages'
|
||||
import type { SessionMessagesResponse } from '@/types/hermes'
|
||||
|
||||
@@ -35,7 +39,9 @@ export async function fetchHistoryWindow(
|
||||
const route = capabilityScoped(scope)
|
||||
const query = new URLSearchParams({ row_id: String(rowId), limit: String(HISTORY_WINDOW_LIMIT) })
|
||||
|
||||
if (route.profile) {query.set('profile', route.profile)}
|
||||
if (route.profile) {
|
||||
query.set('profile', route.profile)
|
||||
}
|
||||
|
||||
// The Electron REST bridge cannot transfer AbortSignal over IPC. Cancellation
|
||||
// below releases the caller immediately and fences the eventual bounded read;
|
||||
@@ -91,51 +97,67 @@ export function useHistoryWindow({ scopeKey, storedId, scope, isCurrent }: Histo
|
||||
setSelection(null)
|
||||
}, [cancel])
|
||||
|
||||
const revealRow = useCallback(async (rowId: number, signal: AbortSignal): Promise<string | null> => {
|
||||
cancel()
|
||||
const revealRow = useCallback(
|
||||
async (rowId: number, signal: AbortSignal): Promise<string | null> => {
|
||||
cancel()
|
||||
|
||||
if (signal.aborted || !Number.isSafeInteger(rowId) || rowId <= 0) {return null}
|
||||
const captured = latest.current
|
||||
|
||||
if (!captured.storedId || !captured.isCurrent()) {return null}
|
||||
const controller = new AbortController()
|
||||
pending.current = controller
|
||||
const abort = () => controller.abort()
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
let release!: () => void
|
||||
|
||||
const aborted = new Promise<null>(resolve => {
|
||||
release = () => resolve(null)
|
||||
controller.signal.addEventListener('abort', release, { once: true })
|
||||
})
|
||||
|
||||
try {
|
||||
const next = await Promise.race([
|
||||
fetchHistoryWindow(captured.storedId, rowId, captured.scope, controller.signal),
|
||||
aborted
|
||||
])
|
||||
|
||||
if (!next || controller.signal.aborted || latest.current.lifetime !== captured.lifetime || !captured.isCurrent()) {
|
||||
if (signal.aborted || !Number.isSafeInteger(rowId) || rowId <= 0) {
|
||||
return null
|
||||
}
|
||||
const captured = latest.current
|
||||
|
||||
const target = next.messages.find(message => message.rowId === rowId)
|
||||
if (!captured.storedId || !captured.isCurrent()) {
|
||||
return null
|
||||
}
|
||||
const controller = new AbortController()
|
||||
pending.current = controller
|
||||
const abort = () => controller.abort()
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
let release!: () => void
|
||||
|
||||
if (!target) {return null}
|
||||
setSelection({ lifetime: captured.lifetime, page: next })
|
||||
const aborted = new Promise<null>(resolve => {
|
||||
release = () => resolve(null)
|
||||
controller.signal.addEventListener('abort', release, { once: true })
|
||||
})
|
||||
|
||||
return target.id
|
||||
} catch {
|
||||
// Missing/older backend, unreadable row, and failed reads preserve the
|
||||
// current page. The caller reports failure and can retry explicitly.
|
||||
return null
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
controller.signal.removeEventListener('abort', release)
|
||||
try {
|
||||
const next = await Promise.race([
|
||||
fetchHistoryWindow(captured.storedId, rowId, captured.scope, controller.signal),
|
||||
aborted
|
||||
])
|
||||
|
||||
if (pending.current === controller) {pending.current = null}
|
||||
}
|
||||
}, [cancel])
|
||||
if (
|
||||
!next ||
|
||||
controller.signal.aborted ||
|
||||
latest.current.lifetime !== captured.lifetime ||
|
||||
!captured.isCurrent()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const target = next.messages.find(message => message.rowId === rowId)
|
||||
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
setSelection({ lifetime: captured.lifetime, page: next })
|
||||
|
||||
return target.id
|
||||
} catch {
|
||||
// Missing/older backend, unreadable row, and failed reads preserve the
|
||||
// current page. The caller reports failure and can retry explicitly.
|
||||
return null
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
controller.signal.removeEventListener('abort', release)
|
||||
|
||||
if (pending.current === controller) {
|
||||
pending.current = null
|
||||
}
|
||||
}
|
||||
},
|
||||
[cancel]
|
||||
)
|
||||
|
||||
/**
|
||||
* Prepend the page before this window's first prompt. The anchor's
|
||||
@@ -143,79 +165,84 @@ export function useHistoryWindow({ scopeKey, storedId, scope, isCurrent }: Histo
|
||||
* transcript's entry point and the rail page one range. `beforePrepend` is
|
||||
* spent in the same commit as the prepend, exactly like a live-page grow.
|
||||
*/
|
||||
const revealOlder = useCallback(async (beforePrepend?: () => void): Promise<boolean> => {
|
||||
const captured = latest.current
|
||||
const current = captured.page
|
||||
const revealOlder = useCallback(
|
||||
async (beforePrepend?: () => void): Promise<boolean> => {
|
||||
const captured = latest.current
|
||||
const current = captured.page
|
||||
|
||||
// No older rows before this page's first row, or nothing to anchor on yet.
|
||||
if (!current?.olderAvailable || !captured.storedId || !captured.isCurrent()) {
|
||||
return false
|
||||
}
|
||||
// No older rows before this page's first row, or nothing to anchor on yet.
|
||||
if (!current?.olderAvailable || !captured.storedId || !captured.isCurrent()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const anchor = current.messages.find(message => message.role === 'user' && message.rowId !== undefined)?.rowId
|
||||
const anchor = current.messages.find(message => message.role === 'user' && message.rowId !== undefined)?.rowId
|
||||
|
||||
cancel()
|
||||
const controller = new AbortController()
|
||||
pending.current = controller
|
||||
cancel()
|
||||
const controller = new AbortController()
|
||||
pending.current = controller
|
||||
|
||||
try {
|
||||
const rowId = await previousPromptRowId(captured.storedId, captured.scope, anchor)
|
||||
try {
|
||||
const rowId = await previousPromptRowId(captured.storedId, captured.scope, anchor)
|
||||
|
||||
if (rowId === null || controller.signal.aborted) {
|
||||
// A complete index that lists no prompt before this window means the
|
||||
// backend's older rows can never be paged to: retire the offer so the
|
||||
// button and the top-edge auto-page stop promising a page that never
|
||||
// arrives. An incomplete index still leaves the page untouched for a retry.
|
||||
if (
|
||||
rowId === null &&
|
||||
anchor !== undefined &&
|
||||
!controller.signal.aborted &&
|
||||
latest.current.page === current &&
|
||||
cachedTimelineIndex(timelineIndexKey(captured.storedId, captured.scope))?.complete
|
||||
) {
|
||||
setSelection({ lifetime: captured.lifetime, page: { ...current, olderAvailable: false } })
|
||||
if (rowId === null || controller.signal.aborted) {
|
||||
// A complete index that lists no prompt before this window means the
|
||||
// backend's older rows can never be paged to: retire the offer so the
|
||||
// button and the top-edge auto-page stop promising a page that never
|
||||
// arrives. An incomplete index still leaves the page untouched for a retry.
|
||||
if (
|
||||
rowId === null &&
|
||||
anchor !== undefined &&
|
||||
!controller.signal.aborted &&
|
||||
latest.current.page === current &&
|
||||
cachedTimelineIndex(timelineIndexKey(captured.storedId, captured.scope))?.complete
|
||||
) {
|
||||
setSelection({ lifetime: captured.lifetime, page: { ...current, olderAvailable: false } })
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const next = await fetchHistoryWindow(captured.storedId, rowId, captured.scope, controller.signal)
|
||||
// The around route reads forward from a prompt, so a turn longer than the
|
||||
// page limit leaves rows between that page's end and this anchor. Never
|
||||
// paint that as one continuous transcript: show the older page on its
|
||||
// own instead, the way a rail jump to that mark would.
|
||||
const contiguous = next.offset + next.messages.length >= current.offset
|
||||
const messages = contiguous ? mergeOlderTranscriptPage(current.messages, next.messages) : next.messages
|
||||
|
||||
// A window replaced while this one was in flight owns the display page.
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
messages === current.messages ||
|
||||
latest.current.lifetime !== captured.lifetime ||
|
||||
latest.current.page !== current
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (contiguous) {
|
||||
beforePrepend?.()
|
||||
}
|
||||
|
||||
setSelection({
|
||||
lifetime: captured.lifetime,
|
||||
page: contiguous ? { ...next, messages, newerAvailable: current.newerAvailable } : next
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// Missing/older backend, unreadable page, and an index that cannot name
|
||||
// the predecessor all leave the page untouched; the caller reports
|
||||
// failure and can retry explicitly.
|
||||
return false
|
||||
} finally {
|
||||
if (pending.current === controller) {
|
||||
pending.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const next = await fetchHistoryWindow(captured.storedId, rowId, captured.scope, controller.signal)
|
||||
// The around route reads forward from a prompt, so a turn longer than the
|
||||
// page limit leaves rows between that page's end and this anchor. Never
|
||||
// paint that as one continuous transcript: show the older page on its
|
||||
// own instead, the way a rail jump to that mark would.
|
||||
const contiguous = next.offset + next.messages.length >= current.offset
|
||||
const messages = contiguous ? mergeOlderTranscriptPage(current.messages, next.messages) : next.messages
|
||||
|
||||
// A window replaced while this one was in flight owns the display page.
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
messages === current.messages ||
|
||||
latest.current.lifetime !== captured.lifetime ||
|
||||
latest.current.page !== current
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (contiguous) {
|
||||
beforePrepend?.()
|
||||
}
|
||||
|
||||
setSelection({
|
||||
lifetime: captured.lifetime,
|
||||
page: contiguous ? { ...next, messages, newerAvailable: current.newerAvailable } : next
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// Missing/older backend, unreadable page, and an index that cannot name
|
||||
// the predecessor all leave the page untouched; the caller reports
|
||||
// failure and can retry explicitly.
|
||||
return false
|
||||
} finally {
|
||||
if (pending.current === controller) {pending.current = null}
|
||||
}
|
||||
}, [cancel])
|
||||
},
|
||||
[cancel]
|
||||
)
|
||||
|
||||
return { page, revealRow, returnToLatest, revealOlder }
|
||||
}
|
||||
|
||||
@@ -299,17 +299,35 @@ describe('useComposerActions native image drops', () => {
|
||||
|
||||
it('does not attach a screenshot when its draft changes during native image saving', async () => {
|
||||
let finishSave!: (path: string) => void
|
||||
const saveImageBuffer = vi.fn(() => new Promise<string>(resolve => { finishSave = resolve }))
|
||||
const saveImageBuffer = vi.fn(
|
||||
() =>
|
||||
new Promise<string>(resolve => {
|
||||
finishSave = resolve
|
||||
})
|
||||
)
|
||||
const add = vi.fn()
|
||||
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { saveImageBuffer } })
|
||||
const { result } = renderHook(() => useComposerActions({
|
||||
activeSessionId: null,
|
||||
currentCwd: '/test',
|
||||
requestGateway: vi.fn(),
|
||||
scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useComposerActions({
|
||||
activeSessionId: null,
|
||||
currentCwd: '/test',
|
||||
requestGateway: vi.fn(),
|
||||
scope: {
|
||||
add,
|
||||
remove: vi.fn(() => null),
|
||||
target: 'main',
|
||||
update: vi.fn(() => true),
|
||||
updateIfCurrent: vi.fn(() => true)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
let current = true
|
||||
const pending = result.current.attachImageBlob(new Blob([new Uint8Array([1])], { type: 'image/png' }), () => current)
|
||||
const pending = result.current.attachImageBlob(
|
||||
new Blob([new Uint8Array([1])], { type: 'image/png' }),
|
||||
() => current
|
||||
)
|
||||
await vi.waitFor(() => expect(saveImageBuffer).toHaveBeenCalledOnce())
|
||||
current = false
|
||||
finishSave('/test/screenshot.png')
|
||||
@@ -396,22 +414,33 @@ describe('useComposerActions generated paste title metadata', () => {
|
||||
const savePastedText = vi.fn(async () => '/tmp/composer-pastes/pasted-content.txt')
|
||||
const add = vi.fn<(attachment: ComposerAttachment) => void>()
|
||||
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { savePastedText } })
|
||||
const { result } = renderHook(() => useComposerActions({
|
||||
activeSessionId: null,
|
||||
currentCwd: '/test',
|
||||
requestGateway: vi.fn(),
|
||||
scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useComposerActions({
|
||||
activeSessionId: null,
|
||||
currentCwd: '/test',
|
||||
requestGateway: vi.fn(),
|
||||
scope: {
|
||||
add,
|
||||
remove: vi.fn(() => null),
|
||||
target: 'main',
|
||||
update: vi.fn(() => true),
|
||||
updateIfCurrent: vi.fn(() => true)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const pasted = `Database migration incident\n${'x'.repeat(1_500)}`
|
||||
await expect(result.current.attachPastedText(pasted)).resolves.toBe(true)
|
||||
|
||||
expect(add).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: 'file',
|
||||
path: '/tmp/composer-pastes/pasted-content.txt',
|
||||
refText: '@file:/tmp/composer-pastes/pasted-content.txt',
|
||||
titlePreview: pasted.slice(0, 1_000)
|
||||
}))
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'file',
|
||||
path: '/tmp/composer-pastes/pasted-content.txt',
|
||||
refText: '@file:/tmp/composer-pastes/pasted-content.txt',
|
||||
titlePreview: pasted.slice(0, 1_000)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -60,7 +60,12 @@ import { ChatBar, ChatBarFallback } from './composer'
|
||||
import { FloatingComposerSurface } from './composer/floating-surface'
|
||||
import { requestComposerInsert } from './composer/focus'
|
||||
import { droppedFileInlineRefs } from './composer/inline-refs'
|
||||
import { ComposerScopeProvider, ComposerSurfaceProvider, useComposerScope, useComposerSurfaceId } from './composer/scope'
|
||||
import {
|
||||
ComposerScopeProvider,
|
||||
ComposerSurfaceProvider,
|
||||
useComposerScope,
|
||||
useComposerSurfaceId
|
||||
} from './composer/scope'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import { useHistoryWindow } from './history-window'
|
||||
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
||||
@@ -258,9 +263,10 @@ export function ChatRuntimeBoundary({
|
||||
const ownerConnection = ownerRoute?.connectionId
|
||||
const ownerProfile = ownerRoute?.targetProfile || ownerRoute?.profile
|
||||
|
||||
const tailProfile = useMemo(() => ownerProfile
|
||||
? { connectionId: ownerConnection, profile: ownerProfile }
|
||||
: undefined, [ownerConnection, ownerProfile])
|
||||
const tailProfile = useMemo(
|
||||
() => (ownerProfile ? { connectionId: ownerConnection, profile: ownerProfile } : undefined),
|
||||
[ownerConnection, ownerProfile]
|
||||
)
|
||||
|
||||
// A Bot chat opened IN PLACE in the main pane (openStoredBotChat) keeps the
|
||||
// active profile, so the ambient scope carries no owner. Publish the session
|
||||
@@ -331,7 +337,7 @@ export function ChatRuntimeBoundary({
|
||||
// static history page is not the live store, and neither is a suppressed
|
||||
// transcript, so both opt out.
|
||||
useTranscriptRetention({
|
||||
anchorId: windowed ? windowedMessages[0]?.id ?? null : null,
|
||||
anchorId: windowed ? (windowedMessages[0]?.id ?? null) : null,
|
||||
enabled: !suppressMessages && !history.page,
|
||||
profile: tailProfile,
|
||||
runtimeId,
|
||||
@@ -348,7 +354,9 @@ export function ChatRuntimeBoundary({
|
||||
async (beforePrepend?: () => void) => {
|
||||
// A historical page is not the live tail: its older neighbours come from
|
||||
// the prompt range the rail already draws, never from store backfill.
|
||||
if (history.page) {return history.revealOlder(beforePrepend)}
|
||||
if (history.page) {
|
||||
return history.revealOlder(beforePrepend)
|
||||
}
|
||||
|
||||
// Network latency is not scroll intent. Capture at arrival, immediately
|
||||
// before the store prepend, and only grow a window that has a page to show.
|
||||
@@ -406,9 +414,18 @@ export function ChatRuntimeBoundary({
|
||||
const newerAvailable = history.page?.newerAvailable ?? false
|
||||
const { revealRow, returnToLatest } = history
|
||||
|
||||
const transcriptWindow = useMemo(() => ({
|
||||
olderAvailable, expandWindow, revealRow, returnToLatest, currentMessages, isHistorical, newerAvailable
|
||||
}), [expandWindow, olderAvailable, revealRow, returnToLatest, currentMessages, isHistorical, newerAvailable])
|
||||
const transcriptWindow = useMemo(
|
||||
() => ({
|
||||
olderAvailable,
|
||||
expandWindow,
|
||||
revealRow,
|
||||
returnToLatest,
|
||||
currentMessages,
|
||||
isHistorical,
|
||||
newerAvailable
|
||||
}),
|
||||
[expandWindow, olderAvailable, revealRow, returnToLatest, currentMessages, isHistorical, newerAvailable]
|
||||
)
|
||||
|
||||
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: runtimeMessageRepository,
|
||||
|
||||
@@ -97,7 +97,10 @@ const fileTarget = (path: string) =>
|
||||
// just be declared on the tile.
|
||||
describe('preview tiles keep a live page alive across Hide', () => {
|
||||
it('registers a Browser tab with lifecycleKeepAlive while a text peek stays evictable', () => {
|
||||
openPreview({ kind: 'url', label: 'Browser', source: 'https://example.com', url: 'https://example.com' }, 'explicit-link')
|
||||
openPreview(
|
||||
{ kind: 'url', label: 'Browser', source: 'https://example.com', url: 'https://example.com' },
|
||||
'explicit-link'
|
||||
)
|
||||
openPreview(fileTarget('/tmp/a.ts'), 'file-browser')
|
||||
|
||||
const browserId = $previewTabs.get().find(tab => tab.target.kind === 'url')!.id
|
||||
|
||||
@@ -21,11 +21,7 @@ export function ResumeExhaustedOverlay({
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
|
||||
<ErrorState
|
||||
className="max-w-sm"
|
||||
description={t.desktop.resumeStrandedBody}
|
||||
title={t.desktop.resumeStrandedTitle}
|
||||
>
|
||||
<ErrorState className="max-w-sm" description={t.desktop.resumeStrandedBody} title={t.desktop.resumeStrandedTitle}>
|
||||
<div className="grid justify-items-center gap-1.5">
|
||||
<Button onClick={() => requestFreshSession()} size="sm" variant="outline">
|
||||
{t.assistant.thread.errorStartNewSession}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { toWebviewInputSpace } from './preview-input'
|
||||
// webview's input space is css × zoom. At the shipped 90 % default an
|
||||
// unscaled click landed 1/0.9 too far from the origin and missed silently.
|
||||
describe('toWebviewInputSpace', () => {
|
||||
it('scales pointer events by the guest zoom so the reporter\'s 500,310 target is hit at 90 %', () => {
|
||||
it("scales pointer events by the guest zoom so the reporter's 500,310 target is hit at 90 %", () => {
|
||||
const down = toWebviewInputSpace({ button: 'left', clickCount: 1, type: 'mouseDown', x: 500, y: 310 }, 0.9)
|
||||
|
||||
expect(down).toEqual({ button: 'left', clickCount: 1, type: 'mouseDown', x: 450, y: 279 })
|
||||
|
||||
@@ -13,7 +13,14 @@ import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles'
|
||||
|
||||
import { $routesVersion, ARTIFACTS_ROUTE, CAPABILITIES_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA } from '../routes'
|
||||
import {
|
||||
$routesVersion,
|
||||
ARTIFACTS_ROUTE,
|
||||
CAPABILITIES_ROUTE,
|
||||
contributedRoutes,
|
||||
MESSAGING_ROUTE,
|
||||
ROUTES_AREA
|
||||
} from '../routes'
|
||||
|
||||
import { paneMirror } from './pane-mirror'
|
||||
|
||||
|
||||
@@ -59,7 +59,9 @@ vi.mock('@/store/connections', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/store/profile-share', () => ({ runImportProfileFlow: vi.fn() }))
|
||||
vi.mock('./use-profile-prewarm', () => ({ useProfilePrewarm: () => ({ cancelPrewarm: vi.fn(), startPrewarm: vi.fn() }) }))
|
||||
vi.mock('./use-profile-prewarm', () => ({
|
||||
useProfilePrewarm: () => ({ cancelPrewarm: vi.fn(), startPrewarm: vi.fn() })
|
||||
}))
|
||||
vi.mock('./use-fleet-roster', () => ({ useFleetRoster: () => undefined }))
|
||||
vi.mock('../../profiles/create-profile-dialog', () => ({ CreateProfileDialog: () => null }))
|
||||
|
||||
|
||||
@@ -21,7 +21,12 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { resolveProfileColor } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeConnectionId, $connectionsRegistry, $hasMultipleConnections, selectConnection } from '@/store/connections'
|
||||
import {
|
||||
$activeConnectionId,
|
||||
$connectionsRegistry,
|
||||
$hasMultipleConnections,
|
||||
selectConnection
|
||||
} from '@/store/connections'
|
||||
import { $fleetRoster } from '@/store/fleet-roster'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
@@ -200,7 +205,9 @@ export function ProfileSwitcher({ compact = false }: { compact?: boolean }) {
|
||||
<DropdownMenuLabel className={cn(dropdownMenuSectionLabel, 'flex items-center gap-1.5')}>
|
||||
<ConnectionGlyph connection={group} />
|
||||
<span className="truncate">{group.label}</span>
|
||||
{!group.reachable && <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-amber-500" />}
|
||||
{!group.reachable && (
|
||||
<span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-amber-500" />
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
{[group.defaultAgent, ...group.named].map(agent => (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -247,6 +247,7 @@ describe('ProfileRail fleet mode', () => {
|
||||
if (condensed) {
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'Profiles' }), { button: 0, ctrlKey: false })
|
||||
}
|
||||
|
||||
fireEvent.contextMenu(
|
||||
screen.getByRole(condensed ? (target.connectionId ? 'menuitem' : 'menuitemradio') : 'button', {
|
||||
name: target.label
|
||||
|
||||
@@ -943,7 +943,9 @@ function ProfilePill({
|
||||
<ProfileLaunchContextMenu connectionId={connectionId ?? null} label={profile} profile={profile}>
|
||||
{button}
|
||||
</ProfileLaunchContextMenu>
|
||||
) : button
|
||||
) : (
|
||||
button
|
||||
)
|
||||
}
|
||||
|
||||
// The gateway marker that heads every group on the fleet rail: its kind glyph
|
||||
|
||||
@@ -14,7 +14,8 @@ afterEach(cleanup)
|
||||
const workspaceOpen = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
const projectsStore = vi.hoisted(() => ({
|
||||
fetchProjectSessions: vi.fn<(id: string, options?: { supersedable?: boolean }) => Promise<null | SidebarProjectTree>>(),
|
||||
fetchProjectSessions:
|
||||
vi.fn<(id: string, options?: { supersedable?: boolean }) => Promise<null | SidebarProjectTree>>(),
|
||||
projectProfile: vi.fn<() => null | string>(() => 'default')
|
||||
}))
|
||||
|
||||
|
||||
@@ -625,11 +625,13 @@ describe('overlayLiveLanes', () => {
|
||||
'entering an ancestor project does not inject a backend-owned sibling worktree session (git_repo_root=%s)',
|
||||
gitRepoRoot => {
|
||||
const sibling = makeCwdSession('/work/repos/app-2', { id: 'sibling', git_repo_root: gitRepoRoot })
|
||||
|
||||
const ancestor = projectNode({
|
||||
id: 'p_work',
|
||||
path: '/work',
|
||||
repos: [{ id: '/work', label: 'work', path: '/work', groups: [], sessionCount: 0 }]
|
||||
})
|
||||
|
||||
const appRepo = {
|
||||
id: '/work/repos/app',
|
||||
label: 'app',
|
||||
@@ -637,6 +639,7 @@ describe('overlayLiveLanes', () => {
|
||||
groups: [lane({ id: '/work/repos/app-2', label: 'app-2', path: '/work/repos/app-2', sessions: [] })],
|
||||
sessionCount: 0
|
||||
}
|
||||
|
||||
// Overview snapshot: the row sits beyond the preview window, so only the
|
||||
// backend's claimed-id set knows the owner.
|
||||
const repo = projectNode({ id: 'p_app', path: '/work/repos/app', previewSessions: [], sessionIds: ['sibling'] })
|
||||
@@ -1131,6 +1134,7 @@ describe('overlayLivePreviews', () => {
|
||||
gitRepoRoot => {
|
||||
const sibling = makeCwdSession('/work/repos/app-2', { id: 'sibling', git_repo_root: gitRepoRoot })
|
||||
const ancestor = projectNode({ id: 'p_work', path: '/work', previewSessions: [], sessionCount: 0 })
|
||||
|
||||
const repo = projectNode({
|
||||
id: 'p_app',
|
||||
path: '/work/repos/app',
|
||||
|
||||
@@ -178,6 +178,7 @@ beforeEach(() => {
|
||||
|
||||
describe('Show all sessions', () => {
|
||||
const sessions = Array.from({ length: 6 }, (_, index) => ({ id: `session-${index + 1}` }) as SessionInfo)
|
||||
|
||||
const renderRows = (items: SessionInfo[]) => (
|
||||
<>
|
||||
{items.map(item => (
|
||||
@@ -204,6 +205,7 @@ describe('Show all sessions', () => {
|
||||
workspaceOpen.value = true
|
||||
$sidebarShowAllSessions.set(true)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
const previewSessions = [0, 2, 10, 40].map(
|
||||
days =>
|
||||
({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user