Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 18s
Harness Replays / detect-changes (pull_request) Successful in 19s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 21s
CI / Detect changes (pull_request) Successful in 1m0s
E2E Chat / detect-changes (pull_request) Successful in 1m8s
gate-check-v3 / gate-check (pull_request) Successful in 22s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m16s
qa-review / approved (pull_request) Successful in 21s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m18s
security-review / approved (pull_request) Successful in 22s
Harness Replays / Harness Replays (pull_request) Successful in 9s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m25s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m15s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
sop-tier-check / tier-check (pull_request) Successful in 27s
sop-checklist / all-items-acked (pull_request) Successful in 33s
CI / Python Lint & Test (pull_request) Successful in 14s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 10s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m38s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 12s
E2E Chat / E2E Chat (pull_request) Failing after 35s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m23s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Failing after 2m32s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 2m35s
CI / Canvas (Next.js) (pull_request) Failing after 12m26s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Failing after 13m35s
CI / all-required (pull_request) Failing after 7s
Comprehensive Playwright E2E coverage for the unified chat stack. - chat-seed.ts: external workspace creation with psql bypass for loopback URLs, heartbeat keeper, platform_inbound_secret pre-seed, and DB cleanup - echo-runtime.ts: minimal A2A JSON-RPC server with workspace-side /internal/chat/uploads/ingest endpoint for file-attachment round-trips - panel load, send/receive echo, history persistence - file attachment round-trip (desktop + mobile) - composer auto-grow (mobile) - markdown rendering: code blocks and tables (desktop) - activity log visibility (desktop) - Extract shared hooks: useChatHistory, useChatSend, useChatSocket - MobileChat: add file attachments, markdown rendering, history context - ChatTab: migrate to shared hooks - data-testid: chat-panel, workspace-card, mobile-chat-cta - .gitea/workflows/e2e-chat.yml: ephemeral Postgres/Redis, workspace-server build, canvas dev server, Playwright run, artifact upload on failure
181 lines
6.0 KiB
TypeScript
181 lines
6.0 KiB
TypeScript
/**
|
|
* Minimal A2A echo runtime for E2E tests.
|
|
*
|
|
* Listens on an ephemeral port, receives A2A JSON-RPC `message/send`
|
|
* requests, and returns a response with the original text echoed back.
|
|
* Also implements the workspace-side chat upload ingest endpoint so
|
|
* file-attachment E2E can exercise the full upload → send → echo
|
|
* round-trip.
|
|
*
|
|
* Usage (inside test fixture):
|
|
* const echo = await startEchoRuntime();
|
|
* // ... seed workspace with agent_url pointing to echo.baseURL ...
|
|
* echo.stop();
|
|
*/
|
|
|
|
import { createServer, type Server } from "node:http";
|
|
|
|
export interface EchoRuntime {
|
|
baseURL: string;
|
|
stop: () => Promise<void>;
|
|
lastRequest: { method: string; text: string; files: unknown[] } | null;
|
|
}
|
|
|
|
/** Parse a minimal multipart body and extract the first file's name + content. */
|
|
function parseMultipart(body: Buffer): { name: string; mimeType: string; content: Buffer } | null {
|
|
// Find the boundary line (first line starting with "--").
|
|
const str = body.toString("binary");
|
|
const firstDash = str.indexOf("--");
|
|
if (firstDash === -1) return null;
|
|
const eol = str.indexOf("\r\n", firstDash);
|
|
if (eol === -1) return null;
|
|
const boundary = str.slice(firstDash + 2, eol);
|
|
const boundaryMarker = "\r\n--" + boundary;
|
|
|
|
// Find the first part that has a filename in Content-Disposition.
|
|
let pos = eol + 2;
|
|
while (pos < str.length) {
|
|
const nextBoundary = str.indexOf(boundaryMarker, pos);
|
|
if (nextBoundary === -1) break;
|
|
const part = str.slice(pos, nextBoundary);
|
|
|
|
const cdMatch = part.match(/Content-Disposition:[^\r\n]*filename="([^"]+)"/i);
|
|
if (cdMatch) {
|
|
const name = cdMatch[1];
|
|
const ctMatch = part.match(/Content-Type:\s*([^\r\n]+)/i);
|
|
const mimeType = ctMatch ? ctMatch[1].trim() : "application/octet-stream";
|
|
// Body starts after the first double-CRLF in the part.
|
|
const bodyStart = part.indexOf("\r\n\r\n");
|
|
if (bodyStart !== -1) {
|
|
// Extract the raw bytes (not the string) so binary is safe.
|
|
const headerBytes = Buffer.byteLength(part.slice(0, bodyStart + 4), "binary");
|
|
const partStartInBody = Buffer.byteLength(str.slice(0, pos + bodyStart + 4), "binary");
|
|
const partEndInBody = Buffer.byteLength(str.slice(0, nextBoundary), "binary");
|
|
const content = body.subarray(partStartInBody, partEndInBody);
|
|
return { name, mimeType, content };
|
|
}
|
|
}
|
|
pos = nextBoundary + boundaryMarker.length;
|
|
// Skip trailing "--" (end marker) or CRLF.
|
|
if (str.slice(pos, pos + 2) === "--") break;
|
|
if (str.slice(pos, pos + 2) === "\r\n") pos += 2;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function startEchoRuntime(): Promise<EchoRuntime> {
|
|
let lastRequest: EchoRuntime["lastRequest"] = null;
|
|
|
|
const server = createServer((req, res) => {
|
|
// CORS: allow the canvas origin (localhost:3000) to call us.
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
|
|
if (req.method === "OPTIONS") {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const url = req.url ?? "/";
|
|
|
|
// Workspace-side chat upload ingest (RFC #2312).
|
|
if (url === "/internal/chat/uploads/ingest" && req.method === "POST") {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
req.on("end", () => {
|
|
const body = Buffer.concat(chunks);
|
|
const file = parseMultipart(body);
|
|
if (!file) {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: "no files field" }));
|
|
return;
|
|
}
|
|
const sanitized = file.name.replace(/[^a-zA-Z0-9._\-]/g, "_").replace(/ /g, "_");
|
|
const prefix = Array.from({ length: 32 }, () =>
|
|
Math.floor(Math.random() * 16).toString(16),
|
|
).join("");
|
|
const response = {
|
|
files: [
|
|
{
|
|
uri: `workspace:/workspace/.molecule/chat-uploads/${prefix}-${sanitized}`,
|
|
name: sanitized,
|
|
mimeType: file.mimeType,
|
|
size: file.content.length,
|
|
},
|
|
],
|
|
};
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify(response));
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Default: A2A JSON-RPC handler.
|
|
let body = "";
|
|
req.setEncoding("utf8");
|
|
req.on("data", (chunk: string) => {
|
|
body += chunk;
|
|
});
|
|
req.on("end", () => {
|
|
res.setHeader("Content-Type", "application/json");
|
|
try {
|
|
const rpc = JSON.parse(body);
|
|
const msg = rpc.params?.message;
|
|
const textParts =
|
|
msg?.parts
|
|
?.filter((p: { kind?: string; text?: string }) => p.kind === "text")
|
|
.map((p: { text?: string }) => p.text)
|
|
.filter(Boolean) ?? [];
|
|
const fileParts =
|
|
msg?.parts?.filter((p: { kind?: string }) => p.kind === "file") ?? [];
|
|
const text = textParts.join("\n");
|
|
|
|
lastRequest = {
|
|
method: rpc.method ?? "unknown",
|
|
text,
|
|
files: fileParts,
|
|
};
|
|
|
|
const replyText = text
|
|
? `Echo: ${text}`
|
|
: fileParts.length > 0
|
|
? "Echo: received your file(s)."
|
|
: "Echo: hello";
|
|
|
|
const response = {
|
|
jsonrpc: "2.0",
|
|
id: rpc.id ?? null,
|
|
result: {
|
|
parts: [{ kind: "text", text: replyText }],
|
|
},
|
|
};
|
|
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify(response));
|
|
} catch {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: "invalid json" }));
|
|
}
|
|
});
|
|
});
|
|
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
const baseURL = `http://127.0.0.1:${port}`;
|
|
|
|
return {
|
|
baseURL,
|
|
stop: () =>
|
|
new Promise((resolve) => {
|
|
server.close(() => resolve(undefined));
|
|
}),
|
|
get lastRequest() {
|
|
return lastRequest;
|
|
},
|
|
};
|
|
}
|