-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathwebhook-component-regression.spec.ts
More file actions
408 lines (346 loc) · 15.8 KB
/
Copy pathwebhook-component-regression.spec.ts
File metadata and controls
408 lines (346 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
import { expect, test } from "../../../fixtures/fixtures";
import { adjustScreenView } from "../../../helpers/ui/adjust-screen-view";
import { awaitBootstrapTest } from "../../../helpers/other/await-bootstrap-test";
import { cleanAllFlows } from "../../../helpers/flows/clean-all-flows";
import { getAuthToken } from "../../../helpers/auth/get-auth-token";
// Run tests serially because addWebhookComponent calls cleanAllFlows() —
// without serial mode, parallel tests delete each other's flows mid-run,
// turning the webhook POST into a 404 race.
test.describe.configure({ mode: "serial" });
// Reusable helper: create blank flow and add the Webhook component.
// After this call the component is visible on the canvas and the inspector is open.
async function addWebhookComponent(page: any) {
await awaitBootstrapTest(page);
// Clean existing flows first to avoid 400 "flow must be unique" under parallelism
await cleanAllFlows(page);
await page.getByTestId("blank-flow").click();
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("webhook");
await page.waitForSelector('[data-testid="input_outputWebhook"]', {
timeout: 10000,
});
await page.getByTestId("input_outputWebhook").hover();
await page.getByTestId("add-component-button-webhook").click();
await adjustScreenView(page);
// Wait for the Webhook node to appear on the canvas
await page.waitForSelector('[data-testid="input_output_webhook_draggable"]', {
timeout: 15000,
});
}
test(
"Webhook component — HTTP POST accepts JSON and plain-text bodies returning 202",
{ tag: ["@release", "@regression"] },
async ({ page, request }) => {
await addWebhookComponent(page);
const flowId = page.url().split("/").slice(-1)[0];
expect(flowId).toMatch(/^[0-9a-f-]{36}$/);
// Wait for autosave to persist the flow before posting
await page.waitForTimeout(4000);
// The webhook endpoint requires `x-api-key` whenever Langflow's
// WEBHOOK_AUTH_ENABLE setting is true (secure-by-default since 1.5+).
// Create a temporary key, use it for the POSTs, and delete it after.
const bearerToken = await getAuthToken(request);
const keyRes = await request.post("/api/v1/api_key/", {
headers: { Authorization: bearerToken },
data: { name: `webhook-regression-${Date.now()}` },
});
expect(keyRes.status()).toBe(200);
const keyBody = await keyRes.json();
const apiKey: string = keyBody.api_key;
const apiKeyId: string = keyBody.id;
try {
// JSON body — the primary use case
const jsonRes = await request.post(`/api/v1/webhook/${flowId}`, {
headers: { "x-api-key": apiKey },
data: { event: "regression-test", value: 42 },
});
expect(jsonRes.status()).toBe(202);
const jsonBody = await jsonRes.json();
expect(jsonBody.status).toBe("in progress");
expect(jsonBody.message).toBe("Task started in the background");
// Plain-text body — the endpoint must accept any Content-Type
const textRes = await request.post(`/api/v1/webhook/${flowId}`, {
headers: { "x-api-key": apiKey, "Content-Type": "text/plain" },
data: "regression-plain-text",
});
expect(textRes.status()).toBe(202);
const textBody = await textRes.json();
expect(textBody.status).toBe("in progress");
} finally {
await request.delete(`/api/v1/api_key/${apiKeyId}`, {
headers: { Authorization: bearerToken },
});
}
},
);
// Marked fixme until the Langflow frontend bundle stops mutating an undefined
// `Accept-Language` header inside the request wrapper (see #165 item 2 — surfaced
// by weekly run 25441253323 as `TypeError: Cannot set properties of undefined`).
// When the upstream bug is fixed, remove `.fixme` and re-validate.
test.fixme(
"Webhook component — flow is saved to database and contains the Webhook node",
// @stable removed: upstream Langflow regression breaks page.evaluate(fetch)
// with "Cannot set properties of undefined (setting 'Accept-Language')".
// Tracked in #180; restore @stable once upstream is fixed.
{ tag: ["@release", "@regression"] },
async ({ page }) => {
await addWebhookComponent(page);
const flowId = page.url().split("/").slice(-1)[0];
expect(flowId).toMatch(/^[0-9a-f-]{36}$/);
// Wait for the auto-save debounce to flush the flow to the database.
// This is required before making any API calls that depend on the flow existing.
await page.waitForTimeout(4000);
// Verify the flow is persisted and contains the Webhook component.
// Use page.evaluate(fetch) so the request runs in the browser context and
// carries the session cookies. The request fixture is unauthenticated and
// would get a 403 from the flows endpoint even in auto-login mode.
const flowData = await page.evaluate(async (fId) => {
const res = await fetch(`/api/v1/flows/${fId}`, {
credentials: "include",
});
if (!res.ok) return null;
return res.json();
}, flowId);
expect(flowData).not.toBeNull();
const nodes: any[] = flowData?.data?.nodes ?? [];
// The flow must contain a Webhook node
const webhookNode = nodes.find((n: any) => n.data?.type === "Webhook");
expect(webhookNode).toBeDefined();
// The endpoint field must store the BACKEND_URL placeholder (substituted by the frontend).
// If this placeholder changes, the endpoint URL will stop working for all users.
const endpointValue =
webhookNode?.data?.node?.template?.endpoint?.value ?? "";
expect(endpointValue).toBe("BACKEND_URL");
},
);
test(
"Webhook component — cURL command in inspector shows valid POST URL with flow ID",
{ tag: ["@stable", "@release", "@regression"] },
async ({ page }) => {
await addWebhookComponent(page);
// The inspector renders the cURL field (via WebhookFieldComponent → TextAreaComponent)
// as a textbox containing the actual curl command with the real backend URL and flow ID.
// This verifies that the CURL_WEBHOOK placeholder is correctly substituted.
const flowId = page.url().split("/").slice(-1)[0];
expect(flowId).toMatch(/^[0-9a-f-]{36}$/);
// Read the cURL textbox value directly from the inspector (no modal needed).
// The textbox is rendered inline in the inspector panel with placeholder "Type something..."
await page.waitForSelector('[placeholder="Type something..."]', {
timeout: 10000,
});
const curlValue = await page
.locator('[placeholder="Type something..."]')
.first()
.inputValue();
// Verify the cURL command structure — these are the key regression points:
// 1. Uses POST method (not GET)
expect(curlValue).toContain("-X POST");
// 2. URL contains the real backend host and the correct flow ID
expect(curlValue).toContain(`/api/v1/webhook/${flowId}`);
// 3. Content-Type header is set to application/json
expect(curlValue).toContain("Content-Type: application/json");
// 4. Includes a placeholder JSON body
expect(curlValue).toContain("-d");
},
);
test(
"Webhook component — empty data field returns empty Data object",
{ tag: ["@stable", "@release", "@regression"] },
async ({ page }) => {
await addWebhookComponent(page);
// The data field is empty by default — run without filling it.
// build_data() checks `if not self.data` and returns Data(data={}).
await page.waitForSelector('[data-testid="button_run_webhook"]', {
timeout: 10000,
});
await page.getByTestId("button_run_webhook").click();
await page.waitForSelector("text=built successfully", { timeout: 30000 });
await expect(page.getByText("built successfully").last()).toBeVisible();
// Open output and verify the result is an empty object
await page.getByTestId("output-inspection-json-webhook").click();
await page.waitForSelector('[role="dialog"]', { timeout: 10000 });
const dialog = page.locator('[role="dialog"]');
const editorContent = await dialog
.locator("[role='textbox']")
.evaluate((el) => el.textContent ?? "");
// The output Data object must be {} — no keys present
const parsed = JSON.parse(editorContent.trim() || "null");
expect(parsed).toEqual({});
await page.keyboard.press("Escape");
},
);
test(
"Webhook component — endpoint field renders the actual webhook URL",
{ tag: ["@stable", "@release", "@regression"] },
async ({ page }) => {
await addWebhookComponent(page);
const flowId = page.url().split("/").slice(-1)[0];
expect(flowId).toMatch(/^[0-9a-f-]{36}$/);
// The endpoint field has advanced=False and copy_field=True.
// The frontend replaces the "BACKEND_URL" placeholder with the real
// webhook URL: {protocol}//{host}/api/v1/webhook/{flowId or endpoint_name}.
await page.waitForSelector('[data-testid="str_endpoint"]', {
timeout: 10000,
});
const endpointValue = await page
.locator('[data-testid="str_endpoint"]')
.inputValue();
expect(endpointValue).toMatch(/^https?:\/\//);
expect(endpointValue).toContain("/api/v1/webhook/");
expect(endpointValue.length).toBeGreaterThan(0);
},
);
test(
"Webhook component — copy button copies the endpoint URL to clipboard",
{ tag: ["@stable", "@release", "@regression"] },
async ({ page }) => {
await addWebhookComponent(page);
// The CopyFieldAreaComponent renders a copy icon button with testid
// btn_copy_{id} where id="str_endpoint" (type_fieldname convention).
// Clicking it copies the endpoint URL and shows a success toast.
await page.waitForSelector('[data-testid="btn_copy_str_endpoint"]', {
timeout: 10000,
});
// Read what the endpoint field is showing before clicking copy
const expectedUrl = await page
.locator('[data-testid="str_endpoint"]')
.inputValue();
expect(expectedUrl).toContain("/api/v1/webhook/");
await page.getByTestId("btn_copy_str_endpoint").click();
// Verify the success toast appears
await expect(page.getByText("Endpoint URL copied")).toBeVisible({
timeout: 5000,
});
// Verify the clipboard actually contains the correct URL
// playwright.config.ts grants clipboard permissions to Chromium
const clipboardText = await page.evaluate(() =>
navigator.clipboard.readText(),
);
expect(clipboardText).toBe(expectedUrl);
},
);
test(
"Webhook component — POST to non-existent flow name returns 404",
{ tag: ["@release", "@regression"] },
async ({ request }) => {
// The webhook endpoint returns 404 when the flow_id_or_name cannot be resolved.
// This is confirmed by the backend unit test: test_webhook_not_found_invalid_endpoint.
// Using a string name (not UUID) as the backend resolves by endpoint_name first.
const response = await request.post(
"/api/v1/webhook/non-existent-flow-e2e-regression-test",
{
data: { test: "not-found" },
},
);
expect(response.status()).toBe(404);
},
);
// Helper: inject a value into the Webhook's "data" field by intercepting the
// GET /api/v1/flows/{id} response so the canvas receives the patched template.
// The "data" field (Payload, advanced=True) has no editable UI — it is populated
// only via the webhook POST endpoint in production. We simulate that by
// intercepting the API response, which is equivalent for testing build_data() logic.
async function loadFlowWithDataField(
page: any,
flowId: string,
dataValue: string,
) {
// Intercept the GET flow response and inject the data field value
await page.route(`**/api/v1/flows/${flowId}`, async (route: any) => {
const response = await route.fetch();
const json = await response.json();
const webhookNode = (json?.data?.nodes ?? []).find(
(n: any) => n?.data?.type === "Webhook",
);
if (webhookNode) {
webhookNode.data.node.template.data.value = dataValue;
}
await route.fulfill({ json });
});
// Navigate to the flow — the interceptor will inject the data field value
await page.goto(`/flow/${flowId}`);
await page.waitForSelector('[data-testid="canvas_controls_dropdown"]', {
timeout: 60000,
});
await page.waitForSelector('[data-testid="button_run_webhook"]', {
timeout: 60000,
});
await adjustScreenView(page);
// Remove the intercept after use so it doesn't affect subsequent requests
await page.unroute(`**/api/v1/flows/${flowId}`);
}
test(
"Webhook component — valid JSON payload is propagated as structured Data output",
{ tag: ["@stable", "@release", "@regression"] },
async ({ page }) => {
await addWebhookComponent(page);
const flowId = page.url().split("/").slice(-1)[0];
expect(flowId).toMatch(/^[0-9a-f-]{36}$/);
// Wait for autosave before reloading
await page.waitForTimeout(4000);
// The "data" field has no editable UI — inject the value via API response
// intercept so the canvas receives the patched template on navigation.
// build_data() will parse the JSON string and return it as a Data object.
await loadFlowWithDataField(
page,
flowId,
'{"event": "regression-test", "value": 42}',
);
await page.getByTestId("button_run_webhook").click();
await page.waitForSelector("text=built successfully", { timeout: 30000 });
await expect(page.getByText("built successfully").last()).toBeVisible();
// Open output inspection and verify the parsed object
await page.getByTestId("output-inspection-json-webhook").click();
await page.waitForSelector('[role="dialog"]', { timeout: 10000 });
const dialog = page.locator('[role="dialog"]');
const editorContent = await dialog
.locator("[role='textbox']")
.evaluate((el) => el.textContent ?? "");
const parsed = JSON.parse(editorContent.trim() || "null");
expect(parsed).toEqual({ event: "regression-test", value: 42 });
await page.keyboard.press("Escape");
},
);
test(
"Webhook component — invalid JSON payload is encapsulated in {payload: ...}",
{ tag: ["@stable", "@release", "@regression"] },
async ({ page }) => {
await addWebhookComponent(page);
const flowId = page.url().split("/").slice(-1)[0];
expect(flowId).toMatch(/^[0-9a-f-]{36}$/);
// Wait for autosave before reloading
await page.waitForTimeout(4000);
// build_data() catches json.JSONDecodeError and wraps the raw string in
// {"payload": "<raw string>"} — this tests that fallback path.
const invalidPayload = "not valid json {{broken";
await loadFlowWithDataField(page, flowId, invalidPayload);
await page.getByTestId("button_run_webhook").click();
await page.waitForSelector("text=built successfully", { timeout: 30000 });
await expect(page.getByText("built successfully").last()).toBeVisible();
// Open output inspection and verify the fallback wrapping
await page.getByTestId("output-inspection-json-webhook").click();
await page.waitForSelector('[role="dialog"]', { timeout: 10000 });
const dialog = page.locator('[role="dialog"]');
const editorContent = await dialog
.locator("[role='textbox']")
.evaluate((el) => el.textContent ?? "");
const parsed = JSON.parse(editorContent.trim() || "null");
expect(parsed).toEqual({ payload: invalidPayload });
await page.keyboard.press("Escape");
},
);
test(
"GET /api/v1/monitor/messages returns 200 with array response",
{ tag: ["@stable", "@release", "@regression"] },
async ({ request }) => {
const authToken = await getAuthToken(request);
// /api/v1/monitor/messages tracks message delivery for all components including Webhook.
const res = await request.get("/api/v1/monitor/messages", {
headers: { Authorization: authToken },
});
expect(res.status()).toBe(200);
const body = await res.json();
// The response must be an array (possibly empty when no flows have run yet)
expect(Array.isArray(body)).toBe(true);
},
);