Repository: sid88in/serverless-appsync-plugin
Pull request #458 (2021-12-31 17:20:49)

Add new commands
- [x]  get-introspection
- [x]  flush-cache
- [x]  logs
- [x]  open console
- [x]  open cloudwatch


Patch:
diff --git a/jest.config.ts b/jest.config.ts
index 263ef31..8f841ad 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -2,6 +2,7 @@ import type { Config } from '@jest/types';
 
 const config: Config.InitialOptions = {
   preset: 'ts-jest',
+  globalSetup: './jest.setup.ts',
   testEnvironment: 'node',
   silent: true,
   moduleDirectories: ['node_modules', 'src'],
diff --git a/jest.setup.ts b/jest.setup.ts
new file mode 100644
index 0000000..b2f51f6
--- /dev/null
+++ b/jest.setup.ts
@@ -0,0 +1,3 @@
+export default async (): Promise<void> => {
+  process.env.TZ = 'UTC';
+};
diff --git a/package-lock.json b/package-lock.json
index 021ae01..9c2438c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1063,6 +1063,16 @@
           "requires": {
             "whatwg-url": "^5.0.0"
           }
+        },
+        "open": {
+          "version": "7.4.2",
+          "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+          "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+          "dev": true,
+          "requires": {
+            "is-docker": "^2.0.0",
+            "is-wsl": "^2.1.1"
+          }
         }
       }
     },
@@ -3454,6 +3464,11 @@
         "timers-ext": "^0.1.7"
       }
     },
+    "define-lazy-prop": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+      "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="
+    },
     "define-properties": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
@@ -5439,8 +5454,7 @@
     "is-docker": {
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
-      "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
-      "dev": true
+      "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="
     },
     "is-fullwidth-code-point": {
       "version": "3.0.0",
@@ -5567,7 +5581,6 @@
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
       "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
-      "dev": true,
       "requires": {
         "is-docker": "^2.0.0"
       }
@@ -7304,13 +7317,13 @@
       }
     },
     "open": {
-      "version": "7.4.2",
-      "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
-      "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
-      "dev": true,
+      "version": "8.4.0",
+      "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz",
+      "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==",
       "requires": {
-        "is-docker": "^2.0.0",
-        "is-wsl": "^2.1.1"
+        "define-lazy-prop": "^2.0.0",
+        "is-docker": "^2.1.1",
+        "is-wsl": "^2.2.0"
       }
     },
     "optional": {
@@ -8422,6 +8435,16 @@
                   }
                 }
               }
+            },
+            "open": {
+              "version": "7.4.2",
+              "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+              "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+              "dev": true,
+              "requires": {
+                "is-docker": "^2.0.0",
+                "is-wsl": "^2.1.1"
+              }
             }
           }
         },
diff --git a/package.json b/package.json
index 9ffe674..ad171ce 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
     "globby": "^11.0.4",
     "graphql": "^15.8.0",
     "lodash": "^4.17.20",
-    "luxon": "^2.1.1"
+    "luxon": "^2.1.1",
+    "open": "^8.4.0"
   }
 }
diff --git a/src/index.ts b/src/index.ts
index ea4aa3a..45ecaa7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,17 +1,11 @@
+import { forEach, last, merge } from 'lodash';
 import { getAppSyncConfig } from './getAppSyncConfig';
+import { GraphQLError } from 'graphql';
+import { DateTime } from 'luxon';
 import chalk from 'chalk';
-import { forEach, last, merge } from 'lodash';
-import { logger } from './utils';
-import {
-  CommandsDefinition,
-  Hook,
-  Provider,
-  Serverless,
-  ServerlessHelpers,
-  ServerlessLogger,
-} from './types/serverless';
-
-import { Api } from './resources/Api';
+import path from 'path';
+import open from 'open';
+import fs from 'fs';
 import {
   DescribeStackResourcesInput,
   DescribeStackResourcesOutput,
@@ -19,38 +13,53 @@ import {
 import {
   GetGraphqlApiRequest,
   GetGraphqlApiResponse,
+  GetIntrospectionSchemaRequest,
+  GetIntrospectionSchemaResponse,
   ListApiKeysRequest,
   ListApiKeysResponse,
 } from 'aws-sdk/clients/appsync';
-import { O } from 'ts-toolbelt';
+import {
+  CommandsDefinition,
+  ServerlessHelpers,
+  ServerlessLogger,
+  Serverless,
+  Provider,
+  Hook,
+} from './types/serverless';
+import {
+  FilterLogEventsResponse,
+  FilterLogEventsRequest,
+} from 'aws-sdk/clients/cloudwatchlogs';
 import { AppSyncValidationError, validateConfig } from './validation';
-import { GraphQLError } from 'graphql';
+import { logger, parseDateTimeOrDuration, wait } from './utils';
+import { Api } from './resources/Api';
+
+const CONSOLE_BASE_URL = 'https://console.aws.amazon.com';
 
 class ServerlessAppsyncPlugin {
   private provider: Provider;
   private gatheredData: {
     apis: {
       id: string;
-      name: string;
       type: string;
       uri: string;
     }[];
     apiKeys: {
-      apiName: string;
       value: string;
-      description: string;
+      description?: string;
     }[];
   };
   public readonly hooks: Record<string, Hook>;
   public readonly commands?: CommandsDefinition;
   private log: ServerlessLogger;
+  private writeText: (text: string) => void;
   private api?: Api;
   private slsVersion: 'v2' | 'v3';
 
   constructor(
     public serverless: Serverless,
     private options: Record<string, string>,
-    helpers?: ServerlessHelpers,
+    private helpers?: ServerlessHelpers,
   ) {
     this.gatheredData = {
       apis: [],
@@ -75,42 +84,114 @@ class ServerlessAppsyncPlugin {
       logger((message: string) => {
         return serverless.cli.log(message, 'AppSync');
       });
+    this.writeText = this.helpers?.writeText || console.log;
 
     this.commands = {
       appsync: {
         commands: {
           'validate-schema': {
-            usage: 'Validates your graphql schemas',
+            usage: 'Validate the graphql schema',
             lifecycleEvents: ['run'],
           },
+          'get-introspection': {
+            usage: "Get the API's introspection schema",
+            lifecycleEvents: ['run'],
+            options: {
+              format: {
+                usage:
+                  'Specify the output format (JSON or SDL). Default: `JSON`',
+                shortcut: 'f',
+                required: false,
+                type: 'string',
+              },
+              output: {
+                usage: 'Output to a file. If not specified, writes to stdout',
+                shortcut: 'o',
+                required: false,
+                type: 'string',
+              },
+            },
+          },
+          'flush-cache': {
+            usage: 'Flushes the cache of the API.',
+            lifecycleEvents: ['run'],
+          },
+          console: {
+            usage: 'Open the AppSync AWS console',
+            lifecycleEvents: ['run'],
+          },
+          cloudwatch: {
+            usage: 'Open the CloudWatch AWS console',
+            lifecycleEvents: ['run'],
+          },
+          logs: {
+            usage: 'Output the logs of the AppSync API',
+            lifecycleEvents: ['run'],
+            options: {
+              startTime: {
+                usage: 'Starting time. Default: 10m (10 minutes ago)',
+                required: false,
+                type: 'string',
+              },
+              tail: {
+                usage: 'Tail the log output',
+                shortcut: 't',
+                required: false,
+                type: 'boolean',
+              },
+              interval: {
+                usage: 'Tail polling interval in milliseconds. Default: `1000`',
+                shortcut: 'i',
+                required: false,
+                type: 'string',
+              },
+              filter: {
+                usage: 'A filter pattern to apply',
+                shortcut: 'f',
+                required: false,
+                type: 'string',
+              },
+            },
+          },
         },
       },
     };
 
     this.hooks = {
-      initialize: () => {
-        this.loadConfig();
-      },
-      'appsync:validate-schema:run': () => {
-        this.validateSchemas();
-        this.log.success('AppSync schema valid');
-      },
-      'before:package:initialize': () => {
-        this.buildAndAppendResources();
-      },
-      'before:aws:info:gatherData': () => {
-        // load embedded functions
-        this.buildAndAppendResources();
-      },
       'after:aws:info:gatherData': () => this.gatherData(),
       'after:aws:info:displayServiceInfo': () => {
         this.displayEndpoints();
         this.displayApiKeys();
       },
+      // Commands
+      'appsync:validate-schema:run': () => {
+        this.validateSchemas();
+        this.log.success('AppSync schema valid');
+      },
+      'appsync:get-introspection:run': () => this.getIntrospection(),
+      'appsync:flush-cache:run': () => this.flushCache(),
+      'appsync:console:run': () => this.openConsole(),
+      'appsync:cloudwatch:run': () => this.openCloudWatch(),
+      'appsync:logs:run': async () => this.initShowLogs(),
     };
+
+    // These hooks need the config to be loaded and
+    // processed in order to add embedded functions
+    // to the service. (eg: function defined in resolvers)
+    [
+      'before:logs:logs',
+      'before:deploy:function:initialize',
+      'before:package:initialize',
+      'before:aws:info:gatherData',
+    ].forEach((hook) => {
+      this.hooks[hook] = () => {
+        this.loadConfig();
+        this.buildAndAppendResources();
+      };
+    });
   }
 
-  async gatherData() {
+  async getApiId() {
     const { StackResources } = await this.provider.request<
       DescribeStackResourcesInput,
       DescribeStackResourcesOutput
@@ -118,49 +199,146 @@ class ServerlessAppsyncPlugin {
       StackName: this.provider.naming.getStackName(),
     });
 
-    const apis =
-      StackResources?.filter(
+    const apiId = last(
+      StackResources?.find(
         (resource) => resource.ResourceType === 'AWS::AppSync::GraphQLApi',
-      ).map((resource) => last(resource.PhysicalResourceId?.split('/'))) || [];
+      )?.PhysicalResourceId?.split('/'),
+    );
 
-    for (const apiId of apis) {
-      if (!apiId) {
-        continue;
-      }
-      const { graphqlApi } = await this.provider.request<
-        GetGraphqlApiRequest,
-        O.Required<GetGraphqlApiResponse, string, 'deep'>
-      >('AppSync', 'getGraphqlApi', {
-        apiId,
-      });
+    if (!apiId) {
+      throw new this.serverless.classes.Error(
+        'AppSync Api not found in stack. Did you forget to deploy?',
+      );
+    }
 
-      if (!graphqlApi) {
-        continue;
-      }
+    return apiId;
+  }
 
-      forEach(graphqlApi?.uris, (value, type) => {
-        this.gatheredData.apis.push({
-          id: apiId,
-          name: graphqlApi?.name || apiId,
-          type: type.toLocaleLowerCase(),
-          uri: value,
-        });
-      });
+  async gatherData() {
+    const apiId = await this.getApiId();
+
+    const { graphqlApi } = await this.provider.request<
+      GetGraphqlApiRequest,
+      GetGraphqlApiResponse
+    >('AppSync', 'getGraphqlApi', {
+      apiId,
+    });
 
-      const { apiKeys } = await this.provider.request<
-        ListApiKeysRequest,
-        O.Required<ListApiKeysResponse, string, 'deep'>
-      >('AppSync', 'listApiKeys', {
-        apiId,
+    forEach(graphqlApi?.uris, (value, type) => {
+      this.gatheredData.apis.push({
+        id: apiId,
+        type: type.toLowerCase(),
+        uri: value,
       });
+    });
 
-      apiKeys?.forEach((apiKey) => {
-        this.gatheredData.apiKeys.push({
-          apiName: graphqlApi.name,
-          value: apiKey.id,
-          description: apiKey.description || graphqlApi.name,
-        });
+    const { apiKeys } = await this.provider.request<
+      ListApiKeysRequest,
+      ListApiKeysResponse
+    >('AppSync', 'listApiKeys', {
+      apiId: apiId,
+    });
+
+    apiKeys?.forEach((apiKey) => {
+      this.gatheredData.apiKeys.push({
+        value: apiKey.id || 'unknown key',
+        description: apiKey.description,
       });
+    });
+  }
+
+  async getIntrospection() {
+    const apiId = await this.getApiId();
+
+    const { schema } = await this.provider.request<
+      GetIntrospectionSchemaRequest,
+      GetIntrospectionSchemaResponse
+    >('AppSync', 'getIntrospectionSchema', {
+      apiId,
+      format: (this.options.format || 'JSON').toUpperCase(),
+    });
+
+    if (!schema) {
+      throw new this.serverless.classes.Error('Schema not found');
+    }
+
+    if (this.options.output) {
+      try {
+        const filePath = path.resolve(this.options.output);
+        fs.writeFileSync(filePath, schema.toString());
+        this.log.success(`Introspection schema exported to ${filePath}`);
+      } catch (error) {
+        this.log.error(`Could not save to file: ${(error as Error).message}`);
+      }
+      return;
+    }
+
+    this.writeText(schema.toString());
+  }
+
+  async flushCache() {
+    const apiId = await this.getApiId();
+    await this.provider.request('AppSync', 'flushApiCache', { apiId });
+    this.log.success('Cache flushed successfully');
+  }
+
+  async openConsole() {
+    const apiId = await this.getApiId();
+    const { region } = this.serverless.service.provider;
+    const url = `${CONSOLE_BASE_URL}/appsync/home?region=${region}#/${apiId}/v1/home`;
+    open(url);
+  }
+
+  async openCloudWatch() {
+    const apiId = await this.getApiId();
+    const { region } = this.serverless.service.provider;
+    const url = `${CONSOLE_BASE_URL}/cloudwatch/home?region=${region}#logsV2:log-groups/log-group/$252Faws$252Fappsync$252Fapis$252F${apiId}`;
+    open(url);
+  }
+
+  async initShowLogs() {
+    const apiId = await this.getApiId();
+    await this.showLogs(`/aws/appsync/apis/${apiId}`);
+  }
+
+  async showLogs(logGroupName: string, nextToken?: string) {
+    let startTime: DateTime;
+    if (this.options.startTime) {
+      startTime = parseDateTimeOrDuration(this.options.startTime);
+    } else {
+      startTime = DateTime.now().minus({ minutes: 10 });
+    }
+
+    const { events, nextToken: newNextToken } = await this.provider.request<
+      FilterLogEventsRequest,
+      FilterLogEventsResponse
+    >('CloudWatchLogs', 'filterLogEvents', {
+      logGroupName,
+      startTime: startTime.toMillis(),
+      nextToken,
+      filterPattern: this.options.filter,
+    });
+
+    events?.forEach((event) => {
+      const { timestamp, message } = event;
+      this.writeText(
+        `${chalk.gray(
+          DateTime.fromMillis(timestamp || 0).toISO(),
+        )}\t${message}`,
+      );
+    });
+
+    const lastTs = last(events)?.timestamp;
+    this.options.startTime = lastTs
+      ? DateTime.fromMillis(lastTs + 1).toISO()
+      : this.options.startTime;
+
+    if (this.options.tail) {
+      const interval = this.options.interval
+        ? parseInt(this.options.interval, 10)
+        : 1000;
+      await wait(interval);
+      await this.showLogs(logGroupName, newNextToken);
     }
   }
 
diff --git a/src/resources/Api.ts b/src/resources/Api.ts
index 3d90fcc..9befda1 100644
--- a/src/resources/Api.ts
+++ b/src/resources/Api.ts
@@ -18,7 +18,7 @@ import {
   ResolverConfig,
 } from '../types/plugin';
 import { parseDuration } from '../utils';
-import { DateTime } from 'luxon';
+import { DateTime, Duration } from 'luxon';
 import { Naming } from './Naming';
 import { DataSource } from './DataSource';
 import { Resolver } from './Resolver';
@@ -117,7 +117,7 @@ export class Api {
 
     const logGroupLogicalId = this.naming.getLogGroupLogicalId();
     const roleLogicalId = this.naming.getLogGroupRoleLogicalId();
-    const policyLogicalId = this.naming.getLogGroupRoleLogicalId();
+    const policyLogicalId = this.naming.getLogGroupPolicyLogicalId();
     const apiLogicalId = this.naming.getApiLogicalId();
 
     return {
@@ -220,7 +220,17 @@ export class Api {
     const startOfHour = DateTime.now().setZone('UTC').startOf('hour');
     let expires: DateTime;
     if (expiresAfter) {
-      expires = startOfHour.plus(parseDuration(expiresAfter));
+      let duration = parseDuration(expiresAfter);
+      // Minimum duration is 1 day from 'now'
+      // However, api key expiry is rounded down to the hour.
+      // meaning the minimum expiry date is in fact 25 hours
+      // We accept 24h durations for simplicity of use
+      // but fix them to be 25
+      // Anything < 24h will be kept to make sure the validation fails later
+      if (duration.as('hours') >= 24 && duration.as('hours') < 25) {
+        duration = Duration.fromDurationLike({ hours: 25 });
+      }
+      expires = startOfHour.plus(duration);
     } else if (expiresAt) {
       expires = DateTime.fromISO(expiresAt);
     } else {
diff --git a/src/types/serverless.ts b/src/types/serverless.ts
index 468b5fc..fd238b5 100644
--- a/src/types/serverless.ts
+++ b/src/types/serverless.ts
@@ -67,6 +67,7 @@ export type CommandsDefinition = Record<
         usage: string;
         required?: boolean;
         shortcut?: string;
+        type?: 'string' | 'boolean' | 'multiple';
       };
     };
   }
diff --git a/src/utils.ts b/src/utils.ts
index 7427cbc..e57a80c 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -2,7 +2,7 @@ import { upperFirst, transform, values } from 'lodash';
 import { TransformKeysToCfnCase } from './typeHelpers';
 import { ServerlessLogger } from './types/serverless';
 import chalk from 'chalk';
-import { Duration } from 'luxon';
+import { DateTime, Duration } from 'luxon';
 
 const timeUnits = {
   y: 'years',
@@ -34,6 +34,25 @@ export const toCfnKeys = <T extends Record<string, unknown>>(
     return acc;
   });
 
+export const wait = async (time: number) => {
+  await new Promise((resolve) => setTimeout(resolve, time));
+};
+
+export const parseDateTimeOrDuration = (input: string) => {
+  try {
+    // Try to parse a date
+    let date = DateTime.fromISO(input);
+    if (!date.isValid) {
+      // try to parse duration
+      date = DateTime.now().minus(parseDuration(input));
+    }
+
+    return date;
+  } catch (error) {
+    throw new Error('Invalid date or duration');
+  }
+};
+
 export const parseDuration = (input: string | number) => {
   let duration: Duration;
   if (typeof input === 'number') {
@@ -60,16 +79,6 @@ export const parseDuration = (input: string | number) => {
     throw new Error(`Could not parse ${input} as a valid duration`);
   }
 
-  // Minimum duration is 1 day from 'now'
-  // However, api key expiry is rounded down to the hour.
-  // meaning the minimum expiry date is in fact 25 hours
-  // We accept 24h durations for simplicity of use
-  // but fix them to be 25
-  // Anything < 24h will be kept to make sure the validation fails later
-  if (duration.as('hours') >= 24 && duration.as('hours') < 25) {
-    duration = Duration.fromDurationLike({ hours: 25 });
-  }
-
   return duration;
 };
 


Test patch:
diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts
index fd71b4d..b0325de 100644
--- a/src/__tests__/api.test.ts
+++ b/src/__tests__/api.test.ts
@@ -491,6 +491,38 @@ describe('Api', () => {
             },
             "Type": "AWS::Logs::LogGroup",
           },
+          "GraphQlApiLogGroupPolicy": Object {
+            "Properties": Object {
+              "PolicyDocument": Object {
+                "Statement": Array [
+                  Object {
+                    "Action": Array [
+                      "logs:CreateLogGroup",
+                      "logs:CreateLogStream",
+                      "logs:PutLogEvents",
+                    ],
+                    "Effect": "Allow",
+                    "Resource": Array [
+                      Object {
+                        "Fn::GetAtt": Array [
+                          "GraphQlApiLogGroup",
+                          "Arn",
+                        ],
+                      },
+                    ],
+                  },
+                ],
+                "Version": "2012-10-17",
+              },
+              "PolicyName": "GraphQlApiLogGroupPolicy",
+              "Roles": Array [
+                Object {
+                  "Ref": "GraphQlApiLogGroupRole",
+                },
+              ],
+            },
+            "Type": "AWS::IAM::Policy",
+          },
           "GraphQlApiLogGroupRole": Object {
             "Properties": Object {
               "AssumeRolePolicyDocument": Object {
diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts
index 9443d51..704db84 100644
--- a/src/__tests__/utils.test.ts
+++ b/src/__tests__/utils.test.ts
@@ -1,4 +1,13 @@
-import { parseDuration } from '../utils';
+import { parseDateTimeOrDuration, parseDuration } from '../utils';
+
+beforeAll(() => {
+  jest.useFakeTimers('modern');
+  jest.setSystemTime(new Date('2020-01-01T17:00:00+00:00'));
+});
+
+afterAll(() => {
+  jest.useRealTimers();
+});
 
 describe('parseDuration', () => {
   it('should parse valid duration', () => {
@@ -10,14 +19,27 @@ describe('parseDuration', () => {
     expect(() => parseDuration('foo')).toThrowError();
   });
 
-  it('should auto-fix 24h durations to 25h', () => {
-    expect(parseDuration(24).toString()).toEqual('PT25H');
-    expect(parseDuration('1d').toString()).toEqual('PT25H');
-    expect(parseDuration('24h').toString()).toEqual('PT25H');
-    expect(parseDuration('1440m').toString()).toEqual('PT25H');
-  });
-
   it('should auto-fix 1y durations to 365 days', () => {
     expect(parseDuration('1y').toString()).toEqual('P365D');
   });
 });
+
+describe('parseDateTimeOrDuration', () => {
+  it('should parse valid date', () => {
+    expect(
+      parseDateTimeOrDuration('2021-12-31T16:57:00+00:00'),
+    ).toMatchInlineSnapshot(`"2021-12-31T16:57:00.000+00:00"`);
+
+    expect(parseDateTimeOrDuration('10m')).toMatchInlineSnapshot(
+      `"2020-01-01T16:50:00.000+00:00"`,
+    );
+
+    expect(parseDateTimeOrDuration('1h')).toMatchInlineSnapshot(
+      `"2020-01-01T16:00:00.000+00:00"`,
+    );
+
+    expect(function () {
+      parseDateTimeOrDuration('foo');
+    }).toThrowErrorMatchingInlineSnapshot(`"Invalid date or duration"`);
+  });
+});