fix(e2e): #2782 parameterize chat-seed JSON + quote regression #2825

Closed
agent-dev-a wants to merge 1 commits from fix/2782-seed-param-auth into main
5 changed files with 262 additions and 48 deletions
+3 -6
View File
@@ -1,7 +1,7 @@
import { test, expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { startEchoRuntime } from "./fixtures/echo-runtime";
import { seedWorkspace, startHeartbeat, cleanupWorkspace, runPsql } from "./fixtures/chat-seed";
import { seedWorkspace, startHeartbeat, cleanupWorkspace, queryPsql } from "./fixtures/chat-seed";
/** Enter the Org-map view so the Canvas (React Flow graph) mounts. */
async function enterMapView(page: Page): Promise<void> {
@@ -71,11 +71,8 @@ test.describe("Desktop ChatTab", () => {
// Regression for #2786: external echo-runtime workspaces must be push-mode
// so the proxy dispatches synchronously to the echo URL. Poll-mode defaults
// short-circuit to {status:'queued'} and the inline echo never renders.
const out = runPsql(
`SELECT delivery_mode FROM workspaces WHERE id = '${workspaceId}';`,
10_000,
);
expect(out).toContain("push");
const rows = await queryPsql<{ delivery_mode: string }>("SELECT delivery_mode FROM workspaces WHERE id = $1", [workspaceId]);
expect(rows[0]?.delivery_mode).toBe("push");
});
test("send text message and receive echo response", async ({ page }) => {
+16 -6
View File
@@ -320,11 +320,17 @@ test.describe("Data Flow — Initial Prompt in Chat", () => {
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
// Pre-seed chat history so the My Chat panel shows deterministic content.
// Include double quotes to regression-test shell-safe JSON quoting in
// seedChatHistory (CR2 #11517).
// Include double quotes, backslashes, and an apostrophe to regression-test
// parameterized JSON handling in seedChatHistory (#2782).
await seedChatHistory(workspaceId, [
{ role: "user", content: 'Hello from seed with "quotes"' },
{ role: "agent", content: 'Hello back from seed with "quotes"' },
{
role: "user",
content: 'Hello from seed with "quotes" and \\backslash\\ plus apostrophe\'s',
},
{
role: "agent",
content: 'Hello back from seed with "quotes" and \\backslash\\ plus apostrophe\'s',
},
]);
cleanup = async () => {
@@ -344,8 +350,12 @@ test.describe("Data Flow — Initial Prompt in Chat", () => {
test("seeded chat history appears in My Chat", async ({ page }) => {
const panel = panelLocator(page);
await expect(panel.getByText('Hello from seed with "quotes"')).toBeVisible({ timeout: 5_000 });
await expect(panel.getByText('Hello back from seed with "quotes"')).toBeVisible({ timeout: 5_000 });
await expect(
panel.getByText('Hello from seed with "quotes" and \\backslash\\ plus apostrophe\'s'),
).toBeVisible({ timeout: 5_000 });
await expect(
panel.getByText('Hello back from seed with "quotes" and \\backslash\\ plus apostrophe\'s'),
).toBeVisible({ timeout: 5_000 });
});
test("My Chat empty state is not shown when history exists", async ({ page }) => {
+82 -36
View File
@@ -10,6 +10,7 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { Client } from "pg";
const PLATFORM_URL = process.env.E2E_PLATFORM_URL ?? "http://localhost:8080";
@@ -31,22 +32,52 @@ export function parseDbUrl(): PgCredentials | null {
return { user, pass, host, port, db };
}
export function runPsql(sql: string, timeoutMs = 30_000): string {
/**
* Create a connected Postgres client from E2E_DATABASE_URL.
* Caller must call client.end().
*/
async function newPgClient(): Promise<Client> {
const creds = parseDbUrl();
if (!creds) {
throw new Error("E2E_DATABASE_URL must be set for DB seeding");
}
const { user, pass, host, port, db } = creds;
const out = execFileSync(
"psql",
["-h", host, "-p", port, "-U", user, "-d", db, "-c", sql],
{
env: { ...process.env, PGPASSWORD: pass },
stdio: "pipe",
timeout: timeoutMs,
},
);
return out.toString();
const client = new Client({
host: creds.host,
port: Number(creds.port),
user: creds.user,
password: creds.pass,
database: creds.db,
});
await client.connect();
return client;
}
/**
* Run a single SQL statement via node-postgres with bound parameters.
* Returns the query result.
*/
async function pgExec(
sql: string,
values: unknown[] = [],
): Promise<import("pg").QueryResult> {
const client = await newPgClient();
try {
return await client.query(sql, values);
} finally {
await client.end();
}
}
/**
* Execute a read-only SQL query and return typed rows.
* Useful for test assertions that need to inspect seeded DB state.
*/
export async function queryPsql<T>(
sql: string,
values: unknown[] = [],
): Promise<T[]> {
const result = await pgExec(sql, values);
return (result.rows ?? []) as T[];
}
export interface SeededWorkspace {
@@ -98,7 +129,7 @@ export async function seedWorkspace(echoURL: string): Promise<SeededWorkspace> {
// 2. Direct DB update: mark online + point url at echo runtime.
// The platform blocks loopback URLs at the API layer (SSRF guard),
// so we bypass via psql for local E2E.
// so we bypass via direct Postgres query for local E2E.
if (!process.env.E2E_DATABASE_URL) {
throw new Error("E2E_DATABASE_URL must be set for DB seeding");
}
@@ -111,8 +142,14 @@ export async function seedWorkspace(echoURL: string): Promise<SeededWorkspace> {
],
).join("");
runPsql(
`UPDATE workspaces SET status = 'online', url = '${echoURL}', platform_inbound_secret = '${inboundSecret}', delivery_mode = 'push' WHERE id = '${ws.id}'`,
await pgExec(
`UPDATE workspaces
SET status = 'online',
url = $1,
platform_inbound_secret = $2,
delivery_mode = 'push'
WHERE id = $3`,
[echoURL, inboundSecret, ws.id],
);
cacheWorkspaceURL(ws.id, echoURL);
@@ -181,9 +218,10 @@ export function startHeartbeat(
* /chat-history hydrator picks them up. Each message becomes its own row
* so arbitrary user/agent sequences can be seeded.
*
* The JSON payloads are passed through psql as dollar-quoted literals so
* message text containing quotes, backslashes, or other special characters
* is preserved exactly and cannot break the SQL string escaping.
* The JSON payloads are bound as node-postgres parameters ($3/$4) so the
* message text is never interpolated into the SQL string or the shell
* command line. This survives quotes, backslashes, apostrophes, and any
* other special characters.
*/
export async function seedChatHistory(
workspaceId: string,
@@ -196,8 +234,10 @@ export async function seedChatHistory(
throw new Error("E2E_DATABASE_URL must be set for chat-history seeding");
}
const rows = messages
.map((msg, i) => {
const client = await newPgClient();
try {
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const offsetSec = messages.length - i;
const requestBody =
msg.role === "user"
@@ -218,32 +258,38 @@ export async function seedChatHistory(
})
: "{}";
// Use a per-row random dollar-quoting tag so the message content
// cannot accidentally close the literal.
const tag = `J${randomUUID().replace(/[^A-Za-z0-9]/g, "")}`;
const reqLit = `$${tag}$${requestBody}$${tag}$`;
const respLit = `$${tag}$${responseBody}$${tag}$`;
return `('${randomUUID()}', '${workspaceId}', 'a2a_receive', NULL, NULL, 'message/send', NULL, ${reqLit}::jsonb, ${respLit}::jsonb, 0, 'ok', NOW() - INTERVAL '${offsetSec} seconds')`;
})
.join(",");
const sql = `INSERT INTO activity_logs (id, workspace_id, activity_type, source_id, target_id, method, summary, request_body, response_body, duration_ms, status, created_at) VALUES ${rows};`;
runPsql(sql, 10_000);
// Bind the JSON payloads as parameters ($3/$4) so the content is never
// interpolated into the SQL string or the shell command line. This
// survives arbitrary quotes, backslashes, and apostrophes.
await client.query(
`INSERT INTO activity_logs (
id, workspace_id, activity_type, source_id, target_id,
method, summary, request_body, response_body, duration_ms,
status, created_at
) VALUES (
$1, $2, 'a2a_receive', NULL, NULL,
'message/send', NULL, $3::jsonb, $4::jsonb, 0,
'ok', NOW() - ($5 * INTERVAL '1 second')
)`,
[randomUUID(), workspaceId, requestBody, responseBody, offsetSec],
);
}
} finally {
await client.end();
}
}
/**
* Delete a seeded workspace row directly from the DB.
* Uses psql (same credentials as seedWorkspace) so we bypass any
* workspace-server side-effects (container stop, cascade cleanup, etc.)
* Uses direct Postgres query (same credentials as seedWorkspace) so we bypass
* any workspace-server side-effects (container stop, cascade cleanup, etc.)
* that can race or 500 on external workspaces.
*/
export async function cleanupWorkspace(workspaceId: string): Promise<void> {
if (!process.env.E2E_DATABASE_URL) return;
try {
runPsql(`DELETE FROM workspaces WHERE id = '${workspaceId}'`);
await pgExec("DELETE FROM workspaces WHERE id = $1", [workspaceId]);
} catch {
// Best-effort cleanup; don't fail the test suite if the row is already gone.
}
+159
View File
@@ -32,6 +32,7 @@
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@types/node": "^25.6.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
@@ -39,6 +40,7 @@
"eslint": "^9.39.4",
"eslint-config-next": "^15.5.15",
"jsdom": "^29.1.1",
"pg": "^8.21.0",
"postcss": "^8.5.13",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
@@ -2852,6 +2854,17 @@
"undici-types": "~7.19.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -8436,6 +8449,95 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"dev": true,
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"dev": true,
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"dev": true
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"dev": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"dev": true,
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"dev": true
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dev": true,
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8539,6 +8641,45 @@
"node": ">=4"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -9310,6 +9451,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -10522,6 +10672,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"engines": {
"node": ">=0.4"
}
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
+2
View File
@@ -35,6 +35,7 @@
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@types/node": "^25.6.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
@@ -42,6 +43,7 @@
"eslint": "^9.39.4",
"eslint-config-next": "^15.5.15",
"jsdom": "^29.1.1",
"pg": "^8.21.0",
"postcss": "^8.5.13",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",