docs: 交付文档、维护指引与端到端回归
补齐 README、开发计划、维护交接与 E2E 用例,执行最终验收。
This commit is contained in:
1 parent
18732801b5
commit
6cd904d460
216 files changed
+37038
No files matched your search
@@ -0,0 +1,99 @@
|
||||
// A pretend customer website with the ReplyPier widget embedded the way the
|
||||
// dashboard's install snippet does it. Everything about the embed comes from the
|
||||
// query string:
|
||||
//
|
||||
// http://127.0.0.1:13000/?widget=<id>
|
||||
// &config=<json> → window.replypierConfig (launcher, position, trigger, …)
|
||||
// &token=<jwt> → window.replypierToken (token-authenticated widgets)
|
||||
// &trigger=1 → adds a page button with data-replypier-open
|
||||
// &snippet=iframe → the dashboard's iframe snippet instead of the script
|
||||
//
|
||||
// It has to be a real server on 127.0.0.1: Chrome's Local Network Access check
|
||||
// blocks a page Playwright fabricates (page.route) from loading the loader and
|
||||
// calling the API on loopback.
|
||||
//
|
||||
// Run: node services/customer-site/server.ts --port 13000 --api <api url> --loader <loader url>
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
function arg(name: string, fallback: string): string {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
return i > -1 ? process.argv[i + 1] : fallback
|
||||
}
|
||||
|
||||
const port = Number(arg('port', '13000'))
|
||||
const apiUrl = arg('api', 'http://127.0.0.1:18000/api/v1')
|
||||
const loaderUrl = arg('loader', 'http://127.0.0.1:15173/webclient/replypier.min.js')
|
||||
|
||||
interface Embed {
|
||||
widgetId: string
|
||||
config: string | null
|
||||
token: string | null
|
||||
trigger: boolean
|
||||
iframe: boolean
|
||||
}
|
||||
|
||||
/** The dashboard's iframe snippet (AgentWidgetTab), classed so the page object finds it. */
|
||||
function iframeSnippet(widgetId: string): string {
|
||||
const src = `${apiUrl}/widgets/${encodeURIComponent(widgetId)}/data`
|
||||
return `<iframe class="replypier-iframe" src="${src}" width="100%" height="600" frameborder="0" title="AI Assistant" allow="clipboard-write"></iframe>`
|
||||
}
|
||||
|
||||
function page(embed: Embed): string {
|
||||
if (embed.iframe) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Acme Store</title></head>
|
||||
<body>
|
||||
<h1>Acme Store</h1>
|
||||
${iframeSnippet(embed.widgetId)}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
const globals = [
|
||||
`window.replypierId = ${JSON.stringify(embed.widgetId)};`,
|
||||
`window.replypierBaseUrl = ${JSON.stringify(apiUrl)};`,
|
||||
// config arrives as JSON text; parse it here so a malformed value fails the request, not the page.
|
||||
embed.config ? `window.replypierConfig = ${JSON.stringify(JSON.parse(embed.config))};` : '',
|
||||
embed.token ? `window.replypierToken = ${JSON.stringify(embed.token)};` : '',
|
||||
].filter(Boolean).join('\n ')
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Acme Store</title></head>
|
||||
<body>
|
||||
<h1>Acme Store</h1>
|
||||
${embed.trigger ? '<button type="button" data-replypier-open>Chat with us</button>' : ''}
|
||||
<script>
|
||||
${globals}
|
||||
</script>
|
||||
<script src="${loaderUrl}"></script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`)
|
||||
if (url.pathname === '/health') {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok')
|
||||
return
|
||||
}
|
||||
if (url.pathname !== '/') {
|
||||
res.writeHead(404).end()
|
||||
return
|
||||
}
|
||||
const params = url.searchParams
|
||||
let html: string
|
||||
try {
|
||||
html = page({
|
||||
widgetId: params.get('widget') ?? '',
|
||||
config: params.get('config'),
|
||||
token: params.get('token'),
|
||||
trigger: params.get('trigger') === '1',
|
||||
iframe: params.get('snippet') === 'iframe',
|
||||
})
|
||||
} catch {
|
||||
res.writeHead(400, { 'content-type': 'text/plain' }).end('config is not valid JSON')
|
||||
return
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html)
|
||||
}).listen(port, '127.0.0.1', () => console.log(`[customer-site] http://127.0.0.1:${port}/?widget=<id>`))
|
||||
@@ -0,0 +1,95 @@
|
||||
// What each provider rejects, from its own published limits — deliberately NOT
|
||||
// the backend's constants (backend/app/channels/interactive_limits.py), so a
|
||||
// backend that drifts past a real limit gets the same 400 it would in production.
|
||||
//
|
||||
// Each check returns the provider-style error message, or null when the payload
|
||||
// would be accepted.
|
||||
|
||||
type Body = Record<string, unknown> | null
|
||||
// Provider payloads are walked loosely; typing every Graph/Bot API shape buys nothing here.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Loose = any
|
||||
|
||||
const len = (value: unknown): number => (typeof value === 'string' ? [...value].length : 0)
|
||||
const bytes = (value: unknown): number => (typeof value === 'string' ? Buffer.byteLength(value) : 0)
|
||||
|
||||
function tooLong(field: string, value: unknown, max: number): string | null {
|
||||
return len(value) > max ? `${field} is ${len(value)} characters; the limit is ${max}` : null
|
||||
}
|
||||
|
||||
function first(...checks: Array<string | null>): string | null {
|
||||
return checks.find((c) => c !== null) ?? null
|
||||
}
|
||||
|
||||
// https://developers.facebook.com/docs/whatsapp/cloud-api/messages
|
||||
function whatsapp(body: Body): string | null {
|
||||
if (!body || body.messaging_product !== 'whatsapp' || !body.type) return null // receipts, typing
|
||||
if (body.type === 'text') return tooLong('text.body', (body.text as Body)?.body, 4096)
|
||||
if (body.type !== 'interactive') return null
|
||||
const interactive = body.interactive as Record<string, Loose>
|
||||
const bodyText = tooLong('interactive.body.text', interactive?.body?.text, 1024)
|
||||
if (interactive?.type === 'button') {
|
||||
const buttons: Loose[] = interactive.action?.buttons ?? []
|
||||
if (buttons.length < 1 || buttons.length > 3) return `interactive buttons must number 1-3, got ${buttons.length}`
|
||||
return first(bodyText, ...buttons.map((b) => first(
|
||||
tooLong('button title', b.reply?.title, 20),
|
||||
bytes(b.reply?.id) > 256 ? 'button id is over 256 bytes' : null,
|
||||
)))
|
||||
}
|
||||
if (interactive?.type === 'list') {
|
||||
const sections: Loose[] = interactive.action?.sections ?? []
|
||||
const rows = sections.flatMap((s) => s.rows ?? [])
|
||||
if (sections.length > 10) return `a list allows 10 sections, got ${sections.length}`
|
||||
if (rows.length < 1 || rows.length > 10) return `a list allows 1-10 rows in total, got ${rows.length}`
|
||||
return first(bodyText, tooLong('list button', interactive.action?.button, 20), ...rows.map((r) => first(
|
||||
tooLong('row title', r.title, 24),
|
||||
tooLong('row description', r.description, 72),
|
||||
bytes(r.id) > 200 ? 'row id is over 200 bytes' : null,
|
||||
)))
|
||||
}
|
||||
return bodyText
|
||||
}
|
||||
|
||||
// https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies
|
||||
function pageSend(textMax: number) {
|
||||
return (body: Body): string | null => {
|
||||
const message = body?.message as Record<string, Loose> | undefined
|
||||
if (!message) return null // sender_action (typing)
|
||||
const replies: Loose[] = message.quick_replies ?? []
|
||||
if (replies.length > 13) return `quick_replies allows 13, got ${replies.length}`
|
||||
return first(tooLong('message.text', message.text, textMax), ...replies.map((q) => tooLong('quick reply title', q.title, 20)))
|
||||
}
|
||||
}
|
||||
|
||||
// https://core.telegram.org/bots/api#sendmessage
|
||||
function telegram(action: string, body: Body): string | null {
|
||||
if (action !== 'sendMessage') return null
|
||||
const keyboard: Loose[] = (body?.reply_markup as Record<string, Loose> | undefined)?.inline_keyboard?.flat() ?? []
|
||||
return first(
|
||||
tooLong('text', body?.text, 4096),
|
||||
...keyboard.map((b) => (bytes(b.callback_data) > 64 ? 'callback_data is over 64 bytes' : null)),
|
||||
)
|
||||
}
|
||||
|
||||
// https://developers.line.biz/en/reference/messaging-api/#send-push-message
|
||||
function line(action: string, body: Body): string | null {
|
||||
if (action !== 'push') return null
|
||||
const messages: Loose[] = (body?.messages as Loose[]) ?? []
|
||||
if (messages.length < 1 || messages.length > 5) return `messages must number 1-5, got ${messages.length}`
|
||||
return first(...messages.map((m) => tooLong('text', m.text, 5000)))
|
||||
}
|
||||
|
||||
/** The provider's rejection for this call, or null. `provider` is the fake's path prefix. */
|
||||
export function rejection(provider: string, action: string, method: string, body: Body): string | null {
|
||||
if (method !== 'POST') return null
|
||||
switch (provider) {
|
||||
case 'graph': return action === 'messages' ? (body?.messaging_product ? whatsapp(body) : pageSend(2000)(body)) : null
|
||||
case 'graph-instagram': return action === 'messages' ? pageSend(1000)(body) : null
|
||||
case 'telegram': return telegram(action, body)
|
||||
case 'line': return line(action, body)
|
||||
case 'twilio': return action === 'Messages.json' ? tooLong('Body', body?.Body, 1600) : null
|
||||
case 'plivo': return action === 'Message' ? tooLong('text', body?.text, 1600) : null
|
||||
case 'slack': return action === 'chat.postMessage' || action === 'chat.update' ? tooLong('text', body?.text, 40000) : null
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
// A recording stand-in for the third-party APIs the backend calls on its own:
|
||||
// Telegram, Meta Graph (WhatsApp / Messenger / Instagram), Slack, LINE, SMS
|
||||
// providers, HubSpot, Pipedrive, Jira, Shopify. The backend reaches it through the *_API_BASE overrides in
|
||||
// .env.e2e (see backend/app/core/external_urls.py); each provider is a path prefix.
|
||||
//
|
||||
// Every request is recorded (method, path, query, headers, body, the answer) and
|
||||
// answered like the provider would:
|
||||
// - the smallest success the adapter accepts, by default;
|
||||
// - the provider's own rejection when a payload breaks its published limits
|
||||
// (limits.ts), so a backend that drifts past a real limit fails here too;
|
||||
// - a scripted answer — a failure, or a reply for a provider with no built-in
|
||||
// answers — while one the test registered matches (PUT /__faults).
|
||||
// Tests then assert on what the backend sent ("the bot replied on Telegram with …")
|
||||
// and read values the provider would have learned, such as the webhook secret
|
||||
// Telegram receives in setWebhook.
|
||||
//
|
||||
// Where a provider tells the backend who the account is, the answer is derived
|
||||
// from what the test sent, so every test gets its own account:
|
||||
// Meta tokens `owns:<id>[,<id>…]:<anything>` own those nodes (pages, numbers, IG
|
||||
// accounts). Graph refuses such a token a
|
||||
// node another token owns, and debug_token
|
||||
// reports its first id as the page
|
||||
// Meta tokens `…:app=<id>.<secret>:…` were issued by the customer's own Meta
|
||||
// app: GET /app answers that id and checks
|
||||
// appsecret_proof against that secret (any
|
||||
// other token is ReplyPier's app)
|
||||
// Meta POST /<number> {webhook_configuration} verifies the override callback the way
|
||||
// Meta does — a GET with hub.verify_token
|
||||
// that must echo hub.challenge — before
|
||||
// accepting it
|
||||
// Meta GET /<node> id = <node>
|
||||
// LINE GET /info userId derived from the channel access token
|
||||
// Slack oauth.v2.access team id and bot token derived from the OAuth code
|
||||
// HubSpot oauth/v1/token an access token derived from the code; introspecting it
|
||||
// (oauth/v1/access-tokens/<token>) gives a hub id from it
|
||||
// Pipedrive oauth/token tokens derived from the code, and this fake as the
|
||||
// company's api_domain; users/me gives a company id
|
||||
// derived from the token
|
||||
// Jira authorize consent granted at once: redirects to redirect_uri with a
|
||||
// fresh code and the state; the token is derived from the
|
||||
// code and accessible-resources gives a site derived from it
|
||||
// Shopify orders the customer's order with the number they searched for
|
||||
// (#1001 if none), shipped with a tracking number
|
||||
//
|
||||
// A call's `action` is the provider's method (Telegram, Slack) or else the last
|
||||
// non-empty path segment (`/Account/<id>/Message/` → `Message`).
|
||||
//
|
||||
// Control API (src/fakes/externals.ts):
|
||||
// GET /__calls?provider=&action=&contains=&since= recorded calls, oldest first;
|
||||
// contains = substring of path or body
|
||||
// DELETE /__calls forget every call
|
||||
// PUT /__faults/:id { provider, action?, contains?, status, body?, times? }
|
||||
// DELETE /__faults/:id
|
||||
//
|
||||
// Run: node services/fake-externals/server.ts --port 14020
|
||||
|
||||
import { createHash, createHmac } from 'node:crypto'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { rejection } from './limits.ts'
|
||||
|
||||
export interface RecordedCall {
|
||||
at: string
|
||||
provider: string
|
||||
/** Provider-level operation: Telegram/Slack method name, else the last path segment. */
|
||||
action: string
|
||||
method: string
|
||||
path: string
|
||||
query: Record<string, string>
|
||||
/** Lower-cased request headers — e.g. authorization, to tell which account sent it. */
|
||||
headers: Record<string, string>
|
||||
body: unknown
|
||||
/** HTTP status the fake answered with. */
|
||||
status: number
|
||||
/** What the fake answered — e.g. the message_id Telegram assigned. */
|
||||
response: unknown
|
||||
}
|
||||
|
||||
export interface Fault {
|
||||
provider: string
|
||||
action?: string
|
||||
/** Only calls whose path or body contains this — scope a fault to one test's account or customer. */
|
||||
contains?: string
|
||||
status: number
|
||||
body?: unknown
|
||||
/** How many matching calls fail; omitted = until the fault is deleted. */
|
||||
times?: number
|
||||
}
|
||||
|
||||
const MAX_CALLS = 5000
|
||||
const calls: RecordedCall[] = []
|
||||
const faults = new Map<string, Fault>()
|
||||
/** Every node some `owns:` token has claimed — what other tokens are refused. */
|
||||
const claimed = new Set<string>()
|
||||
let counter = 0
|
||||
|
||||
class HttpError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}) {
|
||||
if (status === 204) {
|
||||
res.writeHead(status, headers)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
res.writeHead(status, { 'content-type': 'application/json', ...headers })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
async function readRaw(req: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||
return Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
|
||||
function parseBody(raw: string, contentType: string): unknown {
|
||||
if (!raw) return null
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) return Object.fromEntries(new URLSearchParams(raw))
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function digest(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
/** A stable numeric id per bot token, so reconnecting the same token finds the same bot. */
|
||||
function botIdFor(token: string): number {
|
||||
return parseInt(digest(token).slice(0, 10), 16)
|
||||
}
|
||||
|
||||
function bearer(headers: Record<string, string>): string {
|
||||
return (headers.authorization ?? '').replace(/^Bearer\s+/i, '')
|
||||
}
|
||||
|
||||
// ---------- per-provider answers ----------
|
||||
|
||||
interface Call {
|
||||
provider: string
|
||||
/** Where the backend reached this fake, e.g. http://127.0.0.1:14020. */
|
||||
origin: string
|
||||
action: string
|
||||
method: string
|
||||
url: URL
|
||||
headers: Record<string, string>
|
||||
body: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface Answer {
|
||||
status: number
|
||||
body: unknown
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
const ok = (body: unknown): Answer => ({ status: 200, body })
|
||||
|
||||
function telegram(action: string, token: string, body: Record<string, unknown> | null): Answer {
|
||||
switch (action) {
|
||||
case 'getMe': {
|
||||
const id = botIdFor(token)
|
||||
return ok({ ok: true, result: { id, is_bot: true, first_name: 'E2E Bot', username: `e2e_bot_${id}` } })
|
||||
}
|
||||
case 'sendMessage':
|
||||
return ok({ ok: true, result: { message_id: counter, date: Math.floor(Date.now() / 1000), chat: { id: body?.chat_id }, text: body?.text } })
|
||||
default:
|
||||
// setWebhook, deleteWebhook, sendChatAction, answerCallbackQuery, editMessageReplyMarkup, …
|
||||
return ok({ ok: true, result: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** The node ids a Meta token owns, or null for a token that makes no claim. */
|
||||
function ownedNodes(token: string): string[] | null {
|
||||
const match = /^owns:([^:]+):/.exec(token)
|
||||
if (!match) return null
|
||||
const nodes = match[1].split(',')
|
||||
for (const node of nodes) claimed.add(node)
|
||||
return nodes
|
||||
}
|
||||
|
||||
function graphError(message: string, code = 100): Answer {
|
||||
return { status: 400, body: { error: { message, type: 'OAuthException', code, fbtrace_id: 'e2e' } } }
|
||||
}
|
||||
|
||||
/** ReplyPier's own Meta app; its secret mirrors META_APP_SECRET in .env.e2e. */
|
||||
const REPLYPIER_APP = { id: 'e2e-replypier-app', secret: 'e2e-meta-app-secret' }
|
||||
|
||||
/** The Meta app that issued a token. */
|
||||
function appOf(token: string): { id: string; secret: string } {
|
||||
const match = /:app=([^.:]+)\.([^:]+)/.exec(token)
|
||||
return match ? { id: match[1], secret: match[2] } : REPLYPIER_APP
|
||||
}
|
||||
|
||||
/** Meta checks an override callback before taking it: the GET must echo the challenge. */
|
||||
async function overrideCallback(config: Record<string, unknown>): Promise<Answer> {
|
||||
const uri = String(config.override_callback_uri ?? '')
|
||||
if (!uri) return ok({ success: true }) // back to the app's own callback
|
||||
const challenge = `e2e-challenge-${counter}`
|
||||
const query = new URLSearchParams({ 'hub.mode': 'subscribe', 'hub.verify_token': String(config.verify_token ?? ''), 'hub.challenge': challenge })
|
||||
const check = await fetch(`${uri}?${query}`, { signal: AbortSignal.timeout(5_000) }).catch(() => null)
|
||||
if (!check || (await check.text()) !== challenge) {
|
||||
return graphError('The callback URL or verify token couldn\'t be validated. Please verify the provided information or try again later.', 2200)
|
||||
}
|
||||
return ok({ success: true })
|
||||
}
|
||||
|
||||
async function graph({ action, method, url, headers, body }: Call): Promise<Answer> {
|
||||
if (action === 'app' && method === 'GET') {
|
||||
const token = bearer(headers)
|
||||
const app = appOf(token)
|
||||
const proof = url.searchParams.get('appsecret_proof')
|
||||
if (proof !== null && proof !== createHmac('sha256', app.secret).update(token).digest('hex')) {
|
||||
return graphError('Invalid appsecret_proof provided in the API argument')
|
||||
}
|
||||
return ok({ id: app.id, name: `E2E app ${app.id}` })
|
||||
}
|
||||
if (action === 'debug_token') {
|
||||
const inspected = ownedNodes(url.searchParams.get('input_token') ?? '')
|
||||
return ok({ data: { is_valid: Boolean(inspected), type: 'PAGE', profile_id: inspected?.[0] ?? '' } })
|
||||
}
|
||||
// `/<provider>/v21.0/<node>[/<edge>]`. A token can't reach a page, number or IG
|
||||
// account another token owns; anything unclaimed (a customer's PSID, `me`) is fine.
|
||||
const owned = ownedNodes(bearer(headers))
|
||||
const node = url.pathname.split('/').filter(Boolean)[2] ?? ''
|
||||
if (owned && claimed.has(node) && !owned.includes(node)) {
|
||||
return graphError(`Unsupported request - object with ID '${node}' does not exist, cannot be loaded due to missing permissions, or does not support this operation`)
|
||||
}
|
||||
const webhookConfiguration = body?.webhook_configuration
|
||||
if (method === 'POST' && webhookConfiguration && typeof webhookConfiguration === 'object') {
|
||||
return overrideCallback(webhookConfiguration as Record<string, unknown>)
|
||||
}
|
||||
if (method !== 'GET') {
|
||||
return ok({ messages: [{ id: `wamid.e2e.${counter}` }], message_id: `m_e2e_${counter}`, recipient_id: body?.recipient ?? null, success: true })
|
||||
}
|
||||
return ok({ id: action, name: `E2E ${action}`, username: `e2e_${action}`, data: [] })
|
||||
}
|
||||
|
||||
function answer(call: Call): Answer | Promise<Answer> {
|
||||
const { provider, action, headers, body } = call
|
||||
switch (provider) {
|
||||
case 'graph':
|
||||
case 'graph-instagram':
|
||||
return graph(call)
|
||||
case 'slack':
|
||||
if (action === 'oauth.v2.access') {
|
||||
const code = String(body?.code ?? '')
|
||||
return ok({ ok: true, access_token: `xoxb-e2e-${digest(code).slice(0, 12)}`, bot_user_id: 'U0E2EBOT', team: { id: `T${digest(code).slice(0, 10).toUpperCase()}`, name: 'E2E Workspace' } })
|
||||
}
|
||||
return ok({ ok: true, ts: `${Math.floor(Date.now() / 1000)}.${String(counter).padStart(6, '0')}`, channel: body?.channel ?? 'C0E2E' })
|
||||
case 'line':
|
||||
// GET /info is the bot's own profile; everything else (push, webhook endpoint) answers an empty 200.
|
||||
return ok(action === 'info' ? { userId: `U${digest(bearer(headers)).slice(0, 32)}`, displayName: 'E2E LINE Bot', basicId: '@e2e' } : {})
|
||||
case 'twilio':
|
||||
return ok({ sid: `SM${String(counter).padStart(32, '0')}`, status: 'queued' })
|
||||
case 'plivo':
|
||||
return ok({ message_uuid: [`e2e-${counter}`], api_id: `e2e-${counter}` })
|
||||
case 'hubspot':
|
||||
return hubspot(call)
|
||||
case 'pipedrive-oauth':
|
||||
return pipedriveOauth(call)
|
||||
case 'pipedrive':
|
||||
return pipedrive(call)
|
||||
case 'jira-auth':
|
||||
return jiraAuth(call)
|
||||
case 'jira':
|
||||
return jira(call)
|
||||
case 'shopify':
|
||||
return shopify(call)
|
||||
default:
|
||||
return ok({})
|
||||
}
|
||||
}
|
||||
|
||||
function hubspot({ method, url, body }: Call): Answer {
|
||||
const path = url.pathname
|
||||
if (path.endsWith('/oauth/v1/token')) {
|
||||
const seed = String(body?.code ?? body?.refresh_token ?? '')
|
||||
return ok({ access_token: `hs-${digest(seed).slice(0, 16)}`, refresh_token: `hs-refresh-${digest(seed).slice(0, 8)}`, expires_in: 1800, token_type: 'bearer' })
|
||||
}
|
||||
const introspected = /\/oauth\/v1\/access-tokens\/([^/]+)$/.exec(path)
|
||||
if (introspected) return ok({ hub_id: parseInt(digest(introspected[1]).slice(0, 7), 16), hub_domain: 'e2e.hubspot.com', token: introspected[1] })
|
||||
if (path.endsWith('/contacts/batch/upsert')) return ok({ status: 'COMPLETE', results: [{ id: String(counter), new: true }] })
|
||||
return ok(method === 'GET' ? { results: [] } : { id: String(counter) })
|
||||
}
|
||||
|
||||
/** Insert-ordered, so the oldest entry goes first once a map outgrows `max`. */
|
||||
function remember<V>(map: Map<string, V>, key: string, value: V, max = 1000) {
|
||||
map.set(key, value)
|
||||
if (map.size > max) map.delete(map.keys().next().value as string)
|
||||
}
|
||||
|
||||
// Pipedrive: OAuth on pipedrive-oauth (oauth.pipedrive.com); the API on the
|
||||
// company's own host, which the token response names — here, this fake.
|
||||
|
||||
/** Consent granted at once: back to the app with a fresh code and the state. */
|
||||
function consentRedirect(url: URL): Answer {
|
||||
const redirect = url.searchParams.get('redirect_uri')
|
||||
if (!redirect) return { status: 400, body: { error: 'invalid_request', error_description: 'redirect_uri is required' } }
|
||||
const back = new URL(redirect)
|
||||
back.searchParams.set('code', `code-${counter}`)
|
||||
back.searchParams.set('state', url.searchParams.get('state') ?? '')
|
||||
return { status: 302, body: {}, headers: { location: back.toString() } }
|
||||
}
|
||||
|
||||
function pipedriveOauth({ action, url, origin, body }: Call): Answer {
|
||||
if (action === 'authorize') return consentRedirect(url)
|
||||
if (action === 'revoke') return ok({})
|
||||
const seed = String(body?.code ?? body?.refresh_token ?? '')
|
||||
return ok({
|
||||
access_token: `pd-${digest(seed).slice(0, 16)}`, refresh_token: `pd-refresh-${digest(seed).slice(0, 8)}`,
|
||||
token_type: 'Bearer', expires_in: 3600, scope: 'base,contacts:full,leads:full', api_domain: `${origin}/pipedrive`,
|
||||
})
|
||||
}
|
||||
|
||||
function pipedrive({ method, url, headers, body }: Call): Answer {
|
||||
const path = url.pathname.replace(/^\/pipedrive/, '')
|
||||
if (path === '/api/v1/users/me') {
|
||||
return ok({ success: true, data: { id: 1, company_id: parseInt(digest(bearer(headers)).slice(0, 7), 16), company_name: 'E2E Pipedrive Co' } })
|
||||
}
|
||||
// Nothing exists yet: searches find nobody, and every create gets a fresh id.
|
||||
if (path.endsWith('/search')) return ok({ success: true, data: { items: [] } })
|
||||
if (path === '/api/v1/leads' && method === 'GET') return ok({ success: true, data: [] })
|
||||
if (method === 'POST' && path === '/api/v1/leads') return ok({ success: true, data: { id: `00000000-0000-4000-8000-${String(counter).padStart(12, '0')}`, title: body?.title } })
|
||||
if (method === 'POST' || method === 'PATCH') return ok({ success: true, data: { id: counter, ...(body ?? {}) } })
|
||||
return { status: 404, body: { success: false, error: `e2e fake has no answer for ${method} ${path}` } }
|
||||
}
|
||||
|
||||
// Jira Cloud: OAuth on jira-auth (auth.atlassian.com), the REST API on jira (api.atlassian.com).
|
||||
|
||||
function jiraAuth({ action, url, body }: Call): Answer {
|
||||
if (action === 'authorize') return consentRedirect(url)
|
||||
// oauth/token, for both the code grant and a refresh.
|
||||
const seed = String(body?.code ?? body?.refresh_token ?? '')
|
||||
return ok({ access_token: `jira-${digest(seed).slice(0, 16)}`, refresh_token: `jira-refresh-${digest(seed).slice(0, 8)}`, token_type: 'Bearer', expires_in: 3600, scope: 'read:jira-work write:jira-work read:jira-user offline_access' })
|
||||
}
|
||||
|
||||
const JIRA_PRIORITIES = ['Highest', 'High', 'Medium', 'Low', 'Lowest'].map((name, i) => ({ id: String(i + 1), name }))
|
||||
const JIRA_ISSUE_TYPES = [
|
||||
{ id: '10001', name: 'Task', subtask: false, description: 'A small, distinct piece of work.' },
|
||||
{ id: '10002', name: 'Sub-task', subtask: true, description: 'A part of a task.' },
|
||||
]
|
||||
const jiraIssues = new Map<string, { key: string; fields: Record<string, unknown> }>()
|
||||
|
||||
function jiraNotFound(message: string): Answer {
|
||||
return { status: 404, body: { errorMessages: [message], errors: {} } }
|
||||
}
|
||||
|
||||
function jira({ method, url, headers, body }: Call): Answer {
|
||||
if (url.pathname.endsWith('/oauth/token/accessible-resources')) {
|
||||
const site = digest(bearer(headers)).slice(0, 12)
|
||||
return ok([{ id: `cloud-${site}`, url: `https://e2e-${site}.atlassian.net`, name: `e2e-${site}`, scopes: ['read:jira-work', 'write:jira-work'], avatarUrl: '' }])
|
||||
}
|
||||
// /jira/ex/jira/<cloud id>/rest/api/3/<resource>
|
||||
const resource = /\/ex\/jira\/[^/]+\/rest\/api\/3\/(.+)$/.exec(url.pathname)?.[1] ?? ''
|
||||
if (resource === 'project') return ok([{ id: '10000', key: 'SUP', name: 'Support' }])
|
||||
if (resource.startsWith('project/')) return ok({ id: '10000', key: resource.slice('project/'.length), name: 'Support', issueTypes: JIRA_ISSUE_TYPES })
|
||||
if (resource === 'priority') return ok(JIRA_PRIORITIES)
|
||||
if (resource === 'issue/createmeta') {
|
||||
return ok({ projects: [{ key: url.searchParams.get('projectKeys'), issuetypes: [{ id: url.searchParams.get('issuetypeIds'), fields: { summary: {}, description: {}, priority: {} } }] }] })
|
||||
}
|
||||
if (resource === 'issue' && method === 'POST') {
|
||||
const fields = (body?.fields ?? {}) as { project?: { key?: string } }
|
||||
const key = `${fields.project?.key ?? 'SUP'}-${counter}`
|
||||
remember(jiraIssues, key, { key, fields: { ...fields, status: { name: 'To Do' } } })
|
||||
return { status: 201, body: { id: String(counter), key, self: `${url.origin}${url.pathname}/${counter}` } }
|
||||
}
|
||||
const issueKey = /^issue\/([^/]+)$/.exec(resource)?.[1]
|
||||
if (issueKey) {
|
||||
const issue = jiraIssues.get(issueKey)
|
||||
if (!issue) return jiraNotFound('Issue does not exist or you do not have permission to see it.')
|
||||
if (method === 'PUT') {
|
||||
Object.assign(issue.fields, (body?.fields ?? {}) as Record<string, unknown>)
|
||||
return { status: 204, body: null }
|
||||
}
|
||||
return ok({ id: issueKey, ...issue })
|
||||
}
|
||||
return jiraNotFound(`e2e fake has no answer for ${method} ${resource}`)
|
||||
}
|
||||
|
||||
// Shopify Admin API: /shopify/<shop>.myshopify.com/admin/...
|
||||
|
||||
interface ShopifyOrder { id: string; number: string; email: string }
|
||||
const shopifyOrders = new Map<string, ShopifyOrder>()
|
||||
|
||||
function money(amount: string) {
|
||||
return { shopMoney: { amount, currencyCode: 'USD' } }
|
||||
}
|
||||
|
||||
function shopifyOrderNode(order: ShopifyOrder) {
|
||||
return {
|
||||
id: `gid://shopify/Order/${order.id}`,
|
||||
name: `#${order.number}`,
|
||||
email: order.email,
|
||||
phone: null,
|
||||
createdAt: '2026-10-01T10:00:00Z',
|
||||
processedAt: '2026-10-01T10:00:00Z',
|
||||
updatedAt: '2026-10-02T10:00:00Z',
|
||||
cancelledAt: null,
|
||||
cancelReason: null,
|
||||
displayFinancialStatus: 'PAID',
|
||||
displayFulfillmentStatus: 'FULFILLED',
|
||||
subtotalLineItemsQuantity: 1,
|
||||
currentSubtotalPriceSet: money('40.00'),
|
||||
currentTotalPriceSet: money('45.00'),
|
||||
originalTotalPriceSet: money('45.00'),
|
||||
customer: { id: `gid://shopify/Customer/${order.id}`, firstName: 'E2E', lastName: 'Customer', email: order.email, phone: null },
|
||||
shippingAddress: { address1: '1 Test Street', address2: null, city: 'Testville', provinceCode: 'CA', zip: '94000', country: 'United States', phone: null, name: 'E2E Customer' },
|
||||
fulfillments: [{ status: 'SUCCESS', trackingInfo: [{ company: 'UPS', number: `1ZE2E${order.id}`, url: `https://tracking.e2e.test/1ZE2E${order.id}` }] }],
|
||||
lineItems: { edges: [{ node: { id: `gid://shopify/LineItem/${order.id}`, title: 'E2E Mug', name: 'E2E Mug', quantity: 1, originalUnitPriceSet: money('40.00'), variant: { id: 'gid://shopify/ProductVariant/1', title: 'Default', sku: 'E2E-MUG', image: null } } }] },
|
||||
}
|
||||
}
|
||||
|
||||
function shopify({ action, body }: Call): Answer {
|
||||
if (action === 'access_token') {
|
||||
const seed = String(body?.subject_token ?? body?.code ?? '')
|
||||
return ok({ access_token: `shpat_${digest(seed).slice(0, 24)}`, scope: 'read_orders,read_products' })
|
||||
}
|
||||
const query = String(body?.query ?? '')
|
||||
const variables = (body?.variables ?? {}) as Record<string, unknown>
|
||||
if (query.includes('query SearchOrders')) {
|
||||
// `email:<email>[ AND name:<number>]`, as the backend builds it.
|
||||
const search = String(variables.query ?? '')
|
||||
const email = /email:(\S+)/.exec(search)?.[1] ?? ''
|
||||
const number = /name:(\S+)/.exec(search)?.[1] ?? '1001'
|
||||
const order = { id: String(parseInt(digest(`${email}#${number}`).slice(0, 8), 16)), number, email }
|
||||
remember(shopifyOrders, order.id, order)
|
||||
return ok({ data: { orders: { edges: email ? [{ node: shopifyOrderNode(order) }] : [], pageInfo: { hasNextPage: false, endCursor: null } } } })
|
||||
}
|
||||
if (query.includes('query GetOrder')) {
|
||||
const order = shopifyOrders.get(String(variables.id ?? '').split('/').at(-1) ?? '')
|
||||
return ok({ data: { order: order ? shopifyOrderNode(order) : null } })
|
||||
}
|
||||
return ok({ errors: [{ message: 'e2e fake has no answer for this query' }] })
|
||||
}
|
||||
|
||||
/** How each provider says "bad request", so the backend's error handling sees the real shape. */
|
||||
function providerRejection(provider: string, message: string): Answer {
|
||||
switch (provider) {
|
||||
case 'graph':
|
||||
case 'graph-instagram': return graphError(message)
|
||||
case 'telegram': return { status: 400, body: { ok: false, error_code: 400, description: `Bad Request: ${message}` } }
|
||||
case 'slack': return ok({ ok: false, error: 'msg_too_long', detail: message })
|
||||
case 'twilio': return { status: 400, body: { code: 21617, message, status: 400 } }
|
||||
default: return { status: 400, body: { message, error: message } }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- faults ----------
|
||||
|
||||
function matchingFault(provider: string, action: string, haystack: string): Fault | undefined {
|
||||
for (const [id, fault] of faults) {
|
||||
if (fault.provider !== provider) continue
|
||||
if (fault.action && fault.action !== action) continue
|
||||
if (fault.contains && !haystack.includes(fault.contains)) continue
|
||||
if (fault.times !== undefined) {
|
||||
fault.times -= 1
|
||||
if (fault.times <= 0) faults.delete(id)
|
||||
}
|
||||
return fault
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------- http ----------
|
||||
|
||||
async function route(req: IncomingMessage, res: ServerResponse) {
|
||||
const url = new URL(req.url ?? '/', 'http://fake-externals')
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/health') return send(res, 200, { status: 'ok', calls: calls.length, faults: faults.size })
|
||||
if (path === '/__calls' && req.method === 'GET') {
|
||||
const q = url.searchParams
|
||||
const since = q.get('since')
|
||||
const contains = q.get('contains')
|
||||
return send(res, 200, calls.filter((c) =>
|
||||
(!q.get('provider') || c.provider === q.get('provider'))
|
||||
&& (!q.get('action') || c.action === q.get('action'))
|
||||
&& (!since || c.at >= since)
|
||||
&& (!contains || `${c.path}\n${JSON.stringify(c.body)}`.includes(contains))))
|
||||
}
|
||||
if (path === '/__calls' && req.method === 'DELETE') {
|
||||
calls.length = 0
|
||||
return send(res, 200, { ok: true })
|
||||
}
|
||||
const faultPath = /^\/__faults\/([\w.-]+)$/.exec(path)
|
||||
if (faultPath && req.method === 'PUT') {
|
||||
const fault = parseBody(await readRaw(req), 'application/json') as Fault | null
|
||||
if (!fault?.provider || !fault.status) throw new HttpError(400, 'a fault needs provider and status')
|
||||
faults.set(faultPath[1], fault)
|
||||
return send(res, 200, { ok: true })
|
||||
}
|
||||
if (faultPath && req.method === 'DELETE') {
|
||||
faults.delete(faultPath[1])
|
||||
return send(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
const [, provider = '', ...rest] = path.split('/')
|
||||
const raw = await readRaw(req)
|
||||
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v ?? '']))
|
||||
const body = parseBody(raw, headers['content-type'] ?? '') as Record<string, unknown> | null
|
||||
const method = req.method ?? 'GET'
|
||||
counter += 1
|
||||
|
||||
let action = rest.filter(Boolean).at(-1) ?? ''
|
||||
let token = ''
|
||||
if (provider === 'telegram') {
|
||||
// /telegram/bot<token>/<method>
|
||||
token = (rest[0] ?? '').replace(/^bot/, '')
|
||||
action = rest[1] ?? ''
|
||||
}
|
||||
|
||||
const fault = matchingFault(provider, action, `${path}\n${raw}`)
|
||||
const rejected = fault ? null : rejection(provider, action, method, body)
|
||||
let result: Answer
|
||||
if (fault) result = { status: fault.status, body: fault.body ?? {} }
|
||||
else if (rejected) result = providerRejection(provider, rejected)
|
||||
else if (provider === 'telegram') result = telegram(action, token, body)
|
||||
else result = await answer({ provider, origin: `http://${headers.host ?? '127.0.0.1'}`, action, method, url, headers, body })
|
||||
|
||||
calls.push({
|
||||
at: new Date().toISOString(),
|
||||
provider, action, method, path,
|
||||
query: Object.fromEntries(url.searchParams),
|
||||
headers, body,
|
||||
status: result.status,
|
||||
response: result.body,
|
||||
})
|
||||
if (calls.length > MAX_CALLS) calls.splice(0, calls.length - MAX_CALLS)
|
||||
return send(res, result.status, result.body, result.headers)
|
||||
}
|
||||
|
||||
const portArg = process.argv.indexOf('--port')
|
||||
const port = Number(portArg > -1 ? process.argv[portArg + 1] : 14020)
|
||||
|
||||
createServer((req, res) => {
|
||||
route(req, res).catch((err: unknown) => {
|
||||
const status = err instanceof HttpError ? err.status : 500
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[fake-externals] ${req.method} ${req.url} -> ${status}: ${message}`)
|
||||
send(res, status, { error: message })
|
||||
})
|
||||
}).listen(port, '127.0.0.1', () => console.log(`[fake-externals] listening on http://127.0.0.1:${port}`))
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Checks the fake LLM against the backend's real agno code path.
|
||||
|
||||
The e2e suite only works if agno accepts what the fake returns. This runs the same
|
||||
calls the backend makes — the AI-setup key check and a ChatAgent-style structured
|
||||
reply — through agno against a running fake, so an agno upgrade that changes the
|
||||
request or response shape fails here with a clear message instead of as flaky e2e
|
||||
tests. CI runs it right after the stack starts.
|
||||
|
||||
cd backend && PYTHONPATH=. ENV_FILE=../e2e/.env.e2e NO_ENTERPRISE=1 \\
|
||||
python ../e2e/services/fake-llm/contract_check.py [base_url]
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
from agno.agent import Agent
|
||||
|
||||
from app.models.schemas.chat import ChatResponse
|
||||
from app.utils.agno_utils import create_model, test_model_api_key
|
||||
|
||||
BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:14010/v1"
|
||||
CONTROL = BASE_URL.removesuffix("/v1")
|
||||
SCENARIO = "contract-check"
|
||||
|
||||
|
||||
def put_scenario() -> None:
|
||||
spec = {"rules": [
|
||||
{"match": {"source": "human", "flags": "i"},
|
||||
"reply": {"message": "Connecting you.", "fields": {"transfer_to_human": True}}},
|
||||
{"reply": {"message": "Happy to help!"}},
|
||||
]}
|
||||
req = urllib.request.Request(f"{CONTROL}/__scenarios/{SCENARIO}", data=json.dumps(spec).encode(),
|
||||
method="PUT", headers={"content-type": "application/json"})
|
||||
urllib.request.urlopen(req).read()
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
put_scenario()
|
||||
assert await test_model_api_key("e2e-key", "OPENAI_COMPATIBLE", "e2e-fake", base_url=BASE_URL), \
|
||||
"AI-setup key check failed against the fake"
|
||||
|
||||
model = create_model("OPENAI_COMPATIBLE", "e2e-key", "e2e-fake", max_tokens=1000, base_url=BASE_URL)
|
||||
agent = Agent(model=model, instructions=f"Contract check. [[e2e:{SCENARIO}]]",
|
||||
response_model=ChatResponse, structured_outputs=True, system_message_role="system")
|
||||
|
||||
handover = (await agent.arun("I want a human")).content
|
||||
assert isinstance(handover, ChatResponse), f"structured reply not parsed: {handover!r}"
|
||||
assert handover.transfer_to_human is True and handover.message == "Connecting you.", handover
|
||||
|
||||
plain = (await agent.arun("hello")).content
|
||||
assert plain.transfer_to_human is False and plain.message == "Happy to help!", plain
|
||||
|
||||
print("fake LLM contract OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,77 @@
|
||||
// Wire types shared by the fake LLM server and the test-side client (src/fakes/llm.ts).
|
||||
// Type-only: the server imports this with `import type`, so Node's type stripping
|
||||
// needs nothing at runtime.
|
||||
|
||||
/** A serialisable RegExp. */
|
||||
export interface Pattern {
|
||||
source: string
|
||||
flags: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What the model "says" for one matched turn.
|
||||
*
|
||||
* - `message` / `fields` fill a structured (json_schema) reply — e.g. ChatAgent's
|
||||
* ChatResponse. `fields` overlays any schema field: transfer_to_human, create_ticket, …
|
||||
* - `text` is used for plain-text requests (the transfer agent, the key check).
|
||||
* Falls back to `message`.
|
||||
*/
|
||||
export interface Reply {
|
||||
message?: string
|
||||
fields?: Record<string, unknown>
|
||||
text?: string
|
||||
/** Token counts to report, for metering. Default 1/1. */
|
||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||
}
|
||||
|
||||
/** How the turn goes wrong instead of (or before) answering. */
|
||||
export interface Fault {
|
||||
/** Answer with this HTTP status and an OpenAI-style error instead of a completion. */
|
||||
status?: number
|
||||
message?: string
|
||||
/** Wait this long first — past AGENT_RUN_TIMEOUT to exercise the backend's timeout. */
|
||||
delayMs?: number
|
||||
/** Return content that is not the JSON the request's schema asks for. */
|
||||
malformed?: boolean
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
name: string
|
||||
arguments: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ScenarioRule {
|
||||
/** Tested against the last user message. Omitted = matches anything (otherwise). */
|
||||
match?: Pattern
|
||||
/** Ask the backend to run a tool first; `reply` is sent after the tool result comes back. */
|
||||
toolCall?: ToolCall
|
||||
reply: Reply
|
||||
fault?: Fault
|
||||
}
|
||||
|
||||
export interface ScenarioSpec {
|
||||
/**
|
||||
* Some backend calls never see the agent's instructions (the transfer agent's prompt
|
||||
* has no `[[e2e:<id>]]` marker), so a scenario can also match on the unique agent name.
|
||||
*/
|
||||
agentName?: string
|
||||
rules: ScenarioRule[]
|
||||
}
|
||||
|
||||
export interface RecordedRequest {
|
||||
at: string
|
||||
system: string
|
||||
lastUser: string
|
||||
lastRole: string
|
||||
/** 'json_schema:<name>' | 'json_object' | null */
|
||||
responseFormat: string | null
|
||||
tools: string[]
|
||||
/** Contents of the tool-result messages in this request — e.g. what knowledge search returned. */
|
||||
toolResults: string[]
|
||||
/** What the fake answered: 'tool_call:<name>' | 'structured' | 'text' | 'error:<status>' | 'malformed' */
|
||||
answeredWith: string
|
||||
/** The model the backend asked for. */
|
||||
model: string
|
||||
/** The last 6 characters of the API key it sent — which key the org's config used. */
|
||||
keySuffix: string
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// A deterministic stand-in for an OpenAI-compatible model, for e2e tests.
|
||||
//
|
||||
// The backend's agents are pointed here (AI config model_type OPENAI_COMPATIBLE,
|
||||
// base_url http://127.0.0.1:14010/v1). Each test registers a *scenario* — scripted
|
||||
// replies keyed by a `[[e2e:<id>]]` marker in its agent's instructions or by the
|
||||
// agent's unique name — so parallel tests in one org never see each other's script.
|
||||
//
|
||||
// It speaks only what ReplyPier uses: non-streaming /v1/chat/completions, with or
|
||||
// without a strict json_schema response_format (ChatAgent's ChatResponse), and tool
|
||||
// calls. Anything else fails loudly instead of returning something plausible.
|
||||
//
|
||||
// Control API (used by src/fakes/llm.ts):
|
||||
// PUT /__scenarios/:id register/replace a ScenarioSpec
|
||||
// GET /__scenarios/:id/requests what the backend sent for that scenario
|
||||
// DELETE /__scenarios forget everything (manual use only: scenario
|
||||
// ids are unique per test, and a reset would
|
||||
// wipe any other run sharing this fake)
|
||||
//
|
||||
// An API key starting with `e2e-invalid` is rejected with 401, like a real provider.
|
||||
// A rule can also fail on purpose (fault: status, delay, malformed output).
|
||||
//
|
||||
// Run: node services/fake-llm/server.ts --port 14010 (Node >= 22.18 strips the types)
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import type { Fault, RecordedRequest, Reply, ScenarioRule, ScenarioSpec } from './protocol.ts'
|
||||
|
||||
type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
|
||||
// JSON Schema is walked generically (properties, $ref, anyOf, …); typing every
|
||||
// keyword buys nothing here.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type JsonSchema = { [key: string]: any }
|
||||
|
||||
interface ChatMessage {
|
||||
role: string
|
||||
content?: string | Array<{ type: string; text?: string }> | null
|
||||
tool_calls?: unknown[]
|
||||
}
|
||||
|
||||
interface ChatRequest {
|
||||
model?: string
|
||||
messages: ChatMessage[]
|
||||
stream?: boolean
|
||||
response_format?: { type: string; json_schema?: { name?: string; schema?: JsonSchema } }
|
||||
tools?: Array<{ type: string; function: { name: string } }>
|
||||
}
|
||||
|
||||
const MARKER = /\[\[e2e:([\w-]+)\]\]/
|
||||
const DEFAULT_SCENARIO = '__default'
|
||||
const DEFAULT_REPLY: Reply = { message: 'Default reply from the e2e fake LLM.', text: 'OK' }
|
||||
const MAX_RECORDED = 50
|
||||
// Oldest scenarios are forgotten past this, so a fake that outlives many runs stays small.
|
||||
const MAX_SCENARIOS = 5000
|
||||
|
||||
const scenarios = new Map<string, ScenarioSpec>()
|
||||
const recorded = new Map<string, RecordedRequest[]>()
|
||||
|
||||
// ---------- request helpers ----------
|
||||
|
||||
function textOf(content: ChatMessage['content']): string {
|
||||
if (typeof content === 'string') return content
|
||||
if (Array.isArray(content)) return content.map((part) => part.text ?? '').join('\n')
|
||||
return ''
|
||||
}
|
||||
|
||||
function selectScenario(req: ChatRequest): string {
|
||||
const all = req.messages.map((m) => textOf(m.content)).join('\n')
|
||||
const marker = MARKER.exec(all)
|
||||
if (marker && scenarios.has(marker[1])) return marker[1]
|
||||
for (const [id, spec] of scenarios) {
|
||||
if (spec.agentName && all.includes(spec.agentName)) return id
|
||||
}
|
||||
return DEFAULT_SCENARIO
|
||||
}
|
||||
|
||||
function selectRule(spec: ScenarioSpec | undefined, lastUser: string): ScenarioRule | undefined {
|
||||
if (!spec) return undefined
|
||||
return spec.rules.find((rule) => !rule.match || new RegExp(rule.match.source, rule.match.flags).test(lastUser))
|
||||
}
|
||||
|
||||
// ---------- structured output ----------
|
||||
|
||||
function resolveRef(schema: JsonSchema, root: JsonSchema): JsonSchema {
|
||||
if (typeof schema.$ref !== 'string') return schema
|
||||
const path = schema.$ref.replace(/^#\//, '').split('/')
|
||||
return path.reduce((node: JsonSchema, key: string) => node?.[key], root) ?? {}
|
||||
}
|
||||
|
||||
function isNullable(schema: JsonSchema): boolean {
|
||||
if (Array.isArray(schema.type)) return schema.type.includes('null')
|
||||
const variants = schema.anyOf ?? schema.oneOf
|
||||
return Array.isArray(variants) && variants.some((v: JsonSchema) => v.type === 'null')
|
||||
}
|
||||
|
||||
/** The minimal value that satisfies `schema` — the base every structured reply is built on. */
|
||||
function defaultFor(rawSchema: JsonSchema, root: JsonSchema): Json {
|
||||
const schema = resolveRef(rawSchema, root)
|
||||
if ('default' in schema) return schema.default as Json
|
||||
if (isNullable(schema)) return null
|
||||
if (Array.isArray(schema.enum)) return schema.enum[0] as Json
|
||||
const variants = schema.anyOf ?? schema.oneOf ?? schema.allOf
|
||||
if (Array.isArray(variants) && variants.length) return defaultFor(variants[0], root)
|
||||
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
|
||||
switch (type) {
|
||||
case 'string': return ''
|
||||
case 'boolean': return false
|
||||
case 'number':
|
||||
case 'integer': return 0
|
||||
case 'array': return []
|
||||
case 'object':
|
||||
default: {
|
||||
const out: { [key: string]: Json } = {}
|
||||
for (const [key, prop] of Object.entries<JsonSchema>(schema.properties ?? {})) out[key] = defaultFor(prop, root)
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function structuredContent(req: ChatRequest, reply: Reply): string {
|
||||
const schema = req.response_format?.json_schema?.schema
|
||||
const base = schema ? (defaultFor(schema, schema) as { [key: string]: Json }) : {}
|
||||
const message = reply.message ?? DEFAULT_REPLY.message
|
||||
return JSON.stringify({ ...base, message, ...(reply.fields as { [key: string]: Json } | undefined) })
|
||||
}
|
||||
|
||||
// ---------- completion ----------
|
||||
|
||||
function completion(req: ChatRequest, message: { content: string | null; tool_calls?: unknown[] }, usage = { prompt_tokens: 1, completion_tokens: 1 }) {
|
||||
return {
|
||||
id: `chatcmpl-e2e-${Date.now()}`,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: req.model ?? 'e2e-fake',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', ...message },
|
||||
finish_reason: message.tool_calls ? 'tool_calls' : 'stop',
|
||||
}],
|
||||
usage: { ...usage, total_tokens: usage.prompt_tokens + usage.completion_tokens },
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/** A scripted failure: an OpenAI-style error status, or garbage where JSON was asked for. */
|
||||
function faulted(req: ChatRequest, fault: Fault, record: RecordedRequest) {
|
||||
if (fault.status) {
|
||||
record.answeredWith = `error:${fault.status}`
|
||||
throw new HttpError(fault.status, fault.message ?? 'The e2e fake LLM failed on purpose.')
|
||||
}
|
||||
record.answeredWith = 'malformed'
|
||||
return completion(req, { content: '{"message": "this reply is cut off' })
|
||||
}
|
||||
|
||||
async function answer(req: ChatRequest, authorization: string) {
|
||||
const scenarioId = selectScenario(req)
|
||||
const lastUserMsg = [...req.messages].reverse().find((m) => m.role === 'user')
|
||||
const lastUser = textOf(lastUserMsg?.content)
|
||||
const lastRole = req.messages.at(-1)?.role ?? ''
|
||||
const rule = selectRule(scenarios.get(scenarioId), lastUser)
|
||||
const tools = (req.tools ?? []).map((t) => t.function.name)
|
||||
const format = req.response_format
|
||||
const record: RecordedRequest = {
|
||||
at: new Date().toISOString(),
|
||||
system: req.messages.filter((m) => m.role === 'system' || m.role === 'developer').map((m) => textOf(m.content)).join('\n'),
|
||||
lastUser,
|
||||
lastRole,
|
||||
responseFormat: format ? (format.type === 'json_schema' ? `json_schema:${format.json_schema?.name ?? ''}` : format.type) : null,
|
||||
tools,
|
||||
toolResults: req.messages.filter((m) => m.role === 'tool').map((m) => textOf(m.content)),
|
||||
answeredWith: '',
|
||||
model: req.model ?? '',
|
||||
keySuffix: authorization.replace(/^Bearer\s+/i, '').slice(-6),
|
||||
}
|
||||
const list = recorded.get(scenarioId) ?? []
|
||||
list.push(record)
|
||||
recorded.set(scenarioId, list.slice(-MAX_RECORDED))
|
||||
|
||||
if (rule?.fault?.delayMs) await sleep(rule.fault.delayMs)
|
||||
if (rule?.fault && (rule.fault.status || rule.fault.malformed)) return faulted(req, rule.fault, record)
|
||||
|
||||
const usage = rule?.reply.usage
|
||||
let body
|
||||
if (rule?.toolCall && lastRole !== 'tool') {
|
||||
if (!tools.includes(rule.toolCall.name)) {
|
||||
throw new HttpError(422, `scenario '${scenarioId}' wants tool '${rule.toolCall.name}' but the request only offers [${tools.join(', ')}]`)
|
||||
}
|
||||
record.answeredWith = `tool_call:${rule.toolCall.name}`
|
||||
body = completion(req, {
|
||||
content: null,
|
||||
tool_calls: [{
|
||||
id: `call_e2e_${Date.now()}`,
|
||||
type: 'function',
|
||||
function: { name: rule.toolCall.name, arguments: JSON.stringify(rule.toolCall.arguments) },
|
||||
}],
|
||||
})
|
||||
} else if (format && format.type !== 'text') {
|
||||
record.answeredWith = 'structured'
|
||||
body = completion(req, { content: structuredContent(req, rule?.reply ?? DEFAULT_REPLY) }, usage)
|
||||
} else {
|
||||
const reply = rule?.reply ?? DEFAULT_REPLY
|
||||
record.answeredWith = 'text'
|
||||
body = completion(req, { content: reply.text ?? reply.message ?? DEFAULT_REPLY.text! }, usage)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// ---------- http ----------
|
||||
|
||||
class HttpError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||
const raw = Buffer.concat(chunks).toString('utf8')
|
||||
if (!raw) return {}
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
throw new HttpError(400, 'body is not JSON')
|
||||
}
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown) {
|
||||
res.writeHead(status, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
async function route(req: IncomingMessage, res: ServerResponse) {
|
||||
const url = new URL(req.url ?? '/', 'http://fake-llm')
|
||||
const path = url.pathname.replace(/\/+$/, '')
|
||||
const scenarioPath = /^\/__scenarios\/([\w-]+)(\/requests)?$/.exec(path)
|
||||
|
||||
if (req.method === 'GET' && path === '/health') return send(res, 200, { status: 'ok', scenarios: scenarios.size })
|
||||
if (req.method === 'GET' && path === '/v1/models') return send(res, 200, { object: 'list', data: [{ id: 'e2e-fake', object: 'model' }] })
|
||||
|
||||
if (req.method === 'POST' && path === '/v1/chat/completions') {
|
||||
// Lets a test exercise the "invalid API key" path: the backend live-tests keys.
|
||||
if (/^Bearer e2e-invalid/.test(req.headers.authorization ?? '')) {
|
||||
throw new HttpError(401, 'Incorrect API key provided.')
|
||||
}
|
||||
const body = (await readJson(req)) as ChatRequest
|
||||
if (body.stream) throw new HttpError(400, 'the e2e fake LLM does not stream — ReplyPier never asks it to; if it does now, extend services/fake-llm')
|
||||
if (!Array.isArray(body.messages)) throw new HttpError(400, 'messages[] is required')
|
||||
return send(res, 200, await answer(body, req.headers.authorization ?? ''))
|
||||
}
|
||||
|
||||
if (scenarioPath && req.method === 'PUT' && !scenarioPath[2]) {
|
||||
const spec = (await readJson(req)) as ScenarioSpec
|
||||
if (!Array.isArray(spec.rules)) throw new HttpError(400, 'rules[] is required')
|
||||
scenarios.set(scenarioPath[1], spec)
|
||||
recorded.delete(scenarioPath[1])
|
||||
while (scenarios.size > MAX_SCENARIOS) {
|
||||
const oldest = scenarios.keys().next().value!
|
||||
scenarios.delete(oldest)
|
||||
recorded.delete(oldest)
|
||||
}
|
||||
return send(res, 200, { ok: true })
|
||||
}
|
||||
if (scenarioPath && req.method === 'GET' && scenarioPath[2]) return send(res, 200, recorded.get(scenarioPath[1]) ?? [])
|
||||
if (req.method === 'DELETE' && path === '/__scenarios') {
|
||||
scenarios.clear()
|
||||
recorded.clear()
|
||||
return send(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
throw new HttpError(404, `no route for ${req.method} ${url.pathname}`)
|
||||
}
|
||||
|
||||
const portArg = process.argv.indexOf('--port')
|
||||
const port = Number(portArg > -1 ? process.argv[portArg + 1] : process.env.FAKE_LLM_PORT ?? 14010)
|
||||
|
||||
createServer((req, res) => {
|
||||
route(req, res).catch((err: unknown) => {
|
||||
const status = err instanceof HttpError ? err.status : 500
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[fake-llm] ${req.method} ${req.url} -> ${status}: ${message}`)
|
||||
send(res, status, { error: { message, type: 'e2e_fake_llm_error' } })
|
||||
})
|
||||
}).listen(port, '127.0.0.1', () => console.log(`[fake-llm] listening on http://127.0.0.1:${port}`))
|
||||
@@ -0,0 +1,88 @@
|
||||
// A real MCP server (streamable HTTP, stateless) for the MCP-connector journeys:
|
||||
// the backend connects to it exactly as it would to a customer's server, lists
|
||||
// its tools and calls them when the (fake) model asks.
|
||||
//
|
||||
// POST /mcp the MCP endpoint
|
||||
// GET /__requests the headers each MCP request arrived with, oldest first
|
||||
// DELETE /__requests forget them
|
||||
//
|
||||
// Tools: lookup_order(order_id) → "Order <id>: shipped on 2 May via DHL".
|
||||
//
|
||||
// Run: node services/fake-mcp/server.ts --port 14030
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||
import { z } from 'zod'
|
||||
|
||||
interface SeenRequest {
|
||||
at: string
|
||||
method: string
|
||||
authorization: string | null
|
||||
rpcMethod: string | null
|
||||
}
|
||||
|
||||
const seen: SeenRequest[] = []
|
||||
|
||||
function mcpServer(): McpServer {
|
||||
const server = new McpServer({ name: 'e2e-orders', version: '1.0.0' })
|
||||
server.registerTool('lookup_order', {
|
||||
description: 'Look up an order by its id and return its shipping status.',
|
||||
inputSchema: { order_id: z.string().describe('The order id, e.g. A123') },
|
||||
}, async ({ order_id }) => ({
|
||||
content: [{ type: 'text', text: `Order ${order_id}: shipped on 2 May via DHL` }],
|
||||
}))
|
||||
return server
|
||||
}
|
||||
|
||||
async function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||
const raw = Buffer.concat(chunks).toString('utf8')
|
||||
return raw ? JSON.parse(raw) : undefined
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown) {
|
||||
res.writeHead(status, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
async function route(req: IncomingMessage, res: ServerResponse) {
|
||||
const path = new URL(req.url ?? '/', 'http://fake-mcp').pathname
|
||||
if (path === '/health') return send(res, 200, { status: 'ok' })
|
||||
if (path === '/__requests' && req.method === 'GET') return send(res, 200, seen)
|
||||
if (path === '/__requests' && req.method === 'DELETE') {
|
||||
seen.length = 0
|
||||
return send(res, 200, { ok: true })
|
||||
}
|
||||
if (path !== '/mcp') return send(res, 404, { error: `no route for ${path}` })
|
||||
if (req.method !== 'POST') return send(res, 405, { jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null })
|
||||
|
||||
const body = await readJson(req)
|
||||
seen.push({
|
||||
at: new Date().toISOString(),
|
||||
method: req.method,
|
||||
authorization: req.headers.authorization ?? null,
|
||||
rpcMethod: (body as { method?: string } | undefined)?.method ?? null,
|
||||
})
|
||||
// Stateless: a fresh server and transport per request, as the SDK recommends.
|
||||
const server = mcpServer()
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
|
||||
res.on('close', () => {
|
||||
void transport.close()
|
||||
void server.close()
|
||||
})
|
||||
await server.connect(transport)
|
||||
await transport.handleRequest(req, res, body)
|
||||
}
|
||||
|
||||
const portArg = process.argv.indexOf('--port')
|
||||
const port = Number(portArg > -1 ? process.argv[portArg + 1] : 14030)
|
||||
|
||||
createServer((req, res) => {
|
||||
route(req, res).catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[fake-mcp] ${req.method} ${req.url} -> 500: ${message}`)
|
||||
if (!res.headersSent) send(res, 500, { error: message })
|
||||
})
|
||||
}).listen(port, '127.0.0.1', () => console.log(`[fake-mcp] listening on http://127.0.0.1:${port}/mcp`))
|
||||
Reference in new issue
Block a user