diff --git a/README.md b/README.md index 1d063d0..873acf3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/headless.js b/headless.js index df44b50..1023956 100644 --- a/headless.js +++ b/headless.js @@ -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' diff --git a/server.js b/server.js index 74a00b4..1725c7d 100644 --- a/server.js +++ b/server.js @@ -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 }