From ad62816cf4b92ecffbd7ac39b225806390a0584b Mon Sep 17 00:00:00 2001 From: Saiichi Hashimoto Date: Mon, 15 Jan 2024 14:57:57 -0600 Subject: [PATCH 1/4] fix(perf): debounce handler --- package-lock.json | 11 +++- package.json | 2 + src/index.test.ts | 137 +++++++++++++++++++++++++--------------------- src/index.ts | 5 +- 4 files changed, 89 insertions(+), 66 deletions(-) diff --git a/package-lock.json b/package-lock.json index 93b00ce..23527ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "commander": "11.1.0", "croner": "8.0.0", "cronstrue": "2.47.0", + "lodash": "4.17.21", "pino": "8.17.2", "pino-pretty": "10.3.1", "zod": "3.22.4" @@ -31,6 +32,7 @@ "@total-typescript/ts-reset": "0.5.1", "@trivago/prettier-plugin-sort-imports": "4.3.0", "@types/lint-staged": "13.3.0", + "@types/lodash": "4.14.202", "@types/uuid": "9.0.7", "@typescript-eslint/eslint-plugin": "6.18.1", "concurrently": "8.2.2", @@ -3743,6 +3745,12 @@ "integrity": "sha512-WxGjVP+rA4OJlEdbZdT9MS9PFKQ7kVPhLn26gC+2tnBWBEFEj/KW+IbFfz6sxdxY5U6V7BvyF+3BzCGsAMHhNg==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.14.202", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", + "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", + "dev": true + }, "node_modules/@types/node": { "version": "20.11.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", @@ -10644,8 +10652,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash-es": { "version": "4.17.21", diff --git a/package.json b/package.json index 1ed6a87..0f3927f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "commander": "11.1.0", "croner": "8.0.0", "cronstrue": "2.47.0", + "lodash": "4.17.21", "pino": "8.17.2", "pino-pretty": "10.3.1", "zod": "3.22.4" @@ -44,6 +45,7 @@ "@total-typescript/ts-reset": "0.5.1", "@trivago/prettier-plugin-sort-imports": "4.3.0", "@types/lint-staged": "13.3.0", + "@types/lodash": "4.14.202", "@types/uuid": "9.0.7", "@typescript-eslint/eslint-plugin": "6.18.1", "concurrently": "8.2.2", diff --git a/src/index.test.ts b/src/index.test.ts index 21c5806..cbed3c1 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -119,11 +119,13 @@ describe("main", () => { ]); expect(winner).toBe("timeout"); - expect(destination.logs).toContainEqual({ - level: 30, - time: 1696486441293, - msg: "No CRONs Scheduled", - }); + expect(destination.logs).toStrictEqual([ + { + level: 30, + time: 1696486441293, + msg: "No CRONs Scheduled", + }, + ]); }); it("dry ends the process immediately", async () => { @@ -150,11 +152,13 @@ describe("main", () => { ]); expect(winner).not.toBe("timeout"); - expect(destination.logs).toContainEqual({ - level: 30, - time: 1696486441293, - msg: "No CRONs Scheduled", - }); + expect(destination.logs).toStrictEqual([ + { + level: 30, + time: 1696486441293, + msg: "No CRONs Scheduled", + }, + ]); }); it("executes CRON when schedule passes", async () => { @@ -176,11 +180,13 @@ describe("main", () => { await jest.advanceTimersByTimeAsync(0); expect(fetchSpy).not.toHaveBeenCalled(); - expect(destination.logs).toContainEqual({ - level: 30, - msg: "Scheduled /some-api Every second", - time: 1696486441293, - }); + expect(destination.logs).toStrictEqual([ + { + level: 30, + msg: "Scheduled /some-api Every second", + time: 1696486441293, + }, + ]); destination.clear(); await jest.advanceTimersByTimeAsync(1000); @@ -195,20 +201,22 @@ describe("main", () => { }) ); expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(destination.logs).toContainEqual({ - currentRun: "2023-10-05T06:14:02.000Z", - level: 30, - msg: "Started /some-api Every second", - time: 1696486442000, - }); - expect(destination.logs).toContainEqual({ - currentRun: "2023-10-05T06:14:02.000Z", - level: 30, - msg: "Succeeded /some-api Every second", - status: 200, - text: "", - time: 1696486442000, - }); + expect(destination.logs).toStrictEqual([ + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Started /some-api Every second", + time: 1696486442000, + }, + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Succeeded /some-api Every second", + status: 200, + text: "", + time: 1696486442000, + }, + ]); destination.clear(); await jest.advanceTimersByTimeAsync(1000); @@ -223,24 +231,26 @@ describe("main", () => { }) ); expect(fetchSpy).toHaveBeenCalledTimes(2); - expect(destination.logs).toContainEqual({ - currentRun: "2023-10-05T06:14:03.000Z", - level: 30, - msg: "Started /some-api Every second", - time: 1696486443000, - }); - expect(destination.logs).toContainEqual({ - currentRun: "2023-10-05T06:14:03.000Z", - level: 30, - msg: "Succeeded /some-api Every second", - status: 200, - text: "", - time: 1696486443000, - }); + expect(destination.logs).toStrictEqual([ + { + currentRun: "2023-10-05T06:14:03.000Z", + level: 30, + msg: "Started /some-api Every second", + time: 1696486443000, + }, + { + currentRun: "2023-10-05T06:14:03.000Z", + level: 30, + msg: "Succeeded /some-api Every second", + status: 200, + text: "", + time: 1696486443000, + }, + ]); }); it("misses CRON if config changes", async () => { - const { fs } = memfs( + const { fs, vol } = memfs( { "./vercel.json": JSON.stringify({ crons: [{ path: "/some-api", schedule: "* * * * * *" }], @@ -252,34 +262,37 @@ describe("main", () => { proc = main({ destination, fs, - level: "trace", signal: controller.signal, }); await jest.advanceTimersByTimeAsync(0); expect(fetchSpy).not.toHaveBeenCalled(); - expect(destination.logs).toContainEqual({ - level: 30, - msg: "Scheduled /some-api Every second", - time: 1696486441293, - }); + expect(destination.logs).toStrictEqual([ + { + level: 30, + msg: "Scheduled /some-api Every second", + time: 1696486441293, + }, + ]); destination.clear(); - fs.writeFileSync("./vercel.json", JSON.stringify({})); + vol.fromNestedJSON({ "./vercel.json": JSON.stringify({}) }, process.cwd()); await jest.advanceTimersByTimeAsync(0); - expect(destination.logs).toContainEqual({ - config: "./vercel.json", - level: 30, - msg: "Config Changed", - time: 1696486441293, - }); - expect(destination.logs).toContainEqual({ - level: 30, - msg: "No CRONs Scheduled", - time: 1696486441293, - }); + expect(destination.logs).toStrictEqual([ + { + config: "./vercel.json", + level: 30, + msg: "Config Changed", + time: 1696486441293, + }, + { + level: 30, + msg: "No CRONs Scheduled", + time: 1696486441293, + }, + ]); destination.clear(); await jest.advanceTimersByTimeAsync(100000); diff --git a/src/index.ts b/src/index.ts index 936f5c3..3a9d76e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import boxen from "boxen"; import chalk from "chalk"; import { Cron } from "croner"; import cronstrue from "cronstrue"; +import { debounce } from "lodash/fp"; import pino from "pino"; import z from "zod"; @@ -209,7 +210,7 @@ export const main = async ({ let abortPrevious: (() => void) | undefined; - const handler = (async (eventType, filename) => { + const handler = debounce(0, (async (eventType, filename) => { logger.trace({ eventType, filename }, "fs.watch"); if (abortPrevious) { @@ -224,7 +225,7 @@ export const main = async ({ logger.fatal({ error }, "Failed to Schedule CRONs"); abortPrevious?.(); } - }) satisfies Parameters[1]; + }) satisfies Parameters[1]); handler("rename", config); From 4a5bdd0f2439ab3d3addf1e2b257202694b1623f Mon Sep 17 00:00:00 2001 From: Saiichi Hashimoto Date: Mon, 15 Jan 2024 15:09:17 -0600 Subject: [PATCH 2/4] test debug logs --- src/index.test.ts | 24 ++++++++++++++++++++++++ src/index.ts | 3 +-- src/vercel-cron.ts | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index cbed3c1..b061240 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -120,6 +120,12 @@ describe("main", () => { expect(winner).toBe("timeout"); expect(destination.logs).toStrictEqual([ + { + config: "./vercel.json", + level: 20, + time: 1696486441293, + msg: "Watching Config", + }, { level: 30, time: 1696486441293, @@ -153,6 +159,12 @@ describe("main", () => { expect(winner).not.toBe("timeout"); expect(destination.logs).toStrictEqual([ + { + config: "./vercel.json", + level: 20, + time: 1696486441293, + msg: "Watching Config", + }, { level: 30, time: 1696486441293, @@ -181,6 +193,12 @@ describe("main", () => { expect(fetchSpy).not.toHaveBeenCalled(); expect(destination.logs).toStrictEqual([ + { + config: "./vercel.json", + level: 20, + time: 1696486441293, + msg: "Watching Config", + }, { level: 30, msg: "Scheduled /some-api Every second", @@ -269,6 +287,12 @@ describe("main", () => { expect(fetchSpy).not.toHaveBeenCalled(); expect(destination.logs).toStrictEqual([ + { + config: "./vercel.json", + level: 20, + time: 1696486441293, + msg: "Watching Config", + }, { level: 30, msg: "Scheduled /some-api Every second", diff --git a/src/index.ts b/src/index.ts index 3a9d76e..8366ed4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,7 +59,6 @@ export const zOpts = z export const defaults = { config: "./vercel.json", - level: "info", secret: process.env.CRON_SECRET ?? null, url: "http://localhost:3000", } satisfies z.infer; @@ -78,10 +77,10 @@ export const main = async ({ config, dry, ignoreTimestamp, - level, secret, url, color = false, + level = "debug", pretty = false, } = { ...defaults, diff --git a/src/vercel-cron.ts b/src/vercel-cron.ts index 0aada23..678ad6b 100644 --- a/src/vercel-cron.ts +++ b/src/vercel-cron.ts @@ -34,7 +34,7 @@ import pkg from "../package.json"; .option("--no-pretty", "No pretty printing, just a JSON stream of logs") .addOption( new Option("-l --level ", "Logging Level") - .default(defaults.level) + .default("info") .choices([ "trace", "debug", From 161c3f050947b029c29ed132e8207b4a9b7f8185 Mon Sep 17 00:00:00 2001 From: Saiichi Hashimoto Date: Mon, 15 Jan 2024 16:32:13 -0600 Subject: [PATCH 3/4] more tests --- src/index.test.ts | 394 +++++++++++++++++++++++++++++++++++----- src/index.ts | 47 ++--- src/utils.ts | 28 +++ src/vercel-cron.test.ts | 21 ++- 4 files changed, 400 insertions(+), 90 deletions(-) create mode 100644 src/utils.ts diff --git a/src/index.test.ts b/src/index.test.ts index b061240..b0c7d46 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -82,7 +82,7 @@ describe("main", () => { statusText: "OK", type: "basic", url: url.toString(), - text: async () => "", + text: async () => "Some Text", } as Response) ); }); @@ -102,11 +102,7 @@ describe("main", () => { process.cwd() ); - proc = main({ - destination, - fs, - signal: controller.signal, - }); + proc = main({ destination, fs, signal: controller.signal }); await jest.advanceTimersByTimeAsync(0); @@ -126,11 +122,7 @@ describe("main", () => { time: 1696486441293, msg: "Watching Config", }, - { - level: 30, - time: 1696486441293, - msg: "No CRONs Scheduled", - }, + { level: 40, time: 1696486441293, msg: "No CRONs Scheduled" }, ]); }); @@ -140,12 +132,7 @@ describe("main", () => { process.cwd() ); - proc = main({ - destination, - fs, - signal: controller.signal, - dry: true, - }); + proc = main({ destination, fs, signal: controller.signal, dry: true }); await jest.advanceTimersByTimeAsync(0); @@ -165,11 +152,7 @@ describe("main", () => { time: 1696486441293, msg: "Watching Config", }, - { - level: 30, - time: 1696486441293, - msg: "No CRONs Scheduled", - }, + { level: 40, time: 1696486441293, msg: "No CRONs Scheduled" }, ]); }); @@ -183,11 +166,7 @@ describe("main", () => { process.cwd() ); - proc = main({ - destination, - fs, - signal: controller.signal, - }); + proc = main({ destination, fs, signal: controller.signal }); await jest.advanceTimersByTimeAsync(0); @@ -209,8 +188,7 @@ describe("main", () => { await jest.advanceTimersByTimeAsync(1000); - expect(fetchSpy).toHaveBeenNthCalledWith( - 1, + expect(fetchSpy).toHaveBeenCalledWith( "http://localhost:3000/some-api", expect.objectContaining({ method: "GET", @@ -218,7 +196,6 @@ describe("main", () => { headers: {}, }) ); - expect(fetchSpy).toHaveBeenCalledTimes(1); expect(destination.logs).toStrictEqual([ { currentRun: "2023-10-05T06:14:02.000Z", @@ -231,7 +208,7 @@ describe("main", () => { level: 30, msg: "Succeeded /some-api Every second", status: 200, - text: "", + text: "Some Text", time: 1696486442000, }, ]); @@ -239,8 +216,7 @@ describe("main", () => { await jest.advanceTimersByTimeAsync(1000); - expect(fetchSpy).toHaveBeenNthCalledWith( - 2, + expect(fetchSpy).toHaveBeenCalledWith( "http://localhost:3000/some-api", expect.objectContaining({ method: "GET", @@ -248,7 +224,6 @@ describe("main", () => { headers: {}, }) ); - expect(fetchSpy).toHaveBeenCalledTimes(2); expect(destination.logs).toStrictEqual([ { currentRun: "2023-10-05T06:14:03.000Z", @@ -261,13 +236,136 @@ describe("main", () => { level: 30, msg: "Succeeded /some-api Every second", status: 200, - text: "", + text: "Some Text", + time: 1696486443000, + }, + ]); + }); + + it("handles multiple schedules", async () => { + const { fs } = memfs( + { + "./vercel.json": JSON.stringify({ + crons: [ + { path: "/some-api", schedule: "2,4 * * * * *" }, + { path: "/some-other-api", schedule: "3 * * * * *" }, + ], + }), + }, + process.cwd() + ); + + proc = main({ destination, fs, signal: controller.signal }); + + await jest.advanceTimersByTimeAsync(0); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(destination.logs).toStrictEqual([ + { + config: "./vercel.json", + level: 20, + time: 1696486441293, + msg: "Watching Config", + }, + { + level: 30, + msg: "Scheduled /some-api At 2 and 4 seconds past the minute", + time: 1696486441293, + }, + { + level: 30, + msg: "Scheduled /some-other-api At 3 seconds past the minute", + time: 1696486441293, + }, + ]); + destination.clear(); + + await jest.advanceTimersByTimeAsync(1000); + + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/some-api", + expect.objectContaining({ + method: "GET", + redirect: "manual", + headers: {}, + }) + ); + expect(destination.logs).toStrictEqual([ + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Started /some-api At 2 and 4 seconds past the minute", + time: 1696486442000, + }, + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Succeeded /some-api At 2 and 4 seconds past the minute", + status: 200, + text: "Some Text", + time: 1696486442000, + }, + ]); + destination.clear(); + + await jest.advanceTimersByTimeAsync(1000); + + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/some-other-api", + expect.objectContaining({ + method: "GET", + redirect: "manual", + headers: {}, + }) + ); + expect(destination.logs).toStrictEqual([ + { + currentRun: "2023-10-05T06:14:03.000Z", + level: 30, + msg: "Started /some-other-api At 3 seconds past the minute", + time: 1696486443000, + }, + { + currentRun: "2023-10-05T06:14:03.000Z", + level: 30, + msg: "Succeeded /some-other-api At 3 seconds past the minute", + status: 200, + text: "Some Text", time: 1696486443000, }, ]); + destination.clear(); + + await jest.advanceTimersByTimeAsync(1000); + + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/some-api", + expect.objectContaining({ + method: "GET", + redirect: "manual", + headers: {}, + }) + ); + expect(destination.logs).toStrictEqual([ + { + currentRun: "2023-10-05T06:14:04.000Z", + level: 30, + msg: "Started /some-api At 2 and 4 seconds past the minute", + time: 1696486444000, + }, + { + currentRun: "2023-10-05T06:14:04.000Z", + level: 30, + msg: "Succeeded /some-api At 2 and 4 seconds past the minute", + status: 200, + text: "Some Text", + time: 1696486444000, + }, + ]); + destination.clear(); }); - it("misses CRON if config changes", async () => { + it("reschedules CRONs on file change", async () => { const { fs, vol } = memfs( { "./vercel.json": JSON.stringify({ @@ -277,11 +375,7 @@ describe("main", () => { process.cwd() ); - proc = main({ - destination, - fs, - signal: controller.signal, - }); + proc = main({ destination, fs, signal: controller.signal }); await jest.advanceTimersByTimeAsync(0); @@ -304,6 +398,85 @@ describe("main", () => { vol.fromNestedJSON({ "./vercel.json": JSON.stringify({}) }, process.cwd()); await jest.advanceTimersByTimeAsync(0); + expect(destination.logs).toStrictEqual([ + { + config: "./vercel.json", + level: 30, + msg: "Config Changed", + time: 1696486441293, + }, + { level: 40, msg: "No CRONs Scheduled", time: 1696486441293 }, + ]); + destination.clear(); + + await jest.advanceTimersByTimeAsync(100000); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(destination.logs).toHaveLength(0); + }); + + it("keeps running with malformed config", async () => { + const { fs, vol } = memfs( + { "./vercel.json": JSON.stringify([]) }, + process.cwd() + ); + + proc = main({ destination, fs, signal: controller.signal }); + + await jest.advanceTimersByTimeAsync(0); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(destination.logs).toStrictEqual([ + { + config: "./vercel.json", + level: 20, + time: 1696486441293, + msg: "Watching Config", + }, + { + level: 60, + msg: "Failed to Schedule CRONs", + time: 1696486441293, + error: { + type: "ZodError", + name: "ZodError", + message: expect.any(String), + stack: expect.any(String), + aggregateErrors: [ + { + code: "invalid_type", + expected: "object", + message: "Expected object, received array", + path: [], + received: "array", + stack: "", + type: "Object", + }, + ], + issues: [ + { + code: "invalid_type", + expected: "object", + message: "Expected object, received array", + path: [], + received: "array", + }, + ], + }, + }, + ]); + destination.clear(); + + vol.fromNestedJSON( + { + "./vercel.json": JSON.stringify({ + crons: [{ path: "/some-api", schedule: "* * * * * *" }], + }), + }, + process.cwd() + ); + await jest.advanceTimersByTimeAsync(0); + expect(destination.logs).toStrictEqual([ { config: "./vercel.json", @@ -313,16 +486,145 @@ describe("main", () => { }, { level: 30, - msg: "No CRONs Scheduled", + msg: "Scheduled /some-api Every second", time: 1696486441293, }, ]); destination.clear(); - await jest.advanceTimersByTimeAsync(100000); + await jest.advanceTimersByTimeAsync(1000); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(fetchSpy).toHaveBeenCalledTimes(0); - expect(destination.logs).toHaveLength(0); + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/some-api", + expect.objectContaining({ + method: "GET", + redirect: "manual", + headers: {}, + }) + ); + expect(destination.logs).toStrictEqual([ + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Started /some-api Every second", + time: 1696486442000, + }, + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Succeeded /some-api Every second", + status: 200, + text: "Some Text", + time: 1696486442000, + }, + ]); + }); + + it("prints not-ok fetch responses", async () => { + const { fs } = memfs( + { + "./vercel.json": JSON.stringify({ + crons: [{ path: "/some-api", schedule: "* * * * * *" }], + }), + }, + process.cwd() + ); + + fetchSpy.mockImplementation( + async (url) => + ({ + headers: {}, + ok: false, + redirected: false, + status: 400, + statusText: "OK", + type: "basic", + url: url.toString(), + text: async () => "Mock Error", + } as Response) + ); + + proc = main({ destination, fs, signal: controller.signal }); + + await jest.advanceTimersByTimeAsync(0); + destination.clear(); + await jest.advanceTimersByTimeAsync(1000); + + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/some-api", + expect.objectContaining({ + method: "GET", + redirect: "manual", + headers: {}, + }) + ); + expect(destination.logs).toStrictEqual([ + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Started /some-api Every second", + time: 1696486442000, + }, + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 50, + msg: "Failed /some-api Every second", + status: 400, + text: "Mock Error", + time: 1696486442000, + error: { + message: "Mock Error", + type: "Error", + stack: expect.any(String), + }, + }, + ]); + }); + + it("prints fetch errors", async () => { + const { fs } = memfs( + { + "./vercel.json": JSON.stringify({ + crons: [{ path: "/some-api", schedule: "* * * * * *" }], + }), + }, + process.cwd() + ); + + fetchSpy.mockRejectedValue(new Error("Mock Error")); + + proc = main({ destination, fs, signal: controller.signal }); + + await jest.advanceTimersByTimeAsync(0); + destination.clear(); + await jest.advanceTimersByTimeAsync(1000); + + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/some-api", + expect.objectContaining({ + method: "GET", + redirect: "manual", + headers: {}, + }) + ); + expect(destination.logs).toStrictEqual([ + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Started /some-api Every second", + time: 1696486442000, + }, + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 50, + msg: "Failed /some-api Every second", + time: 1696486442000, + error: { + message: "Mock Error", + type: "Error", + stack: expect.any(String), + }, + }, + ]); }); }); diff --git a/src/index.ts b/src/index.ts index 8366ed4..805e48b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,32 +9,7 @@ import { debounce } from "lodash/fp"; import pino from "pino"; import z from "zod"; -// TODO [engine:node@>=20.3.0]: Replace with AbortSignal.any -const anySignal = (signals: Array) => { - const controller = new globalThis.AbortController(); - - const onAbort = () => { - controller.abort(); - - signals.forEach((signal) => { - if (signal?.removeEventListener) { - signal.removeEventListener("abort", onAbort); - } - }); - }; - - signals.forEach((signal) => { - if (signal?.addEventListener) { - signal.addEventListener("abort", onAbort); - } - }); - - if (signals.some((signal) => signal?.aborted)) { - onAbort(); - } - - return controller.signal; -}; +import { anySignal } from "./utils"; export const zOpts = z .object({ @@ -74,14 +49,14 @@ export const main = async ({ signal?: AbortSignal; }) => { const { + color, config, dry, ignoreTimestamp, + pretty, secret, url, - color = false, level = "debug", - pretty = false, } = { ...defaults, ...opts, @@ -120,15 +95,16 @@ export const main = async ({ : pino(loggerOptions, destination); if (logger.isLevelEnabled("info") && pretty) { - // eslint-disable-next-line no-console -- boxen! + /* eslint-disable no-console -- boxen! */ console.log( boxen("▲ Vercel CRON ▲", { borderColor: color ? "magenta" : undefined, borderStyle: "round", - margin: { left: 0, right: 0, top: 0, bottom: 1 }, padding: 1, }) ); + console.log(); + /* eslint-enable no-console */ } logger.trace({ opts }, "Parsed Options"); @@ -159,7 +135,7 @@ export const main = async ({ cronstrue.toString(schedule) )}`; - const cron = Cron(schedule, { timezone: "UTC" }, async () => { + const cron = Cron(schedule, async () => { const runLogger = logger.child({ currentRun: cron.currentRun() }); runLogger.info(`Started ${pathString}`); @@ -178,14 +154,15 @@ export const main = async ({ return; } + const text = await res.text(); if (!res.ok) { runLogger.error( - { status: res.status, error: new Error(await res.text()) }, + { status: res.status, text, error: new Error(text) }, `Failed ${pathString}` ); } else { runLogger.info( - { status: res.status, text: await res.text() }, + { status: res.status, text }, `Succeeded ${pathString}` ); } @@ -199,7 +176,7 @@ export const main = async ({ }); if (!crons.length) { - logger.info("No CRONs Scheduled"); + logger.warn("No CRONs Scheduled"); } return controller.abort.bind(controller); @@ -222,7 +199,7 @@ export const main = async ({ abortPrevious = await scheduleCrons(); } catch (error) { logger.fatal({ error }, "Failed to Schedule CRONs"); - abortPrevious?.(); + abortPrevious = () => {}; } }) satisfies Parameters[1]); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..6eef69d --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,28 @@ +// Stryker disable all + +// TODO [engine:node@>=20.3.0]: Replace with AbortSignal.any +export const anySignal = (signals: Array) => { + const controller = new globalThis.AbortController(); + + const onAbort = () => { + controller.abort(); + + signals.forEach((signal) => { + if (signal?.removeEventListener) { + signal.removeEventListener("abort", onAbort); + } + }); + }; + + signals.forEach((signal) => { + if (signal?.addEventListener) { + signal.addEventListener("abort", onAbort); + } + }); + + if (signals.some((signal) => signal?.aborted)) { + onAbort(); + } + + return controller.signal; +}; diff --git a/src/vercel-cron.test.ts b/src/vercel-cron.test.ts index ad76084..c7fbef8 100644 --- a/src/vercel-cron.test.ts +++ b/src/vercel-cron.test.ts @@ -10,6 +10,7 @@ import { it, jest, } from "@jest/globals"; +import chalk from "chalk"; // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports, unicorn/prefer-module, @typescript-eslint/no-unused-vars -- HACK We're "including" bin by running a process against the built file so jest won't pick it up with `--findRelatedTests`. const helpJestFindRelatedTests = () => require("./vercel-cron"); @@ -81,18 +82,20 @@ Options: it("prints banner", async () => { const { stderr, stdout } = await exec( - "ts-node ./src/vercel-cron.ts --ignoreTimestamp --dry --no-color", + "ts-node ./src/vercel-cron.ts --ignoreTimestamp --dry", { signal: controller.signal } ); expect(stderr).toBe(""); - expect(stdout).toBe(`╭─────────────────────────╮ -│ │ -│ ▲ Vercel CRON ▲ │ -│ │ -╰─────────────────────────╯ - -INFO: No CRONs Scheduled -`); + expect(stdout).toBe( + `${chalk.magenta("╭─────────────────────────╮")} +${chalk.magenta("│")} ${chalk.magenta("│")} +${chalk.magenta("│")} ▲ Vercel CRON ▲ ${chalk.magenta("│")} +${chalk.magenta("│")} ${chalk.magenta("│")} +${chalk.magenta("╰─────────────────────────╯")} + +${chalk.yellow("WARN")}: ${chalk.cyan(`No CRONs Scheduled`)} +` + ); }); }); From 79f22ae9f6d4268a2129c085ecf6ac5faaa5084a Mon Sep 17 00:00:00 2001 From: Saiichi Hashimoto Date: Mon, 15 Jan 2024 16:51:30 -0600 Subject: [PATCH 4/4] test most options fixes #6 --- src/index.test.ts | 169 +++++++++++++++++++++++++++++++++++++--------- src/index.ts | 3 +- 2 files changed, 138 insertions(+), 34 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index b0c7d46..5eb955b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -218,11 +218,7 @@ describe("main", () => { expect(fetchSpy).toHaveBeenCalledWith( "http://localhost:3000/some-api", - expect.objectContaining({ - method: "GET", - redirect: "manual", - headers: {}, - }) + expect.objectContaining({}) ); expect(destination.logs).toStrictEqual([ { @@ -284,11 +280,7 @@ describe("main", () => { expect(fetchSpy).toHaveBeenCalledWith( "http://localhost:3000/some-api", - expect.objectContaining({ - method: "GET", - redirect: "manual", - headers: {}, - }) + expect.objectContaining({}) ); expect(destination.logs).toStrictEqual([ { @@ -312,11 +304,7 @@ describe("main", () => { expect(fetchSpy).toHaveBeenCalledWith( "http://localhost:3000/some-other-api", - expect.objectContaining({ - method: "GET", - redirect: "manual", - headers: {}, - }) + expect.objectContaining({}) ); expect(destination.logs).toStrictEqual([ { @@ -340,11 +328,7 @@ describe("main", () => { expect(fetchSpy).toHaveBeenCalledWith( "http://localhost:3000/some-api", - expect.objectContaining({ - method: "GET", - redirect: "manual", - headers: {}, - }) + expect.objectContaining({}) ); expect(destination.logs).toStrictEqual([ { @@ -494,12 +478,139 @@ describe("main", () => { await jest.advanceTimersByTimeAsync(1000); + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/some-api", + expect.objectContaining({}) + ); + expect(destination.logs).toStrictEqual([ + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Started /some-api Every second", + time: 1696486442000, + }, + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Succeeded /some-api Every second", + status: 200, + text: "Some Text", + time: 1696486442000, + }, + ]); + }); + + it("uses specified config", async () => { + const { fs } = memfs( + { + "./vercel-other.json": JSON.stringify({ + crons: [{ path: "/some-api", schedule: "* * * * * *" }], + }), + }, + process.cwd() + ); + + proc = main({ + destination, + fs, + signal: controller.signal, + config: "./vercel-other.json", + }); + + await jest.advanceTimersByTimeAsync(0); + destination.clear(); + await jest.advanceTimersByTimeAsync(1000); + + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/some-api", + expect.objectContaining({}) + ); + expect(destination.logs).toStrictEqual([ + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Started /some-api Every second", + time: 1696486442000, + }, + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Succeeded /some-api Every second", + status: 200, + text: "Some Text", + time: 1696486442000, + }, + ]); + }); + + it("uses specified url", async () => { + const { fs } = memfs( + { + "./vercel.json": JSON.stringify({ + crons: [{ path: "/some-api", schedule: "* * * * * *" }], + }), + }, + process.cwd() + ); + + proc = main({ + destination, + fs, + signal: controller.signal, + url: "https://my-website.com", + }); + + await jest.advanceTimersByTimeAsync(0); + destination.clear(); + await jest.advanceTimersByTimeAsync(1000); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://my-website.com/some-api", + expect.objectContaining({}) + ); + expect(destination.logs).toStrictEqual([ + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Started /some-api Every second", + time: 1696486442000, + }, + { + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Succeeded /some-api Every second", + status: 200, + text: "Some Text", + time: 1696486442000, + }, + ]); + }); + + it("uses specified secret", async () => { + const { fs } = memfs( + { + "./vercel.json": JSON.stringify({ + crons: [{ path: "/some-api", schedule: "* * * * * *" }], + }), + }, + process.cwd() + ); + + proc = main({ + destination, + fs, + signal: controller.signal, + secret: "mock-secret", + }); + + await jest.advanceTimersByTimeAsync(0); + destination.clear(); + await jest.advanceTimersByTimeAsync(1000); + expect(fetchSpy).toHaveBeenCalledWith( "http://localhost:3000/some-api", expect.objectContaining({ - method: "GET", - redirect: "manual", - headers: {}, + headers: { Authorization: "Bearer mock-secret" }, }) ); expect(destination.logs).toStrictEqual([ @@ -552,11 +663,7 @@ describe("main", () => { expect(fetchSpy).toHaveBeenCalledWith( "http://localhost:3000/some-api", - expect.objectContaining({ - method: "GET", - redirect: "manual", - headers: {}, - }) + expect.objectContaining({}) ); expect(destination.logs).toStrictEqual([ { @@ -601,11 +708,7 @@ describe("main", () => { expect(fetchSpy).toHaveBeenCalledWith( "http://localhost:3000/some-api", - expect.objectContaining({ - method: "GET", - redirect: "manual", - headers: {}, - }) + expect.objectContaining({}) ); expect(destination.logs).toStrictEqual([ { diff --git a/src/index.ts b/src/index.ts index 805e48b..6930c2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { Cron } from "croner"; import cronstrue from "cronstrue"; import { debounce } from "lodash/fp"; import pino from "pino"; +import type { LoggerOptions } from "pino"; import z from "zod"; import { anySignal } from "./utils"; @@ -88,7 +89,7 @@ export const main = async ({ }, }, }), - }; + } satisfies LoggerOptions; const logger = !destination ? pino(loggerOptions)