Add waitLogContains matcher for agentctl scenario runs.
Introduce bounded log polling in attached and headless scenario runners so startup hardening can be asserted directly from structured pearcord.log lines. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -481,7 +481,7 @@ Bundled scenarios under `apps/pearcord/scenarios/`:
|
||||
|
||||
`mesh.connectGuildMeshThread(scenarioPath)` — optional `markThreadAndParentRead: true` in scenario JSON (v0.8.279). host steps + invite + `create-thread` + reply; guest must receive `threadReply` token.
|
||||
|
||||
Scenario step extras (headless + attached `run`): `joinFirstVoice`, `joinFirstStage`, `stageFixtureSticker`, `sendMessageWithStagedStickers`, `openDmPeer`, `selectFirstForumChannel`, `selectSecondForumChannel`, `selectFirstTextChannel`, `selectSecondTextChannel`, `selectChannelNamed`, `selectChannelOtherThanActive`, `selectParentOfActiveThread`, `selectActiveThread`, `threadName` with `selectActiveThread`, `markActiveChannelRead`, `seedInboxNotification`, `markLatestNotificationRead`, `openLatestNotification`, `seedMentionAlertForActiveChannel`, `createThreadFromLastMessage`, `markThreadAndParentRead`, `toggleReactionOnLastMessage` (emoji string or `{ emoji }`), `stageFixtureAttachment` (`{ image: true }` for PNG), `sendMessageWithStagedAttachments`, `requestPreviewForStagedAttachment`, `messagesMin`, `messageContentIncludes`, `searchResultsMin`, `searchResultsMax`, `searchResultsCached`, `searchUnchanged`, `searchRecentIncludes`, `mentionAlertCountMax`, `notificationUnreadMin`, `notificationUnreadMax`, `reactionCountMin`, `reactionsInclude`, `attachmentsOnLastMessageMin`, `stickersOnLastMessageMin`, `activeChannelName`, `parentChannelPermissions` on `wait`. `waitEvent` supports `payloadType` (e.g. `attachment-preview`).
|
||||
Scenario step extras (headless + attached `run`): `joinFirstVoice`, `joinFirstStage`, `stageFixtureSticker`, `sendMessageWithStagedStickers`, `openDmPeer`, `selectFirstForumChannel`, `selectSecondForumChannel`, `selectFirstTextChannel`, `selectSecondTextChannel`, `selectChannelNamed`, `selectChannelOtherThanActive`, `selectParentOfActiveThread`, `selectActiveThread`, `threadName` with `selectActiveThread`, `markActiveChannelRead`, `seedInboxNotification`, `markLatestNotificationRead`, `openLatestNotification`, `seedMentionAlertForActiveChannel`, `createThreadFromLastMessage`, `markThreadAndParentRead`, `toggleReactionOnLastMessage` (emoji string or `{ emoji }`), `stageFixtureAttachment` (`{ image: true }` for PNG), `sendMessageWithStagedAttachments`, `requestPreviewForStagedAttachment`, `messagesMin`, `messageContentIncludes`, `searchResultsMin`, `searchResultsMax`, `searchResultsCached`, `searchUnchanged`, `searchRecentIncludes`, `mentionAlertCountMax`, `notificationUnreadMin`, `notificationUnreadMax`, `reactionCountMin`, `reactionsInclude`, `attachmentsOnLastMessageMin`, `stickersOnLastMessageMin`, `activeChannelName`, `parentChannelPermissions` on `wait`, and `waitLogContains` (`messageIncludes`/`contains` and optional `regex`/`lines`) for structured log verification. `waitEvent` supports `payloadType` (e.g. `attachment-preview`).
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
+64
@@ -112,6 +112,66 @@ class HeadlessSession {
|
||||
throw new Error(`headless wait timeout: ${JSON.stringify(match)}`)
|
||||
}
|
||||
|
||||
_tailLogLines (lines = 200) {
|
||||
const cap = Math.min(Math.max(1, Number(lines) || 200), 1000)
|
||||
const logPath = path.join(this.storagePath, 'pearcord.log')
|
||||
let text = ''
|
||||
try {
|
||||
text = fs.readFileSync(logPath, 'utf8')
|
||||
} catch {
|
||||
return { logPath, lines: [] }
|
||||
}
|
||||
return {
|
||||
logPath,
|
||||
lines: text.split('\n').filter(Boolean).slice(-cap)
|
||||
}
|
||||
}
|
||||
|
||||
async waitLogContains (spec, timeoutMs = 15000) {
|
||||
const cfg =
|
||||
typeof spec === 'string'
|
||||
? { messageIncludes: spec }
|
||||
: spec && typeof spec === 'object'
|
||||
? spec
|
||||
: {}
|
||||
const messageIncludes = String(cfg.messageIncludes || cfg.contains || '').trim()
|
||||
const regexSource = String(cfg.regex || '').trim()
|
||||
const linesCap = Math.min(Math.max(1, Number(cfg.lines) || 300), 1000)
|
||||
if (!messageIncludes && !regexSource) {
|
||||
throw new Error('headless wait.logContains requires messageIncludes/contains or regex')
|
||||
}
|
||||
let regex = null
|
||||
if (regexSource) {
|
||||
try {
|
||||
regex = new RegExp(regexSource)
|
||||
} catch (err) {
|
||||
throw new Error(`headless wait.logContains invalid regex: ${err?.message || String(err)}`)
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let lastTail = []
|
||||
let logPath = path.join(this.storagePath, 'pearcord.log')
|
||||
while (Date.now() < deadline) {
|
||||
const tail = this._tailLogLines(linesCap)
|
||||
logPath = tail.logPath
|
||||
lastTail = tail.lines
|
||||
for (const line of lastTail) {
|
||||
const includesOk = messageIncludes ? line.includes(messageIncludes) : true
|
||||
const regexOk = regex ? regex.test(line) : true
|
||||
if (includesOk && regexOk) return { line, logPath }
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
throw new Error(
|
||||
`headless wait.logContains timeout: ${JSON.stringify({
|
||||
messageIncludes: messageIncludes || null,
|
||||
regex: regexSource || null,
|
||||
logPath,
|
||||
inspectedLines: lastTail.length
|
||||
})}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ ipc?: object, wait?: object, timeoutMs?: number }>} steps
|
||||
*/
|
||||
@@ -506,6 +566,10 @@ class HeadlessSession {
|
||||
}
|
||||
results.push({ waitEvent: step.waitEvent, payload: matched })
|
||||
}
|
||||
if (step.waitLogContains) {
|
||||
const matched = await this.waitLogContains(step.waitLogContains, step.timeoutMs)
|
||||
results.push({ waitLogContains: step.waitLogContains, matched })
|
||||
}
|
||||
if (step.createWebhookActiveChannel) {
|
||||
const spec =
|
||||
step.createWebhookActiveChannel && typeof step.createWebhookActiveChannel === 'object'
|
||||
|
||||
@@ -153,6 +153,66 @@ class AgentServer {
|
||||
return req?.token === expected
|
||||
}
|
||||
|
||||
_logTailLines (lines = 200) {
|
||||
const cap = Math.min(Math.max(1, Number(lines) || 200), 1000)
|
||||
const logPath = resolveLogPath(this.storagePath)
|
||||
let text = ''
|
||||
try {
|
||||
text = fs.readFileSync(logPath, 'utf8')
|
||||
} catch {
|
||||
return { logPath, lines: [] }
|
||||
}
|
||||
return {
|
||||
logPath,
|
||||
lines: text.split('\n').filter(Boolean).slice(-cap)
|
||||
}
|
||||
}
|
||||
|
||||
async _waitLogContains (spec, timeoutMs = 15000) {
|
||||
const cfg =
|
||||
typeof spec === 'string'
|
||||
? { messageIncludes: spec }
|
||||
: spec && typeof spec === 'object'
|
||||
? spec
|
||||
: {}
|
||||
const messageIncludes = String(cfg.messageIncludes || cfg.contains || '').trim()
|
||||
const regexSource = String(cfg.regex || '').trim()
|
||||
const linesCap = Math.min(Math.max(1, Number(cfg.lines) || 300), 1000)
|
||||
if (!messageIncludes && !regexSource) {
|
||||
throw new Error('waitLogContains requires messageIncludes/contains or regex')
|
||||
}
|
||||
let regex = null
|
||||
if (regexSource) {
|
||||
try {
|
||||
regex = new RegExp(regexSource)
|
||||
} catch (err) {
|
||||
throw new Error(`waitLogContains invalid regex: ${err?.message || String(err)}`)
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let lastTail = []
|
||||
let logPath = resolveLogPath(this.storagePath)
|
||||
while (Date.now() < deadline) {
|
||||
const tail = this._logTailLines(linesCap)
|
||||
logPath = tail.logPath
|
||||
lastTail = tail.lines
|
||||
for (const line of lastTail) {
|
||||
const includesOk = messageIncludes ? line.includes(messageIncludes) : true
|
||||
const regexOk = regex ? regex.test(line) : true
|
||||
if (includesOk && regexOk) return { line, logPath }
|
||||
}
|
||||
await sleep(100)
|
||||
}
|
||||
throw new Error(
|
||||
`wait.logContains timeout: ${JSON.stringify({
|
||||
messageIncludes: messageIncludes || null,
|
||||
regex: regexSource || null,
|
||||
logPath,
|
||||
inspectedLines: lastTail.length
|
||||
})}`
|
||||
)
|
||||
}
|
||||
|
||||
async _runScenarioSteps (steps) {
|
||||
const results = []
|
||||
for (const step of steps || []) {
|
||||
@@ -230,6 +290,11 @@ class AgentServer {
|
||||
}
|
||||
results.push({ waitEvent: step.waitEvent, event: matched })
|
||||
}
|
||||
if (step.waitLogContains) {
|
||||
const timeoutMs = Number(step.timeoutMs) || 15000
|
||||
const matched = await this._waitLogContains(step.waitLogContains, timeoutMs)
|
||||
results.push({ waitLogContains: step.waitLogContains, matched })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -368,16 +433,8 @@ class AgentServer {
|
||||
|
||||
if (req.op === 'log-tail') {
|
||||
const lines = Math.min(Number(req.lines) || 40, 500)
|
||||
const logPath = resolveLogPath(this.storagePath)
|
||||
let text = ''
|
||||
try {
|
||||
text = fs.readFileSync(logPath, 'utf8')
|
||||
} catch {
|
||||
this._reply(socket, { ...base, ok: true, lines: [], logPath })
|
||||
return
|
||||
}
|
||||
const tail = text.split('\n').filter(Boolean).slice(-lines)
|
||||
this._reply(socket, { ...base, ok: true, logPath, lines: tail })
|
||||
const tail = this._logTailLines(lines)
|
||||
this._reply(socket, { ...base, ok: true, logPath: tail.logPath, lines: tail.lines })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user