Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: return runtime versions used by the application with a doctor hook #81

Merged
merged 17 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,16 @@ invoked.

## Supported Scripts

The hooks currently provided by this repo are `build`, `start`, `check-update`,
`install-update`, `get-trigger`, and `get-manifest`.
The hooks currently provided by this repo are `build`, `check-update`, `doctor`,
`get-hooks`, `get-manifest`, `get-trigger`, `install-update`, and `start`.

| Hook Name | CLI Command | Description |
| ---------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `build` | `slack deploy` | Bundles any functions with Deno into an output directory that's compatible with the Run on Slack runtime. Implemented in `build.ts`. |
| `check-update` | `slack upgrade` | Checks the App's SDK dependencies to determine whether or not any of your libraries need to be updated. Implemented in `check_update.ts`. |
| `get-manifest` | `slack manifest` | Converts a `manifest.json`, `manifest.js`, or `manifest.ts` file into a valid manifest JSON payload. Implemented in `get_manifest.ts`. |
| `doctor` | `slack doctor` | Returns runtime versions and other system dependencies required by the application. Implemented in `doctor.ts`. |
| `get-hooks` | All | Fetches the list of available hooks for the CLI from this repository. Implemented in `mod.ts`. |
| `get-manifest` | `slack manifest` | Converts a `manifest.json`, `manifest.js`, or `manifest.ts` file into a valid manifest JSON payload. Implemented in `get_manifest.ts`. |
| `get-trigger` | `slack trigger create` | Converts a specified `json`, `js`, or `ts` file into a valid trigger JSON payload to be uploaded by the CLI to the `workflows.triggers.create` Slack API endpoint. Implemented in `get_trigger.ts`. |
| `install-update` | `slack upgrade` | Prompts the user to automatically update any dependencies that need to be updated based on the result of the `check-update` hook. Implemented in `install_update.ts`. |
| `start` | `slack run` | While developing and locally running a deno-slack-based application, the CLI manages a socket connection with Slack's backend and delegates to this hook for invoking the correct application function for relevant events incoming via this connection. For more information, see the [deno-slack-runtime](https://github.com/slackapi/deno-slack-runtime) repository's details on `local-run`. |
Expand Down
26 changes: 4 additions & 22 deletions src/check_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
DENO_SLACK_SDK,
} from "./libraries.ts";
import { getProtocolInterface, JSONValue } from "./deps.ts";
import { getJSON } from "./utilities.ts";
import { getJSON, isNewSemverRelease } from "./utilities.ts";

const IMPORT_MAP_SDKS = [DENO_SLACK_SDK, DENO_SLACK_API];
const SLACK_JSON_SDKS = [
Expand Down Expand Up @@ -229,8 +229,10 @@ export async function fetchLatestModuleVersion(
}

/**
* extractVersion takes in a string, searches for a version,
* extractVersion takes in a URL formatted string, searches for a version,
* and, if version is found, returns that version.
*
* Example input: https://deno.land/x/[email protected]/
*/
export function extractVersion(str: string): string {
const at = str.indexOf("@");
Expand All @@ -245,26 +247,6 @@ export function extractVersion(str: string): string {
return version;
}

/**
* isNewSemverRelease takes two semver formatted strings
* and compares them to see if the second argument is a
* newer version than the first argument.
* If it's newer it returns true, otherwise returns false.
*/

export const isNewSemverRelease = (current: string, target: string) => {
const [currMajor, currMinor, currPatch] = current
.split(".")
.map((val) => Number(val));
const [targetMajor, targetMinor, targetPatch] = target
.split(".")
.map((val) => Number(val));

if (targetMajor !== currMajor) return targetMajor > currMajor;
if (targetMinor !== currMinor) return targetMinor > currMinor;
return targetPatch > currPatch;
};

/**
* hasBreakingChange determines whether or not there is a
* major version difference of greater or equal to 1 between the current
Expand Down
80 changes: 80 additions & 0 deletions src/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { getProtocolInterface } from "./deps.ts";
import { isNewSemverRelease } from "./utilities.ts";

type RuntimeVersion = {
name: string;
current: string;
} & RuntimeDetails;

type RuntimeDetails = {
message?: string;
error?: {
message: string;
};
};

const getHostedDenoRuntimeVersion = async (): Promise<RuntimeDetails> => {
try {
const metadataURL = "https://api.slack.com/slackcli/metadata.json";
WilliamBergamin marked this conversation as resolved.
Show resolved Hide resolved
const response = await fetch(metadataURL);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can throw if the hostname can't be found. I hope that's rare because it doesn't seem obvious how we could catch this...

Screenshot 2024-03-22 at 3 27 33 PM

Here's the stacktrace:

Screenshot 2024-03-22 at 3 28 22 PM

if (!response.ok || response.status !== 200) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response.status !== 200 protects against successful but unexpected responses, but maybe we want to strengthen this?

The actual fetch follows redirects which I think is good for future compatibility, but that means this can be redirected (302) to a successful page (200) which is not the right page. For instance, a login page. Then the result is this:

login

throw new Error(
`Failed to collect upstream CLI metadata - ${response.status}`,
);
}
const metadata = await response.json();
const version = metadata?.["deno-runtime"]?.releases[0]?.version;
if (!version) {
const details = JSON.stringify(metadata, null, " ");
return {
message: `Upstream CLI metadata response included:\n${details}`,
error: {
message: "Failed to find the minimum Deno version",
},
};
}
const message = Deno.version.deno !== version
? `Applications deployed to Slack use Deno version ${version}`
: undefined;
if (isNewSemverRelease(Deno.version.deno, version)) {
return {
message,
error: { message: "The installed runtime version is not supported" },
};
}
return { message };
} catch (err) {
if (err instanceof Error) {
return { error: { message: err.message } };
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To my earlier comments, can we check to make sure that the HTTP response code/body are at a minimum raised if the error caught is one of the HTTP or payload-parsing code fails? Perhaps add that as part of the test suite (if it's not already there).

}
return { error: { message: err } };
}

Check warning on line 51 in src/doctor.ts

View check run for this annotation

Codecov / codecov/patch

src/doctor.ts#L50-L51

Added lines #L50 - L51 were not covered by tests
};

export const getRuntimeVersions = async (): Promise<{
versions: RuntimeVersion[];
}> => {
const hostedDenoRuntimeVersion = await getHostedDenoRuntimeVersion();
const versions = [
{
"name": "deno",
"current": Deno.version.deno,
...hostedDenoRuntimeVersion,
},
{
"name": "typescript",
"current": Deno.version.typescript,
},
{
"name": "v8",
"current": Deno.version.v8,
},
];
return { versions };
};

if (import.meta.main) {
const protocol = getProtocolInterface(Deno.args);
const prunedDoctor = await getRuntimeVersions();
protocol.respond(JSON.stringify(prunedDoctor));
}

Check warning on line 80 in src/doctor.ts

View check run for this annotation

Codecov / codecov/patch

src/doctor.ts#L77-L80

Added lines #L77 - L80 were not covered by tests
2 changes: 2 additions & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const projectScripts = (args: string[]) => {
`deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/${HOOKS_TAG}/check_update.ts`,
"install-update":
`deno run -q --config=deno.jsonc --allow-run --allow-read --allow-write --allow-net https://deno.land/x/${HOOKS_TAG}/install_update.ts`,
"doctor":
`deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/${HOOKS_TAG}/doctor.ts`,
},
"config": {
"protocol-version": ["message-boundaries"],
Expand Down
48 changes: 3 additions & 45 deletions src/tests/check_update_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
fetchLatestModuleVersion,
getDenoImportMapFiles,
hasBreakingChange,
isNewSemverRelease,
readProjectDependencies,
} from "../check_update.ts";

Expand Down Expand Up @@ -110,13 +109,13 @@ Deno.test("check-update hook tests", async (t) => {
await t.step("extractDependencies method", async (evT) => {
await evT.step(
"given import_map.json or slack.json file contents, an array of key, value dependency pairs is returned",
async () => {
const importMapActual = await extractDependencies(
() => {
const importMapActual = extractDependencies(
JSON.parse(MOCK_IMPORT_MAP_JSON),
"imports",
);

const slackHooksActual = await extractDependencies(
const slackHooksActual = extractDependencies(
JSON.parse(MOCK_SLACK_JSON),
"hooks",
);
Expand Down Expand Up @@ -321,45 +320,4 @@ Deno.test("check-update hook tests", async (t) => {
},
);
});
await t.step("isNewSemverRelease method", async (evT) => {
await evT.step("returns true for semver updates", () => {
assertEquals(isNewSemverRelease("0.0.1", "0.0.2"), true);
assertEquals(isNewSemverRelease("0.0.1", "0.2.0"), true);
assertEquals(isNewSemverRelease("0.0.1", "2.0.0"), true);
assertEquals(isNewSemverRelease("0.1.0", "0.1.1"), true);
assertEquals(isNewSemverRelease("0.1.0", "0.2.0"), true);
assertEquals(isNewSemverRelease("0.1.0", "2.0.0"), true);
assertEquals(isNewSemverRelease("1.0.0", "1.0.1"), true);
assertEquals(isNewSemverRelease("1.0.0", "1.1.0"), true);
assertEquals(isNewSemverRelease("1.0.0", "1.1.1"), true);
assertEquals(isNewSemverRelease("1.0.0", "2.0.0"), true);
assertEquals(isNewSemverRelease("0.0.2", "0.0.13"), true);
});
await evT.step("returns false for semver downgrades", () => {
assertEquals(isNewSemverRelease("2.0.0", "1.0.0"), false);
assertEquals(isNewSemverRelease("2.0.0", "0.1.0"), false);
assertEquals(isNewSemverRelease("2.0.0", "0.3.0"), false);
assertEquals(isNewSemverRelease("2.0.0", "0.0.1"), false);
assertEquals(isNewSemverRelease("2.0.0", "0.0.3"), false);
assertEquals(isNewSemverRelease("2.0.0", "1.1.0"), false);
assertEquals(isNewSemverRelease("2.0.0", "1.3.0"), false);
assertEquals(isNewSemverRelease("2.0.0", "1.1.1"), false);
assertEquals(isNewSemverRelease("2.0.0", "1.3.3"), false);
assertEquals(isNewSemverRelease("0.2.0", "0.1.0"), false);
assertEquals(isNewSemverRelease("0.2.0", "0.0.1"), false);
assertEquals(isNewSemverRelease("0.2.0", "0.0.3"), false);
assertEquals(isNewSemverRelease("0.2.0", "0.1.1"), false);
assertEquals(isNewSemverRelease("0.2.0", "0.1.3"), false);
assertEquals(isNewSemverRelease("0.0.2", "0.0.1"), false);
assertEquals(isNewSemverRelease("0.0.20", "0.0.13"), false);
});
await evT.step("returns false for semver matches", () => {
assertEquals(isNewSemverRelease("0.0.1", "0.0.1"), false);
assertEquals(isNewSemverRelease("0.1.0", "0.1.0"), false);
assertEquals(isNewSemverRelease("0.1.1", "0.1.1"), false);
assertEquals(isNewSemverRelease("1.0.0", "1.0.0"), false);
assertEquals(isNewSemverRelease("1.0.1", "1.0.1"), false);
assertEquals(isNewSemverRelease("1.1.1", "1.1.1"), false);
});
});
});
169 changes: 169 additions & 0 deletions src/tests/doctor_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { assertEquals } from "../dev_deps.ts";
import { mockFetch } from "../dev_deps.ts";
import { getRuntimeVersions } from "../doctor.ts";

const REAL_DENO_VERSION = Deno.version;
const MOCK_DENO_VERSION = {
deno: "1.2.3",
typescript: "5.0.0",
v8: "12.3.456.78",
};

const MOCK_SLACK_CLI_MANIFEST = {
"slack-cli": {
"title": "Slack CLI",
"description": "CLI for creating, building, and deploying Slack apps.",
"releases": [
{
"version": "2.19.0",
"release_date": "2024-03-11",
},
],
},
"deno-runtime": {
"title": "Deno Runtime",
"releases": [
{
"version": "1.101.1",
"release_date": "2023-09-19",
},
],
},
};

Deno.test("doctor hook tests", async (t) => {
Object.defineProperty(Deno, "version", {
value: MOCK_DENO_VERSION,
writable: true,
configurable: true,
});
mockFetch.install();

await t.step("known runtime values for the system are returned", async () => {
mockFetch.mock("GET@/slackcli/metadata.json", (_req: Request) => {
return new Response(null, { status: 404 });
});
Deno.version.deno = "1.2.3";

const actual = await getRuntimeVersions();
const expected = {
versions: [
{
name: "deno",
current: "1.2.3",
error: {
message: "Failed to collect upstream CLI metadata - 404",
},
},
{
name: "typescript",
current: "5.0.0",
},
{
name: "v8",
current: "12.3.456.78",
},
],
};
assertEquals(actual, expected);
});

await t.step("matched upstream requirements return success", async () => {
mockFetch.mock("GET@/slackcli/metadata.json", (_req: Request) => {
return new Response(JSON.stringify(MOCK_SLACK_CLI_MANIFEST));
});
Deno.version.deno = "1.101.1";

const actual = await getRuntimeVersions();
const expected = {
versions: [
{
name: "deno",
current: "1.101.1",
message: undefined,
},
{
name: "typescript",
current: "5.0.0",
},
{
name: "v8",
current: "12.3.456.78",
},
],
};
assertEquals(actual, expected);
});

await t.step("unsupported upstream runtimes note differences", async () => {
mockFetch.mock("GET@/slackcli/metadata.json", (_req: Request) => {
return new Response(JSON.stringify(MOCK_SLACK_CLI_MANIFEST));
});
Deno.version.deno = "1.2.3";

const actual = await getRuntimeVersions();
const expected = {
versions: [
{
name: "deno",
current: "1.2.3",
message: "Applications deployed to Slack use Deno version 1.101.1",
error: {
message: "The installed runtime version is not supported",
},
},
{
name: "typescript",
current: "5.0.0",
},
{
name: "v8",
current: "12.3.456.78",
},
],
};
assertEquals(actual, expected);
});

await t.step("missing minimums from cli metadata are noted", async () => {
const metadata = {
runtimes: ["deno", "node"],
};
mockFetch.mock("GET@/slackcli/metadata.json", (_req: Request) => {
return new Response(JSON.stringify(metadata));
});
Deno.version.deno = "1.2.3";

const actual = await getRuntimeVersions();
const expected = {
versions: [
{
name: "deno",
current: "1.2.3",
message: `Upstream CLI metadata response included:\n${
JSON.stringify(metadata, null, 2)
}`,
error: {
message: "Failed to find the minimum Deno version",
},
},
{
name: "typescript",
current: "5.0.0",
},
{
name: "v8",
current: "12.3.456.78",
},
],
};
assertEquals(actual, expected);
});

Object.defineProperty(Deno, "version", {
value: REAL_DENO_VERSION,
writable: false,
configurable: false,
});
mockFetch.uninstall();
});
Loading
Loading