mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
Since the role moved to n8n 2 (2.42.3 on 2026-10-05, now 2.44.0),
POST /rest/workflows/:id/run accepts a manual run only with a trigger to
start from or a destination node; any other key, `workflowData` among them,
is dropped. The LiteLLM gateway test still sent only `workflowData`, so every
attempt failed with HTTP 400 "To run the workflow manually, specify either a
trigger to start from or a destination node." (PR #912, run 38455633497,
job 115523214958). On main the test sat behind the MCP provisioning failure of
n8n#0 that e4078c05fd fixed, so the next main run would have hit it.
The test now runs the stored probe workflow from its manual trigger.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
336 lines
12 KiB
JavaScript
336 lines
12 KiB
JavaScript
const { test, expect } = require("@playwright/test");
|
|
const { resolveTimeout } = require("./timeouts");
|
|
const { decodeDotenvQuotedValue, gotoOnion } = require("./personas");
|
|
|
|
const credentialName = decodeDotenvQuotedValue(process.env.N8N_LITELLM_CREDENTIAL_NAME);
|
|
const gatewayBaseUrl = decodeDotenvQuotedValue(process.env.N8N_LITELLM_BASE_URL);
|
|
const gatewayModel = decodeDotenvQuotedValue(process.env.N8N_LITELLM_MODEL);
|
|
|
|
const CREDENTIAL_TYPE = "openAiApi";
|
|
const PROBE_WORKFLOW = "infinito:litellm-probe";
|
|
const TRIGGER_NODE = "Start";
|
|
const REQUEST_NODE = "Gateway";
|
|
const PROMPT = "Reply with exactly the word: pong";
|
|
const FINAL_STATUSES = new Set(["success", "error", "crashed", "canceled"]);
|
|
const COMPLETION_TIMEOUT_MS = 240_000;
|
|
|
|
/**
|
|
* Return a caller for n8n's internal `/rest` API bound to the page's session.
|
|
*
|
|
* n8n binds the `n8n-auth` JWT to the `browser-id` the frontend generated at
|
|
* login time, so a request that omits the header is rejected with 401 while
|
|
* the very same cookie works in the tab. The SSO path (files/hooks.js) issues
|
|
* a cookie without one, hence the empty-value tolerance.
|
|
*
|
|
* Args:
|
|
* page: the signed-in Playwright page.
|
|
* baseUrl: origin of the n8n vhost.
|
|
*/
|
|
async function restClient(page, baseUrl) {
|
|
const browserId = await page.evaluate(() => {
|
|
try {
|
|
const stored = window.localStorage.getItem("n8n-browserId");
|
|
if (stored) {
|
|
return stored;
|
|
}
|
|
const key = Object.keys(window.localStorage).find((name) => /browser.?id/i.test(name));
|
|
return key ? window.localStorage.getItem(key) || "" : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
});
|
|
|
|
return async function rest(method, path, payload) {
|
|
const headers = { accept: "application/json" };
|
|
if (browserId) {
|
|
headers["browser-id"] = browserId;
|
|
}
|
|
const options = { method, headers, failOnStatusCode: false, timeout: resolveTimeout(120_000) };
|
|
if (payload !== undefined) {
|
|
options.data = payload;
|
|
}
|
|
const response = await page.request.fetch(`${baseUrl}${path}`, options);
|
|
const text = await response.text();
|
|
let body;
|
|
try {
|
|
body = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
body = null;
|
|
}
|
|
return { status: response.status(), body, text: text.slice(0, 400) };
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sign in as the n8n instance owner, the account that owns the managed
|
|
* credential, over whichever login surface the deploy exposes.
|
|
*
|
|
* Args:
|
|
* page: a page with cleared cookies.
|
|
* shared: the role's `_shared` module.
|
|
*/
|
|
async function signInAsOwner(page, shared) {
|
|
if (shared.env.oidcEnabled) {
|
|
expect(shared.env.adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
|
|
expect(shared.env.adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
|
|
await shared.signInViaN8nOidc(
|
|
page,
|
|
shared.env.adminUsername,
|
|
shared.env.adminPassword,
|
|
"administrator"
|
|
);
|
|
return;
|
|
}
|
|
|
|
expect(shared.env.adminEmail, "ADMIN_EMAIL must be set").toBeTruthy();
|
|
expect(shared.env.n8nOwnerPassword, "N8N_OWNER_PASSWORD must be set").toBeTruthy();
|
|
await gotoOnion(page, `${shared.env.n8nBaseUrl}/`);
|
|
await shared.performN8nLoginForm(page, shared.env.adminEmail, shared.env.n8nOwnerPassword);
|
|
}
|
|
|
|
/**
|
|
* Revive the `data` field of an execution read over `/rest`.
|
|
*
|
|
* n8n 1.95.3 hands the field back exactly as it stores it: a `flatted` string,
|
|
* a JSON array whose object and string members are numeric indices back into
|
|
* that same array. Reading `.resultData` off that string yields `undefined`,
|
|
* which is indistinguishable from a node that never ran.
|
|
*
|
|
* Args:
|
|
* payload: the execution's `data` field, flatted string or plain object.
|
|
*/
|
|
function reviveExecutionData(payload) {
|
|
if (typeof payload !== "string") {
|
|
return payload;
|
|
}
|
|
const nodes = JSON.parse(payload);
|
|
if (!Array.isArray(nodes)) {
|
|
return nodes;
|
|
}
|
|
const revived = new Map();
|
|
const revive = (index) => {
|
|
const value = nodes[index];
|
|
if (value === null || typeof value !== "object") {
|
|
return value;
|
|
}
|
|
if (revived.has(index)) {
|
|
return revived.get(index);
|
|
}
|
|
const output = Array.isArray(value) ? [] : {};
|
|
revived.set(index, output);
|
|
for (const [key, reference] of Object.entries(value)) {
|
|
output[key] = typeof reference === "string" ? revive(Number(reference)) : reference;
|
|
}
|
|
return output;
|
|
};
|
|
return revive(0);
|
|
}
|
|
|
|
/**
|
|
* Poll one manual execution until it reaches a final state.
|
|
*
|
|
* Args:
|
|
* page: the signed-in Playwright page.
|
|
* rest: the `/rest` caller.
|
|
* executionId: id returned by the manual run.
|
|
* timeoutMs: budget for the whole completion.
|
|
*/
|
|
async function pollExecution(page, rest, executionId, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
for (;;) {
|
|
const latest = await rest("GET", `/rest/executions/${executionId}`);
|
|
const execution = latest.body?.data;
|
|
const status = String(execution?.status || "");
|
|
if (execution && (execution.finished === true || FINAL_STATUSES.has(status))) {
|
|
return { execution, latest };
|
|
}
|
|
if (Date.now() >= deadline) {
|
|
return { execution: null, latest };
|
|
}
|
|
await page.waitForTimeout(resolveTimeout(2_000));
|
|
}
|
|
}
|
|
|
|
function probeWorkflow(credentialId, endpoint) {
|
|
return {
|
|
name: PROBE_WORKFLOW,
|
|
active: false,
|
|
settings: { executionOrder: "v1" },
|
|
nodes: [
|
|
{
|
|
id: "infinito-litellm-start",
|
|
name: TRIGGER_NODE,
|
|
type: "n8n-nodes-base.manualTrigger",
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: {},
|
|
},
|
|
{
|
|
id: "infinito-litellm-request",
|
|
name: REQUEST_NODE,
|
|
type: "n8n-nodes-base.httpRequest",
|
|
typeVersion: 4.2,
|
|
position: [220, 0],
|
|
parameters: {
|
|
method: "POST",
|
|
url: `${endpoint}/chat/completions`,
|
|
authentication: "predefinedCredentialType",
|
|
nodeCredentialType: CREDENTIAL_TYPE,
|
|
sendBody: true,
|
|
contentType: "json",
|
|
specifyBody: "json",
|
|
jsonBody: JSON.stringify({
|
|
model: gatewayModel,
|
|
messages: [{ role: "user", content: PROMPT }],
|
|
stream: false,
|
|
}),
|
|
options: { timeout: COMPLETION_TIMEOUT_MS }, // nocheck: raw-timeout an n8n node parameter, not a Playwright wait: n8n reaches LiteLLM inside the deployment and never crosses a Tor circuit
|
|
},
|
|
credentials: { [CREDENTIAL_TYPE]: { id: credentialId, name: credentialName } },
|
|
},
|
|
],
|
|
connections: { [TRIGGER_NODE]: { main: [[{ node: REQUEST_NODE, type: "main", index: 0 }]] } },
|
|
};
|
|
}
|
|
|
|
exports.register = function (shared) {
|
|
test("administrator: the managed openAiApi credential answers a prompt through the in-cluster gateway", async ({ page }) => {
|
|
shared.skipUnlessServiceEnabled("litellm");
|
|
test.setTimeout(resolveTimeout(360_000));
|
|
|
|
expect(credentialName, "N8N_LITELLM_CREDENTIAL_NAME must be set").toBeTruthy();
|
|
expect(gatewayBaseUrl, "N8N_LITELLM_BASE_URL must be set").toBeTruthy();
|
|
expect(gatewayModel, "N8N_LITELLM_MODEL must be set").toBeTruthy();
|
|
|
|
await signInAsOwner(page, shared);
|
|
|
|
const rest = await restClient(page, shared.env.n8nBaseUrl);
|
|
|
|
const list = await rest("GET", "/rest/credentials");
|
|
expect(
|
|
list.status,
|
|
`the owner session must be able to list credentials over /rest (HTTP ${list.status}: ${list.text})`
|
|
).toBe(200);
|
|
|
|
const managed = (Array.isArray(list.body?.data) ? list.body.data : []).filter(
|
|
(entry) => entry?.name === credentialName
|
|
);
|
|
expect(
|
|
managed,
|
|
`exactly one credential named "${credentialName}" must exist; tasks/utils/ai_gateway.yml upserts it, so 0 means the gateway wiring never ran and every AI Agent node stays unconfigured`
|
|
).toHaveLength(1);
|
|
expect(
|
|
String(managed[0]?.type),
|
|
`the managed credential must be of type ${CREDENTIAL_TYPE}; that is the only type an AI Agent node reads its endpoint and key from`
|
|
).toBe(CREDENTIAL_TYPE);
|
|
|
|
const credentialId = String(managed[0].id);
|
|
const detail = await rest("GET", `/rest/credentials/${credentialId}?includeData=true`);
|
|
expect(
|
|
detail.status,
|
|
`the owner must be able to read the managed credential with its data (HTTP ${detail.status})`
|
|
).toBe(200);
|
|
|
|
const storedUrl = String(detail.body?.data?.data?.url || "");
|
|
expect(
|
|
storedUrl.length,
|
|
"the managed credential must carry a base URL; an empty one falls back to the openAiApi vendor default at api.openai.com and sends every prompt off the deployment"
|
|
).toBeGreaterThan(0);
|
|
expect(
|
|
storedUrl,
|
|
"the stored base URL must be the in-cluster gateway address the deploy configured (N8N_LITELLM_BASE_URL)"
|
|
).toBe(gatewayBaseUrl);
|
|
|
|
const endpoint = new URL(storedUrl);
|
|
expect(
|
|
endpoint.protocol,
|
|
"the gateway endpoint must be plain http on the container network; https would mean the prompt leaves through the public ingress"
|
|
).toBe("http:");
|
|
expect(
|
|
endpoint.hostname.includes("."),
|
|
`the gateway host must be an undotted container name (got "${endpoint.hostname}"); a dotted host is completed by the container dns-search suffix and resolves through the public ingress, which is the leak the platform forbids`
|
|
).toBe(false);
|
|
expect(
|
|
endpoint.host.toLowerCase(),
|
|
"the gateway must be a distinct in-cluster service, not n8n pointing at itself"
|
|
).not.toBe(new URL(shared.env.n8nBaseUrl).host.toLowerCase());
|
|
|
|
const probe = await rest("POST", "/rest/credentials/test", {
|
|
credentials: {
|
|
id: credentialId,
|
|
name: credentialName,
|
|
type: CREDENTIAL_TYPE,
|
|
data: detail.body.data.data,
|
|
},
|
|
});
|
|
expect(
|
|
probe.status,
|
|
`n8n must accept a credential test for the managed credential (HTTP ${probe.status})`
|
|
).toBe(200);
|
|
expect(
|
|
String(probe.body?.data?.status),
|
|
`n8n tests an ${CREDENTIAL_TYPE} credential with GET ${storedUrl}/models, so anything but "OK" means n8n cannot reach the gateway or the gateway rejects this role's virtual key: ${String(probe.body?.data?.message || probe.status)}`
|
|
).toBe("OK");
|
|
|
|
const workflow = probeWorkflow(credentialId, storedUrl.replace(/\/+$/, ""));
|
|
const created = await rest("POST", "/rest/workflows", workflow);
|
|
expect(
|
|
created.status,
|
|
`n8n must accept the probe workflow that sends one prompt through the managed credential (HTTP ${created.status}: ${created.text})`
|
|
).toBeLessThan(300);
|
|
|
|
const workflowId = String(created.body?.data?.id || "");
|
|
expect(
|
|
workflowId.length,
|
|
`the created probe workflow must carry an id to run it with (HTTP ${created.status}: ${created.text})`
|
|
).toBeGreaterThan(0);
|
|
|
|
try {
|
|
const run = await rest("POST", `/rest/workflows/${workflowId}/run`, {
|
|
triggerToStartFrom: { name: TRIGGER_NODE },
|
|
});
|
|
expect(
|
|
run.status,
|
|
`n8n must accept the manual run of the probe workflow (HTTP ${run.status}: ${run.text})`
|
|
).toBeLessThan(300);
|
|
|
|
const executionId = String(run.body?.data?.executionId || "");
|
|
expect(
|
|
executionId.length,
|
|
`the manual run must report an execution id to read the answer from (HTTP ${run.status}: ${run.text})`
|
|
).toBeGreaterThan(0);
|
|
|
|
const { execution, latest } = await pollExecution(
|
|
page,
|
|
rest,
|
|
executionId,
|
|
COMPLETION_TIMEOUT_MS
|
|
);
|
|
expect(
|
|
execution,
|
|
`execution ${executionId} must reach a final state within ${COMPLETION_TIMEOUT_MS} ms (last HTTP ${latest.status}: ${latest.text})`
|
|
).toBeTruthy();
|
|
const result = reviveExecutionData(execution.data);
|
|
expect(
|
|
String(execution.status),
|
|
`the prompt must be answered through the gateway; a failed execution is n8n reaching ${storedUrl} and being refused or timed out: ${JSON.stringify(result?.resultData?.error || {}).slice(0, 400)}`
|
|
).toBe("success");
|
|
|
|
const item = result?.resultData?.runData?.[REQUEST_NODE]?.[0]?.data?.main?.[0]?.[0];
|
|
expect(
|
|
item?.json,
|
|
`the ${REQUEST_NODE} node must have produced a response item; empty runData means the node never ran and nothing was asked of the gateway`
|
|
).toBeTruthy();
|
|
|
|
const answer = String(item.json?.choices?.[0]?.message?.content || "").trim();
|
|
expect(
|
|
answer.length,
|
|
`the gateway must return non-empty assistant text over n8n -> ${storedUrl} -> ${gatewayModel} (got ${JSON.stringify(item.json).slice(0, 300)})`
|
|
).toBeGreaterThan(0);
|
|
} finally {
|
|
await rest("DELETE", `/rest/workflows/${workflowId}`).catch(() => {});
|
|
await shared.n8nLogout(page).catch(() => {});
|
|
}
|
|
});
|
|
};
|