import assert from "node:assert"; import { dirname, join } from "node:path"; import { beforeEach, describe, it } from "node:test"; import { fileURLToPath } from "node:url"; import { YAMLException } from "js-yaml"; import Config from "../src/config.js"; import Content from "../src/content.js"; import SlackError from "../src/errors.js"; import send from "../src/send.js"; import { mocks } from "./index.spec.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURES = join(__dirname, "fixtures"); /** * Confirm values from the action input or environment variables are gathered */ describe("content", () => { beforeEach(() => { mocks.reset(); mocks.inputs = { ...mocks.inputs, method: "chat.postMessage", token: "xoxb-example", }; // Set up default apiCall mock for tests that use send() mocks.calls._resolvesWith = { ok: true }; }); describe("flatten", () => { it("flattens nested payloads provided with delimiter", async () => { mocks.inputs = { ...mocks.inputs, payload: ` "apples": "tree", "bananas": { "truthiness": true } `, "payload-delimiter": "_", }; const config = new Config(mocks.core); const expected = { apples: "tree", bananas_truthiness: "true", }; assert.deepEqual(config.content.values, expected); }); }); describe("get", () => { it("errors if both a payload and file path are provided", async () => { mocks.inputs = { ...mocks.inputs, payload: `"message"="hello"`, "payload-file-path": "example.json", }; try { await send(mocks.core); assert.fail("Failed to throw for invalid input"); } catch (err) { if (err instanceof SlackError) { assert.ok( err.message.includes( "Invalid input! Just the payload or payload file path is required.", ), ); } else { assert.fail(err); } } }); }); describe("payload", async () => { it("parses complete YAML from the input payload", async () => { mocks.inputs = { ...mocks.inputs, payload: ` message: "this is wrapped" channel: "C0123456789" `, }; const config = new Config(mocks.core); const expected = { message: "this is wrapped", channel: "C0123456789", }; assert.deepEqual(config.content.values, expected); }); it("parses complete JSON from the input payload", async () => { mocks.inputs = { ...mocks.inputs, payload: `{ "message": "this is wrapped", "channel": "C0123456789" } `, }; const config = new Config(mocks.core); const expected = { message: "this is wrapped", channel: "C0123456789", }; assert.deepEqual(config.content.values, expected); }); it("templatizes variables requires configuration", async () => { mocks.inputs = { ...mocks.inputs, payload: `{ "message": "this matches an existing variable: \${{ github.apiUrl }}", "channel": "C0123456789" } `, }; const config = new Config(mocks.core); // biome-ignore-start lint/suspicious/noTemplateCurlyInString: GitHub Action YAML variable syntax const expected = { message: "this matches an existing variable: ${{ github.apiUrl }}", channel: "C0123456789", }; // biome-ignore-end lint/suspicious/noTemplateCurlyInString: https://docs.github.com/en/actions/how-tos/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#using-contexts-to-access-variable-values assert.deepEqual(config.content.values, expected); }); it("templatizes variables with matching variables", async () => { mocks.inputs = { ...mocks.inputs, payload: ` channel: C0123456789 reply_broadcast: false message: Served \${{ env.NUMBER }} items blocks: - type: section text: type: mrkdwn text: "Served \${{ env.NUMBER }} items on: \${{ env.DETAILS }}" - type: divider - type: section block_id: selector text: type: mrkdwn text: Send feedback accessory: action_id: response type: multi_static_select placeholder: type: plain_text text: Select URL options: - text: type: plain_text text: "\${{ github.apiUrl }}" value: api - text: type: plain_text text: "\${{ github.serverUrl }}" value: server - text: type: plain_text text: "\${{ github.graphqlUrl }}" value: graphql `, }; mocks.booleanInputs = { ...mocks.booleanInputs, "payload-templated": true, }; process.env.DETAILS = ` -fri -sat -sun`; process.env.NUMBER = 12; const config = new Config(mocks.core); process.env.DETAILS = undefined; process.env.NUMBER = undefined; const expected = { channel: "C0123456789", reply_broadcast: false, message: "Served 12 items", blocks: [ { type: "section", text: { type: "mrkdwn", text: "Served 12 items on: \n-fri\n-sat\n-sun", }, }, { type: "divider", }, { type: "section", block_id: "selector", text: { type: "mrkdwn", text: "Send feedback", }, accessory: { action_id: "response", type: "multi_static_select", placeholder: { type: "plain_text", text: "Select URL", }, options: [ { text: { type: "plain_text", text: "https://api.github.com", }, value: "api", }, { text: { type: "plain_text", text: "https://github.com", }, value: "server", }, { text: { type: "plain_text", text: "https://api.github.com/graphql", }, value: "graphql", }, ], }, }, ], }; assert.deepEqual(config.content.values, expected); }); /** * @see {@link https://github.com/slackapi/slack-github-action/issues/203} */ it("templatizes variables with missing variables", async () => { // biome-ignore-start lint/suspicious/noTemplateCurlyInString: GitHub Action YAML variable syntax mocks.inputs = { ...mocks.inputs, payload: "message: What makes ${{ env.TREASURE }} a secret", }; // biome-ignore-end lint/suspicious/noTemplateCurlyInString: https://docs.github.com/en/actions/how-tos/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#using-contexts-to-access-variable-values mocks.booleanInputs = { ...mocks.booleanInputs, "payload-templated": true, }; const config = new Config(mocks.core); const expected = { message: "What makes ??? a secret", }; assert.deepEqual(config.content.values, expected); }); it("trims last comma JSON with the input payload", async () => { mocks.inputs = { ...mocks.inputs, payload: ` "message": "LGTM!", "channel": "C0123456789", `, }; const config = new Config(mocks.core); const expected = { message: "LGTM!", channel: "C0123456789", }; assert.deepEqual(config.content.values, expected); }); it("wraps incomplete JSON from the input payload", async () => { mocks.inputs = { ...mocks.inputs, payload: ` "message": "LGTM!", "channel": "C0123456789", "blocks": [ { "type": "section", "text": { "text": "LGTM! :+1:" } } ] `, }; const config = new Config(mocks.core); const expected = { message: "LGTM!", channel: "C0123456789", blocks: [ { type: "section", text: { text: "LGTM! :+1:", }, }, ], }; assert.deepEqual(config.content.values, expected); }); it("fails if no payload content is provided in input", async () => { /** * @type {Config} */ const config = { core: mocks.core, inputs: { payloadFilePath: "unknown.json", }, }; try { new Content().getContentPayload(config); assert.fail("Failed to throw for missing payload content"); } catch (err) { if (err instanceof SlackError) { assert.ok( err.message.includes( "Invalid input! No payload content was provided", ), ); } else { assert.fail(err); } } }); it("fails if invalid JSON exists in the input payload", async () => { mocks.inputs = { ...mocks.inputs, payload: "{", }; try { await send(mocks.core); assert.fail("Failed to throw for invalid JSON"); } catch (err) { if (err instanceof SlackError) { assert.ok( err.message.includes( "Invalid input! Failed to parse contents of the provided payload", ), ); assert.notStrictEqual(err.cause?.values, undefined); assert.equal(err.cause.values.length, 2); const [jsonError, yamlError] = err.cause.values; assert.ok(jsonError instanceof SyntaxError); assert.ok(yamlError instanceof YAMLException); } else { assert.fail(err); } } }); }); describe("payload file", async () => { it("parses complete YAML from the input payload file", async () => { mocks.inputs = { ...mocks.inputs, "payload-file-path": join(FIXTURES, "payload.yaml"), }; const config = new Config(mocks.core); const expected = { message: "drink water", channel: "C6H12O6H2O2", }; assert.deepEqual(config.content.values, expected); }); it("parses complete YML from the input payload file", async () => { mocks.inputs = { ...mocks.inputs, "payload-file-path": join(FIXTURES, "payload.yml"), }; const config = new Config(mocks.core); const expected = { message: "drink coffee", channel: "C0FFEEEEEEEE", }; assert.deepEqual(config.content.values, expected); }); it("parses complete JSON from the input payload file", async () => { mocks.inputs = { ...mocks.inputs, "payload-file-path": join(FIXTURES, "payload.json"), }; const config = new Config(mocks.core); const expected = { message: "drink water", channel: "C6H12O6H2O2", }; assert.deepEqual(config.content.values, expected); }); it("templatizes variables requires configuration", async () => { mocks.inputs = { ...mocks.inputs, "payload-file-path": join(FIXTURES, "payload-with-github-vars.json"), }; const config = new Config(mocks.core); // biome-ignore-start lint/suspicious/noTemplateCurlyInString: GitHub Action YAML variable syntax const expected = { message: "this matches an existing variable: ${{ github.apiUrl }}", channel: "C0123456789", }; // biome-ignore-end lint/suspicious/noTemplateCurlyInString: https://docs.github.com/en/actions/how-tos/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#using-contexts-to-access-variable-values assert.deepEqual(config.content.values, expected); }); it("templatizes variables with matching variables", async () => { mocks.inputs = { ...mocks.inputs, "payload-file-path": join(FIXTURES, "payload-with-env-vars.json"), }; mocks.booleanInputs = { ...mocks.booleanInputs, "payload-templated": true, }; process.env.DETAILS = ` -fri -sat -sun`; process.env.NUMBER = 12; const config = new Config(mocks.core); process.env.DETAILS = undefined; process.env.NUMBER = undefined; const expected = { channel: "C0123456789", reply_broadcast: false, message: "Served 12 items", blocks: [ { type: "section", text: { type: "mrkdwn", text: "Served 12 items on: \n-fri\n-sat\n-sun", }, }, { type: "divider", }, { type: "section", block_id: "selector", text: { type: "mrkdwn", text: "Send feedback", }, accessory: { action_id: "response", type: "multi_static_select", placeholder: { type: "plain_text", text: "Select URL", }, options: [ { text: { type: "plain_text", text: "https://api.github.com", }, value: "api", }, { text: { type: "plain_text", text: "https://github.com", }, value: "server", }, { text: { type: "plain_text", text: "https://api.github.com/graphql", }, value: "graphql", }, ], }, }, ], }; assert.deepEqual(config.content.values, expected); }); /** * @see {@link https://github.com/slackapi/slack-github-action/issues/203} */ it("templatizes variables with missing variables", async () => { mocks.inputs = { ...mocks.inputs, "payload-file-path": join(FIXTURES, "payload-with-missing-var.json"), }; mocks.booleanInputs = { ...mocks.booleanInputs, "payload-templated": true, }; const config = new Config(mocks.core); const expected = { message: "What makes ??? a secret", }; assert.deepEqual(config.content.values, expected); }); it("fails if no payload file is provided in the input", async () => { /** * @type {Config} */ const config = { core: mocks.core, inputs: { payload: "LGTM", }, }; try { new Content().getContentPayloadFilePath(config); assert.fail("Failed to throw for the wrong payload type"); } catch (err) { if (err instanceof SlackError) { assert.ok( err.message.includes("Invalid input! No payload found for content"), ); } else { assert.fail(err); } } }); it("fails to parse a file path that does not exist", async () => { mocks.inputs = { ...mocks.inputs, "payload-file-path": join(FIXTURES, "nonexistent.json"), }; try { await send(mocks.core); assert.fail("Failed to throw for nonexistent files"); } catch (err) { if (err instanceof SlackError) { assert.ok( err.message.includes( "Invalid input! Failed to parse contents of the provided payload file", ), ); } else { assert.fail(err); } } }); it("fails to parse a file with an unknown extension", async () => { mocks.inputs = { ...mocks.inputs, "payload-file-path": join(FIXTURES, "payload.yaml.md"), }; try { await send(mocks.core); assert.fail("Failed to throw for an unknown extension"); } catch (err) { if (err instanceof SlackError) { assert.ok( err.message.includes( "Invalid input! Failed to parse contents of the provided payload file", ), ); assert.notStrictEqual(err.cause?.values, undefined); assert.equal(err.cause.values.length, 1); assert.ok( err.cause.values[0].message.includes( "Invalid input! Failed to parse file extension", ), ); } else { assert.fail(err); } } }); it("fails if invalid JSON exists in the input payload", async () => { mocks.inputs = { ...mocks.inputs, "payload-file-path": join(FIXTURES, "payload-invalid.json"), }; try { await send(mocks.core); assert.fail("Failed to throw for invalid JSON"); } catch (err) { if (err instanceof SlackError) { assert.ok( err.message.includes( "Invalid input! Failed to parse contents of the provided payload file", ), ); assert.notStrictEqual(err.cause?.values, undefined); assert.equal(err.cause.values.length, 1); assert.ok(err.cause.values[0] instanceof SyntaxError); } else { assert.fail(err); } } }); it("fails if invalid YAML exists in the input payload", async () => { mocks.inputs = { ...mocks.inputs, "payload-file-path": join(FIXTURES, "payload-invalid.yaml"), }; try { await send(mocks.core); assert.fail("Failed to throw for invalid YAML"); } catch (err) { if (err instanceof SlackError) { assert.ok( err.message.includes( "Invalid input! Failed to parse contents of the provided payload file", ), ); assert.notStrictEqual(err.cause?.values, undefined); assert.equal(err.cause.values.length, 1); assert.ok(err.cause.values[0] instanceof YAMLException); } else { assert.fail(err); } } }); }); });