diff --git a/nodes/workflow-launch.html b/nodes/workflow-launch.html index 96717b9..36942df 100644 --- a/nodes/workflow-launch.html +++ b/nodes/workflow-launch.html @@ -24,6 +24,12 @@ +
+ + +
    @@ -60,6 +66,7 @@ : Launchpad (string) : The human-readable name of a pipeline in the launchpad to launch. Supports autocomplete. : Run name (string) : Custom name for the workflow run (optional, defaults to auto-generated name). : Resume from (string) : Workflow ID from a previous run to resume (optional). Can be extracted from workflow monitor output using `msg.workflowId`. +: Labels (string) : Comma-separated label names to assign to the workflow run (optional), e.g., `production,rnaseq,urgent`. Labels are automatically created if they don't exist (requires API token with Maintain role or higher). The node resolves names to IDs automatically. : Parameters (array) : Individual parameter key-value pairs added via the editable list. Each parameter can be configured with a name and a value of any type (string, number, boolean, JSON, etc.). These take highest precedence when merging. : Params JSON (object) : A JSON object containing multiple parameters to merge with the launchpad's default parameters. Merged before individual parameters. : Workspace ID (string) : Override the workspace ID from the Seqera config node (optional). @@ -95,6 +102,16 @@ The resumed workflow must use the same work directory as the original run, so ensure your compute environment has access to the cached results. +#### Labels + +Labels help organize and track workflow runs in your workspace. Simply provide comma-separated label names (e.g., `production,rnaseq,urgent`) and the node will: + +1. Check if labels exist in the workspace +2. Automatically create missing labels (requires Maintain role or higher) +3. Resolve names to IDs and apply them to the workflow run + +**Token permissions:** Your API token needs Maintain role or higher to create labels automatically. Without sufficient permissions, you'll need to create labels manually in the Seqera UI first. + ### References - [Seqera Platform API docs](https://docs.seqera.io/platform/latest/api) - information about launching workflows @@ -125,6 +142,8 @@ runNameType: { value: "str" }, resumeWorkflowId: { value: "" }, resumeWorkflowIdType: { value: "str" }, + labels: { value: "" }, + labelsType: { value: "str" }, workspaceId: { value: "" }, workspaceIdType: { value: "str" }, sourceWorkspaceId: { value: "" }, @@ -156,6 +175,14 @@ $("#node-input-resumeWorkflowId").typedInput("value", this.resumeWorkflowId || ""); $("#node-input-resumeWorkflowId").typedInput("type", this.resumeWorkflowIdType || "str"); + // Initialize labels typedInput (comma-separated string by default) + $("#node-input-labels").typedInput({ + default: "str", + types: ["str", "msg", "flow", "global", "env", "jsonata", "json"], + }); + $("#node-input-labels").typedInput("value", this.labels || ""); + $("#node-input-labels").typedInput("type", this.labelsType || "str"); + ti("#node-input-workspaceId", this.workspaceId || "", this.workspaceIdType || "str"); ti("#node-input-sourceWorkspaceId", this.sourceWorkspaceId || "", this.sourceWorkspaceIdType || "str"); @@ -298,6 +325,7 @@ save.call(this, "#node-input-paramsKey", "paramsKey", "paramsKeyType"); save.call(this, "#node-input-runName", "runName", "runNameType"); save.call(this, "#node-input-resumeWorkflowId", "resumeWorkflowId", "resumeWorkflowIdType"); + save.call(this, "#node-input-labels", "labels", "labelsType"); save.call(this, "#node-input-workspaceId", "workspaceId", "workspaceIdType"); save.call(this, "#node-input-sourceWorkspaceId", "sourceWorkspaceId", "sourceWorkspaceIdType"); diff --git a/nodes/workflow-launch.js b/nodes/workflow-launch.js index 1709ea9..342c3ef 100644 --- a/nodes/workflow-launch.js +++ b/nodes/workflow-launch.js @@ -97,6 +97,8 @@ module.exports = function (RED) { node.sourceWorkspaceIdPropType = config.sourceWorkspaceIdType; node.resumeWorkflowIdProp = config.resumeWorkflowId; node.resumeWorkflowIdPropType = config.resumeWorkflowIdType; + node.labelsProp = config.labels; + node.labelsPropType = config.labelsType; node.seqeraConfig = RED.nodes.getNode(config.seqera); node.defaultBaseUrl = (node.seqeraConfig && node.seqeraConfig.baseUrl) || "https://api.cloud.seqera.io"; @@ -113,6 +115,74 @@ module.exports = function (RED) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${d.toLocaleTimeString()}`; }; + // Helper function to resolve label names to IDs, creating labels if needed + const resolveLabelIds = async (labelInput, workspaceId, baseUrl, msg) => { + if (!labelInput || !workspaceId) { + return []; + } + + // Parse label names from comma-separated string (typedInput always returns string) + // Normalize to lowercase since platform treats labels as case-insensitive + const labelNames = String(labelInput) + .split(",") + .map((l) => l.trim().toLowerCase()) + .filter(Boolean); + + if (labelNames.length === 0) { + return []; + } + + const labelIds = []; + + for (const labelName of labelNames) { + // Query for specific label by name using search parameter to avoid pagination issues + // Note: API search returns partial matches, so we use find() to ensure exact match + const searchUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}&search=${encodeURIComponent( + labelName, + )}`; + const searchResp = await apiCall(node, "get", searchUrl); + const labels = searchResp.data?.labels || []; + // Case-insensitive comparison since platform normalizes label names + let match = labels.find((l) => l.name.toLowerCase() === labelName); + + if (!match) { + // Label doesn't exist, try to create it + try { + const createUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}`; + const createResp = await apiCall(node, "post", createUrl, { + data: { name: labelName }, + }); + + if (createResp.data?.id) { + match = createResp.data; + node.log(`Created label '${labelName}' (ID: ${match.id})`); + } else { + throw new Error(`Failed to create label '${labelName}': API returned no ID`); + } + } catch (errCreate) { + const errorStatus = errCreate.response?.status; + const errorDetail = errCreate.response?.data?.message || errCreate.response?.data || errCreate.message; + + if (errorStatus === 403) { + throw new Error( + `Cannot create label '${labelName}': API token requires 'Maintain' role or higher. Use a token with sufficient permissions or create labels manually in Seqera UI.`, + ); + } else { + throw new Error(`Failed to create label '${labelName}': ${errorDetail}`); + } + } + } + + if (match?.id) { + labelIds.push(String(match.id)); + } else { + throw new Error(`Failed to resolve label '${labelName}': No ID found`); + } + } + + return labelIds; + }; + node.on("input", async function (msg, send, done) { node.status({ fill: "blue", shape: "ring", text: `launching: ${formatDateTime()}` }); @@ -137,6 +207,7 @@ module.exports = function (RED) { const workspaceIdOverride = await evalProp(node.workspaceIdProp, node.workspaceIdPropType); const sourceWorkspaceIdOverride = await evalProp(node.sourceWorkspaceIdProp, node.sourceWorkspaceIdPropType); const resumeWorkflowId = await evalProp(node.resumeWorkflowIdProp, node.resumeWorkflowIdPropType); + const labels = await evalProp(node.labelsProp, node.labelsPropType); const baseUrl = baseUrlOverride || (node.seqeraConfig && node.seqeraConfig.baseUrl) || node.defaultBaseUrl; const workspaceId = workspaceIdOverride || (node.seqeraConfig && node.seqeraConfig.workspaceId) || null; @@ -292,6 +363,22 @@ module.exports = function (RED) { } } + // Resolve label names to IDs if provided, creating labels as needed + // Applied after both regular launch and resume paths have set up body.launch + if (labels) { + body.launch = body.launch || {}; + try { + const labelIds = await resolveLabelIds(labels, workspaceId, baseUrl, msg); + if (labelIds.length > 0) { + body.launch.labelIds = labelIds; + } + } catch (errLabels) { + node.error(`Failed to resolve labels: ${errLabels.message}`, msg); + node.status({ fill: "red", shape: "ring", text: `error: ${formatDateTime()}` }); + return; + } + } + // Build URL with query params let url = `${baseUrl.replace(/\/$/, "")}/workflow/launch`; const qs = new URLSearchParams(); @@ -313,7 +400,15 @@ module.exports = function (RED) { send(outMsg); if (done) done(); } catch (err) { - node.error(`Seqera API request failed: ${err.message}\nRequest: POST ${url}`, msg); + const errorDetail = err.response?.data?.message || err.response?.data || err.message; + const errorStatus = err.response?.status; + let errorMsg = `Seqera API request failed: ${errorDetail}\nRequest: POST ${url}`; + + if (errorStatus === 403) { + errorMsg += `\n\nPermission denied. Please check:\n1. API token has 'Launch' or 'Maintain' role\n2. Workspace ID ${workspaceId} is correct\n3. Token has access to this workspace`; + } + + node.error(errorMsg, msg); node.status({ fill: "red", shape: "ring", text: `error: ${formatDateTime()}` }); return; } @@ -341,6 +436,8 @@ module.exports = function (RED) { sourceWorkspaceIdType: { value: "str" }, resumeWorkflowId: { value: "" }, resumeWorkflowIdType: { value: "str" }, + labels: { value: "" }, + labelsType: { value: "str" }, token: { value: "token" }, tokenType: { value: "str" }, }, diff --git a/test/helper.js b/test/helper.js index 3beef44..9bec45b 100644 --- a/test/helper.js +++ b/test/helper.js @@ -272,6 +272,44 @@ function createWorkspacesResponse(workspaces = []) { }; } +/** + * Creates a mock labels response object. + * + * @param {Array} labels - Labels to include + * @returns {Object} A labels response object + */ +function createLabelsResponse(labels = []) { + const defaultLabels = [ + { + id: "1", + name: "production", + resource: true, + isDefault: false, + lastUsed: "2024-01-01T00:00:00Z", + }, + ]; + + return { + labels: labels.length > 0 ? labels : defaultLabels, + }; +} + +/** + * Creates a mock label creation response object. + * + * @param {Object} overrides - Properties to override + * @returns {Object} A label response object + */ +function createLabelResponse(overrides = {}) { + return { + id: "1", + name: "test-label", + resource: true, + isDefault: false, + ...overrides, + }; +} + /** * Helper to wait for a node to emit a message. * Wraps the assertion in try/catch to properly report failures. @@ -334,6 +372,8 @@ module.exports = { createUserInfoResponse, createOrganizationsResponse, createWorkspacesResponse, + createLabelsResponse, + createLabelResponse, expectMessage, expectMessages, }; diff --git a/test/workflow-launch_spec.js b/test/workflow-launch_spec.js index ee9e65a..17e1ac5 100644 --- a/test/workflow-launch_spec.js +++ b/test/workflow-launch_spec.js @@ -6,6 +6,7 @@ * - Parameter merging (paramsJSON and params array) * - Resume workflow functionality * - Custom run names + * - Label resolution and creation * - Error handling */ @@ -19,6 +20,8 @@ const { createPipelinesResponse, createLaunchConfigResponse, createWorkflowResponse, + createLabelsResponse, + createLabelResponse, } = require("./helper"); const { expect } = require("chai"); @@ -567,6 +570,513 @@ describe("seqera-workflow-launch Node", function () { }); }); + describe("labels", function () { + it("should resolve existing label names to IDs and launch with labels", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "production,rnaseq", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label search for "production" + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "production" }) + .reply(200, createLabelsResponse([{ id: "101", name: "production" }])); + + // Mock label search for "rnaseq" + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "rnaseq" }) + .reply(200, createLabelsResponse([{ id: "102", name: "rnaseq" }])); + + // Mock workflow launch - verify labelIds are included + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return ( + body.launch.labelIds && + body.launch.labelIds.length === 2 && + body.launch.labelIds.includes("101") && + body.launch.labelIds.includes("102") + ); + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should create missing labels automatically when they don't exist", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "newlabel", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label search - return empty results + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "newlabel" }) + .reply(200, createLabelsResponse([])); + + // Mock label creation + nock(DEFAULT_BASE_URL) + .post("/labels", { name: "newlabel" }) + .query({ workspaceId: DEFAULT_WORKSPACE_ID }) + .reply(200, createLabelResponse({ id: "201", name: "newlabel" })); + + // Mock workflow launch + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return body.launch.labelIds && body.launch.labelIds.includes("201"); + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should handle case-insensitive label matching", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "Production", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label search - API normalizes to lowercase + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "production" }) + .reply(200, createLabelsResponse([{ id: "101", name: "production" }])); + + // Mock workflow launch + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return body.launch.labelIds && body.launch.labelIds.includes("101"); + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should handle empty labels gracefully", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock workflow launch - should not include labelIds + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return !body.launch.labelIds || body.launch.labelIds.length === 0; + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should handle whitespace in comma-separated labels", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: " production , rnaseq , urgent ", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label searches - should trim whitespace + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "production" }) + .reply(200, createLabelsResponse([{ id: "101", name: "production" }])); + + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "rnaseq" }) + .reply(200, createLabelsResponse([{ id: "102", name: "rnaseq" }])); + + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "urgent" }) + .reply(200, createLabelsResponse([{ id: "103", name: "urgent" }])); + + // Mock workflow launch + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return body.launch.labelIds && body.launch.labelIds.length === 3; + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should error when lacking permission to create labels", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "newlabel", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label search - return empty results + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "newlabel" }) + .reply(200, createLabelsResponse([])); + + // Mock label creation - return 403 forbidden + nock(DEFAULT_BASE_URL) + .post("/labels", { name: "newlabel" }) + .query({ workspaceId: DEFAULT_WORKSPACE_ID }) + .reply(403, { message: "Insufficient permissions" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + + launchNode.on("call:error", function (call) { + try { + expect(call.firstArg).to.include("Cannot create label 'newlabel'"); + expect(call.firstArg).to.include("Maintain"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should work with labels in resume workflow", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + resumeWorkflowId: "wf-original-123", + resumeWorkflowIdType: "str", + labels: "resumed,retry", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock get workflow details + nock(DEFAULT_BASE_URL) + .get("/workflow/wf-original-123") + .query(true) + .reply( + 200, + createWorkflowResponse({ + id: "wf-original-123", + commitId: "abc123", + }), + ); + + // Mock get workflow launch config + nock(DEFAULT_BASE_URL) + .get("/workflow/wf-original-123/launch") + .query(true) + .reply( + 200, + createLaunchConfigResponse({ + sessionId: "session-123", + resumeCommitId: "abc123", + }), + ); + + // Mock label searches + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "resumed" }) + .reply(200, createLabelsResponse([{ id: "301", name: "resumed" }])); + + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "retry" }) + .reply(200, createLabelsResponse([{ id: "302", name: "retry" }])); + + // Mock workflow launch with resume and labels + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return ( + body.launch.resume === true && + body.launch.labelIds && + body.launch.labelIds.includes("301") && + body.launch.labelIds.includes("302") + ); + }) + .query(true) + .reply(200, { workflowId: "wf-resumed-456" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-resumed-456"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ + payload: { launch: {} }, + }); + }); + }); + + it("should work with labels from message property", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "customLabels", + labelsType: "msg", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label search + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "dynamic" }) + .reply(200, createLabelsResponse([{ id: "401", name: "dynamic" }])); + + // Mock workflow launch + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return body.launch.labelIds && body.launch.labelIds.includes("401"); + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ + payload: {}, + customLabels: "dynamic", + }); + }); + }); + }); + describe("error handling", function () { it("should report error when no body and no launchpad name", function (done) { const flow = [