Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "api.prompd.app",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.10",
"description": "Node.js backend for Prompd Editor with MongoDB and WebSocket support",
"main": "src/server.js",
"license": "Elastic-2.0",
Expand All @@ -14,7 +14,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.65.0",
"@google/generative-ai": "^0.24.1",
"@prompd/cli": "^0.5.0-beta.7",
"@prompd/cli": "0.5.0-beta.10",
"adm-zip": "^0.5.10",
"archiver": "^6.0.1",
"axios": "^1.6.2",
Expand Down
13 changes: 13 additions & 0 deletions backend/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@ import dotenv from 'dotenv'
// Load environment variables FIRST
dotenv.config()

// Node 24+ c-ares DNS resolver can't resolve SRV records through
// ISP DNS-over-HTTPS proxies (e.g., Cox doh). Use public DNS as fallback.
import dns from 'dns'
try {
const resolver = new dns.Resolver()
resolver.setServers(['8.8.8.8', '1.1.1.1'])
resolver.resolveSrv('_mongodb._tcp.test.mongodb.net', () => {})
// If the default resolver fails SRV lookups, this ensures MongoDB SRV works
dns.setServers(['8.8.8.8', '1.1.1.1', ...dns.getServers()])
} catch {
// Ignore — default DNS is fine
}

import express from 'express'
import { createServer } from 'http'
import { Server } from 'socket.io'
Expand Down
173 changes: 173 additions & 0 deletions frontend/electron/ipc/TestIpcRegistration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* TestIpcRegistration — IPC handlers for test:* channels
*
* Bridges the renderer process to @prompd/test for prompt test discovery,
* execution, and live progress streaming.
*
* IMPORTANT: Passes the main process's @prompd/cli module to TestRunner
* so they share the same ConfigManager singleton (API keys, provider config).
*
* Handlers: test:discover, test:run, test:runAll, test:stop
* Events: test:progress (main → renderer)
*/

const { BaseIpcRegistration } = require('./IpcRegistration')
const crypto = require('crypto')

/** @type {import('@prompd/test') | null} */
let testModule = null

/** @type {any} */
let cliModule = null

/**
* Lazy-load @prompd/test (CommonJS package)
*/
function getTestModule() {
if (!testModule) {
try {
testModule = require('@prompd/test')
} catch (err) {
console.error('[TestIpc] Failed to load @prompd/test:', err.message)
throw err
}
}
return testModule
}

/**
* Lazy-load @prompd/cli — same instance used by main.js
*/
async function getCliModule() {
if (!cliModule) {
try {
cliModule = await import('@prompd/cli')
// Use the singleton ConfigManager — same instance the PrompdExecutor uses internally
const cm = cliModule.ConfigManager.getInstance
? cliModule.ConfigManager.getInstance()
: new cliModule.ConfigManager()
await cm.loadConfig()
console.log('[TestIpc] @prompd/cli loaded and config initialized via singleton')
} catch (err) {
console.error('[TestIpc] Failed to load @prompd/cli:', err.message)
throw err
}
}
return cliModule
}

class TestIpcRegistration extends BaseIpcRegistration {
constructor() {
super('TestIpc')
/** @type {Map<string, { abort: AbortController }>} */
this.activeRuns = new Map()
}

register(ipcMain) {
// Discover .test.prmd files in a directory
ipcMain.handle('test:discover', async (_event, { directory }) => {
try {
const { TestDiscovery } = getTestModule()
const discovery = new TestDiscovery()
const result = await discovery.discover(directory)

return {
success: true,
suites: result.suites.map(s => ({
name: s.name,
description: s.description,
testFilePath: s.testFilePath,
targetPath: s.target,
testCount: s.tests.length,
testNames: s.tests.map(t => t.name),
})),
errors: result.errors,
}
} catch (err) {
return {
success: false,
suites: [],
errors: [{ filePath: directory, message: err.message }],
}
}
})

// Run tests for a specific target
ipcMain.handle('test:run', async (event, { target, options = {} }) => {
return this._runTests(event, target, options)
})

// Run all tests in a directory
ipcMain.handle('test:runAll', async (event, { directory, options = {} }) => {
return this._runTests(event, directory, options)
})

// Stop a running test
ipcMain.handle('test:stop', async (_event, { runId }) => {
const run = this.activeRuns.get(runId)
if (run) {
run.abort.abort()
this.activeRuns.delete(runId)
return { success: true }
}
return { success: false, error: 'No active run with that ID' }
})

console.log('[TestIpc] Registered test:discover, test:run, test:runAll, test:stop')
}

/**
* Internal: run tests with progress streaming
*/
async _runTests(event, target, options) {
const runId = crypto.randomUUID()
const abort = new AbortController()
this.activeRuns.set(runId, { abort })

const sender = event.sender

try {
const { TestRunner } = getTestModule()
// Pass the CLI module so TestRunner uses the same singleton (shared config/API keys)
const cli = await getCliModule()
const runner = new TestRunner(cli)

// Progress callback sends events to renderer
const onProgress = (progressEvent) => {
if (abort.signal.aborted) return
try {
sender.send('test:progress', { runId, event: progressEvent })
} catch {
// Sender may be destroyed if window closed
}
}

const result = await runner.run(target, { ...options, signal: abort.signal }, onProgress)

this.activeRuns.delete(runId)

return {
success: true,
runId,
result,
}
} catch (err) {
this.activeRuns.delete(runId)
return {
success: false,
runId,
error: err.message,
}
}
}

async cleanup() {
// Abort any running tests on app quit
for (const [runId, run] of this.activeRuns) {
run.abort.abort()
this.activeRuns.delete(runId)
}
}
}

module.exports = { TestIpcRegistration }
2 changes: 2 additions & 0 deletions frontend/electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const { TemplateIpcRegistration } = require('./ipc/TemplateIpcRegistration')
const { ResourceIpcRegistration } = require('./ipc/ResourceIpcRegistration')
const { SkillIpcRegistration } = require('./ipc/SkillIpcRegistration')
const { CacheIpcRegistration } = require('./ipc/CacheIpcRegistration')
const { TestIpcRegistration } = require('./ipc/TestIpcRegistration')
const mcpService = require('./services/mcpService')
const { mcpServerService } = require('./services/mcpServerService')
const ipcModules = [
Expand All @@ -40,6 +41,7 @@ const ipcModules = [
new ResourceIpcRegistration(),
new SkillIpcRegistration(),
new CacheIpcRegistration(),
new TestIpcRegistration(),
]

// Tray and trigger services for background workflow execution
Expand Down
17 changes: 17 additions & 0 deletions frontend/electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('skill:list', workspacePath),
},

// Test runner - discover, run, and stream .test.prmd evaluation results
test: {
discover: (directory) =>
ipcRenderer.invoke('test:discover', { directory }),
run: (target, options) =>
ipcRenderer.invoke('test:run', { target, options }),
runAll: (directory, options) =>
ipcRenderer.invoke('test:runAll', { directory, options }),
stop: (runId) =>
ipcRenderer.invoke('test:stop', { runId }),
onProgress: (callback) => {
const handler = (_, data) => callback(data)
ipcRenderer.on('test:progress', handler)
return () => ipcRenderer.removeListener('test:progress', handler)
},
},

// Trigger service - background workflow execution management
// Handles scheduled (cron), webhook, and file-watch triggers
trigger: {
Expand Down
Loading
Loading