diff --git a/README.md b/README.md index a825360..305c89d 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ need to perform some initial setup steps before you can develop your action. 1. :building_construction: Package the TypeScript for distribution ```bash - npm fetchEntries bundle + npm runAction bundle ``` 1. :white_check_mark: Run the tests @@ -84,19 +84,19 @@ inputs, and outputs for your action. ## Update the Action Code The [`src/`](./src/) directory is the heart of your action! This contains the -source code that will be fetchEntries when your action is invoked. You can replace the +source code that will be runAction when your action is invoked. You can replace the contents of this directory with your own code. There are a few things to keep in mind when writing your action code: - Most GitHub Actions toolkit and CI/CD operations are processed asynchronously. - In `main.ts`, you will see that the action is fetchEntries in an `async` function. + In `main.ts`, you will see that the action is runAction in an `async` function. ```javascript import * as core from '@actions/core' //... - async function fetchEntries() { + async function runAction() { try { //... } catch (error) { @@ -121,14 +121,14 @@ So, what are you waiting for? Go ahead and start customizing your action! 1. Format, test, and build the action ```bash - npm fetchEntries all + npm runAction all ``` > [!WARNING] > - > This step is important! It will fetchEntries [`ncc`](https://github.com/vercel/ncc) + > This step is important! It will runAction [`ncc`](https://github.com/vercel/ncc) > to build the final JavaScript action code with all dependencies included. - > If you do not fetchEntries this step, your action will not work correctly when it is + > If you do not runAction this step, your action will not work correctly when it is > used in a workflow. This step also includes the `--license` option for > `ncc`, which will create a license file for all of the production node > modules used in your project. @@ -175,7 +175,7 @@ steps: - name: Print Output id: output - fetchEntries: echo "${{ steps.test-action.outputs.time }}" + runAction: echo "${{ steps.test-action.outputs.time }}" ``` For example workflow runs, check out the @@ -206,7 +206,7 @@ steps: - name: Print Output id: output - fetchEntries: echo "${{ steps.test-action.outputs.time }}" + runAction: echo "${{ steps.test-action.outputs.time }}" ``` ## Publishing a new release diff --git a/package.json b/package.json index 5e904ea..8976a02 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "jest": { "preset": "ts-jest", "verbose": true, + "resetMocks": true, "testEnvironment": "node", "moduleFileExtensions": [ "js", diff --git a/src/extractors/extractFeed.ts b/src/extractors/extractFeed.ts index 5c78a7e..1ce7e91 100644 --- a/src/extractors/extractFeed.ts +++ b/src/extractors/extractFeed.ts @@ -2,7 +2,7 @@ import { FeedWithoutEntriesError } from "./errors"; import { feedExtractor } from "./helpers"; import { FeedData, FeedEntry } from "./types/feed-extractor"; -type FeedWithEntries = FeedData & { entries: FeedEntry[] }; +export type FeedWithEntries = FeedData & { entries: FeedEntry[] }; export async function extractFeed(feedUrl: URL) { const extract = await feedExtractor(); diff --git a/src/index.test.ts b/src/index.test.ts index 9456860..8f64154 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -4,13 +4,13 @@ import * as main from "./main"; -jest.spyOn(main, "fetchEntries"); +jest.spyOn(main, "runAction"); describe("index", () => { it("calls run when imported", async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports require("./index"); - expect(main.fetchEntries).toHaveBeenCalled(); + expect(main.runAction).toHaveBeenCalled(); }); }); diff --git a/src/index.ts b/src/index.ts index ffd1815..cf3c07e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,6 @@ * The entrypoint for the action. */ -import { fetchEntries } from "./main"; +import { runAction } from "./main"; -void fetchEntries(); +void runAction(); diff --git a/src/logger.ts b/src/logger.ts index 61d8de7..346b403 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,6 +1,6 @@ -import { createLogger, format, transports } from "winston"; +import { createLogger, format, Logger, transports } from "winston"; -export const logger = createLogger({ +const logger: Logger = createLogger({ level: "info", format: format.combine( format.timestamp({ @@ -17,3 +17,5 @@ export const logger = createLogger({ }), ], }); + +export default logger; diff --git a/src/main.test.ts b/src/main.test.ts index ccfac69..0e987e8 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -2,18 +2,57 @@ * Unit tests for the action's entrypoint, src/index.ts */ -import { fetchEntries } from "./main"; -import requireActual = jest.requireActual; +import { setFailed } from "@actions/core"; -jest.mock("@extractus/feed-extractor", () => jest.fn()); +import { runAction } from "./main"; +import { processFeed } from "./processors/processFeed"; +import { InvalidUrlError, UnknownError } from "./utils/errors"; +import { getInputFeedUrls } from "./utils/io"; -jest.mock("./utils/utils", () => ({ - ...requireActual("./utils"), - getInputFeedUrls: jest.fn(() => ["https://www.google.com"]), -})); +jest.mock("@actions/core"); +jest.mock("./processors/processFeed"); +jest.mock("./utils/io"); -describe("fetchEntries", () => { - it("calls run when imported", async () => { - await fetchEntries(); +describe("runAction", () => { + const setFailedMock = setFailed as jest.MockedFunction; + const getInputFeedUrlsMock = getInputFeedUrls as jest.MockedFunction< + typeof getInputFeedUrls + >; + const processFeedMock = processFeed as jest.MockedFunction< + typeof processFeed + >; + + it("returns error if something unexpected happens", async () => { + const thrownError = new Error(); + const expectedError = new UnknownError(thrownError); + getInputFeedUrlsMock.mockImplementation(() => { + throw thrownError; + }); + + await runAction(); + + expect(setFailedMock).toHaveBeenCalledWith(expectedError.message); + }); + + it("returns error if the feed urls throw", async () => { + const expectedError = new InvalidUrlError("asd", new Error()); + getInputFeedUrlsMock.mockImplementation(() => { + throw expectedError; + }); + + await runAction(); + + expect(setFailedMock).toHaveBeenCalledWith(expectedError.message); + }); + + it("starts processing the feed if everything is ok", async () => { + const expectedUrl = new URL("https://www.google.com"); + const expectedUrl2 = new URL("https://www.google.com.uy"); + getInputFeedUrlsMock.mockReturnValue([expectedUrl, expectedUrl2]); + + await runAction(); + + expect(processFeedMock).toHaveBeenCalledWith(expectedUrl); + expect(processFeedMock).toHaveBeenCalledWith(expectedUrl2); }); }); diff --git a/src/main.ts b/src/main.ts index 828be28..8908020 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,54 +1,18 @@ import { setFailed } from "@actions/core"; import { CustomError } from "ts-custom-error"; -import { extractArticle } from "./extractors/extractArticle"; -import { extractFeed } from "./extractors/extractFeed"; -import { FeedEntry } from "./extractors/types/feed-extractor"; -import { logger } from "./logger"; +import { processFeed } from "./processors/processFeed"; import { UnknownError } from "./utils/errors"; -import { storeFile } from "./utils/fs"; -import { buildFilename, getInputFeedUrls, getOutputDir } from "./utils/io"; +import { getInputFeedUrls } from "./utils/io"; -export async function fetchEntries() { +export async function runAction() { try { const feedUrls = getInputFeedUrls(); - - feedUrls.map(async (feedUrl: URL) => { - try { - const feedData = await extractFeed(feedUrl); - void Promise.all(feedData.entries.map(processEntry)); - } catch (err) { - if (err instanceof CustomError) { - logger.warn(err.message); - return; - } - - setFailed(new UnknownError(err).message); - return; - } - }); - } catch (error) { - setFailed(new UnknownError(error).message); - return; - } -} - -async function processEntry(feedEntry: FeedEntry) { - const outputDir = getOutputDir(); - try { - const article = await extractArticle(feedEntry); - const filename = buildFilename(article.url); - const destinationFile = `${outputDir}/${filename}.json`; - const fileContents = JSON.stringify(article, null, 2); - storeFile(destinationFile, fileContents); - logger.info( - `New article stored: "%s". Path: "%s"`, - article.url, - destinationFile, - ); + feedUrls.forEach((feedUrl) => processFeed(feedUrl)); } catch (err) { + // Even if it's a custom error, we want to return error for the process here if (err instanceof CustomError) { - logger.warn(err.message); + setFailed(err.message); return; } diff --git a/src/processors/processEntry.ts b/src/processors/processEntry.ts new file mode 100644 index 0000000..6781d46 --- /dev/null +++ b/src/processors/processEntry.ts @@ -0,0 +1,33 @@ +import { setFailed } from "@actions/core"; +import { CustomError } from "ts-custom-error"; + +import { extractArticle } from "../extractors/extractArticle"; +import { FeedEntry } from "../extractors/types/feed-extractor"; +import logger from "../logger"; +import { UnknownError } from "../utils/errors"; +import { storeFile } from "../utils/fs"; +import { buildFilename, getOutputDir } from "../utils/io"; + +export async function processEntry(feedEntry: FeedEntry) { + const outputDir = getOutputDir(); + try { + const article = await extractArticle(feedEntry); + const filename = buildFilename(article.url); + const destinationFile = `${outputDir}/${filename}.json`; + const fileContents = JSON.stringify(article, null, 2); + storeFile(destinationFile, fileContents); + logger.info( + `New article stored: "%s". Path: "%s"`, + article.url, + destinationFile, + ); + } catch (err) { + if (err instanceof CustomError) { + logger.warn(err.message); + return; + } + + setFailed(new UnknownError(err).message); + return; + } +} diff --git a/src/processors/processFeed.test.ts b/src/processors/processFeed.test.ts new file mode 100644 index 0000000..56e78c6 --- /dev/null +++ b/src/processors/processFeed.test.ts @@ -0,0 +1,61 @@ +import { setFailed } from "@actions/core"; + +import { FeedWithoutEntriesError } from "../extractors/errors"; +import { extractFeed, FeedWithEntries } from "../extractors/extractFeed"; +import { FeedEntry } from "../extractors/types/feed-extractor"; +import logger from "../logger"; +import { UnknownError } from "../utils/errors"; +import { processEntry } from "./processEntry"; +import { processFeed } from "./processFeed"; + +jest.mock("@actions/core"); +jest.mock("../extractors/extractFeed"); +jest.mock("../logger"); +jest.mock("./processEntry"); + +describe("processor", () => { + describe("processFeed", () => { + logger.warn = jest.fn(); + const setFailedMock = setFailed as jest.MockedFunction; + const extractFeedMock = extractFeed as jest.MockedFunction< + typeof extractFeed + >; + const processEntryMock = processEntry as jest.MockedFunction< + typeof processEntry + >; + + it("returns error if something unexpected happens", async () => { + const url = new URL("https://www.google.com"); + const thrownError = new Error("Unpredictable error"); + const expectedError = new UnknownError(thrownError); + + extractFeedMock.mockRejectedValue(thrownError); + + await processFeed(url); + expect(logger.warn).not.toHaveBeenCalled(); + expect(setFailedMock).toHaveBeenCalledWith(expectedError.message); + }); + + it("warns the error if something happens", async () => { + const url = new URL("https://www.google.com"); + const expectedError = new FeedWithoutEntriesError(url, {}); + extractFeedMock.mockRejectedValue(expectedError); + + await processFeed(url); + expect(logger.warn).toHaveBeenCalledWith(expectedError.message); + expect(setFailedMock).not.toHaveBeenCalled(); + }); + + it("process the entries", async () => { + const entry = { id: "test-entry" } as FeedEntry; + const feedData = { entries: [entry] } as FeedWithEntries; + extractFeedMock.mockResolvedValue(feedData); + + await processFeed(new URL("https://www.google.com")); + + expect(processEntryMock).toHaveBeenCalledWith(entry); + expect(logger.warn).not.toHaveBeenCalledWith(); + expect(setFailedMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/processors/processFeed.ts b/src/processors/processFeed.ts new file mode 100644 index 0000000..ea5ca9f --- /dev/null +++ b/src/processors/processFeed.ts @@ -0,0 +1,22 @@ +import { setFailed } from "@actions/core"; +import { CustomError } from "ts-custom-error"; + +import { extractFeed } from "../extractors/extractFeed"; +import logger from "../logger"; +import { UnknownError } from "../utils/errors"; +import { processEntry } from "./processEntry"; + +export async function processFeed(feedUrl: URL) { + try { + const feedData = await extractFeed(feedUrl); + void Promise.all(feedData.entries.map((entry) => processEntry(entry))); + } catch (err) { + if (err instanceof CustomError) { + logger.warn(err.message); + return; + } + + setFailed(new UnknownError(err).message); + return; + } +} diff --git a/src/utils/io.test.ts b/src/utils/io.test.ts index 0f2c56b..1761043 100644 --- a/src/utils/io.test.ts +++ b/src/utils/io.test.ts @@ -7,9 +7,7 @@ describe("io", () => { beforeEach(() => { process.env = { ...originalEnv }; }); - afterEach(() => { - jest.resetModules(); - }); + describe("getInputFeedUrls", () => { it("throws NoUrlsGivenError if INPUT_FEED_URL is an empty string", () => { process.env.INPUT_FEED_URL = "";