From 4a7a0b737a2232f5c6c8e9d1ad3076b4661d9b3a Mon Sep 17 00:00:00 2001 From: Filip Maj Date: Tue, 17 Sep 2024 17:39:44 -0700 Subject: [PATCH] polish up type tests, finish moving context "unit" tests over to type tests. --- src/App.ts | 15 ++-- src/types/options/index.ts | 8 +- test/types/action.test-d.ts | 45 +++++----- test/types/command.test-d.ts | 25 ++++-- test/types/options.test-d.ts | 168 +++++++++++++++++------------------ test/types/view.test-d.ts | 120 ++++++------------------- 6 files changed, 165 insertions(+), 216 deletions(-) diff --git a/src/App.ts b/src/App.ts index 242fb7b28..9e2c9c423 100644 --- a/src/App.ts +++ b/src/App.ts @@ -795,18 +795,18 @@ export default class App } public options< - Source extends OptionsSource = 'block_suggestion', + Source extends OptionsSource = 'block_suggestion', // TODO: here, similarly to `message()`, the generic is the string `type` of the payload. in others, like `action()`, it's the entire payload. could we make this consistent? MiddlewareCustomContext extends StringIndexed = StringIndexed, >( actionId: string | RegExp, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void; - // TODO: reflect the type in constraints to Source + // TODO: reflect the type in constraints to Source (this relates to the above TODO, too) public options< Source extends OptionsSource = OptionsSource, MiddlewareCustomContext extends StringIndexed = StringIndexed, >( - constraints: OptionsConstraints, + constraints: OptionsConstraints, // TODO: to be able to 'link' listener arguments to the constrains, should pass the Source type in as a generic here ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void; // TODO: reflect the type in constraints to Source @@ -822,8 +822,8 @@ export default class App ? { action_id: actionIdOrConstraints } : actionIdOrConstraints; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([onlyOptions, matchConstraints(constraints), ..._listeners] as Middleware[]); } @@ -836,6 +836,7 @@ export default class App ): void; public view< ViewActionType extends SlackViewAction = SlackViewAction, + // TODO: add a type parameter for view constraints; this way we can constrain the handler view arguments based on the type of the constraint, similar to what action() does MiddlewareCustomContext extends StringIndexed = StringIndexed, >( constraints: ViewConstraints, @@ -866,8 +867,8 @@ export default class App return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([ onlyViewActions, matchConstraints(constraints), diff --git a/src/types/options/index.ts b/src/types/options/index.ts index 7f0ee3759..020c46be7 100644 --- a/src/types/options/index.ts +++ b/src/types/options/index.ts @@ -145,9 +145,10 @@ export interface DialogSuggestion extends StringIndexed { type OptionsAckFn = Source extends 'block_suggestion' ? AckFn>> : Source extends 'interactive_message' - ? AckFn>> - : AckFn>>; + ? AckFn>> + : AckFn>>; +// TODO: why are the next two interfaces identical? export interface BlockOptions { options: Option[]; } @@ -213,8 +214,7 @@ export interface OptionsRequest ex block_id: Source extends 'block_suggestion' ? string : never; container: Source extends 'block_suggestion' ? StringIndexed : never; - // this appears in the block_suggestions schema, but we're not sure when its present or what its type would be - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: this appears in the block_suggestions schema, but we're not sure when its present or what its type would be app_unfurl?: any; // exists for enterprise installs diff --git a/test/types/action.test-d.ts b/test/types/action.test-d.ts index 0996c724a..807415dcf 100644 --- a/test/types/action.test-d.ts +++ b/test/types/action.test-d.ts @@ -1,5 +1,5 @@ -import { expectError, expectType } from 'tsd'; -import type { BlockElementAction, DialogSubmitAction, InteractiveAction } from '../../'; +import { expectAssignable, expectError, expectType } from 'tsd'; +import type { SlackAction, BlockElementAction, DialogSubmitAction, InteractiveAction } from '../../'; import App from '../../src/App'; const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); @@ -11,23 +11,28 @@ expectError( }), ); -expectType( - app.action({ type: 'block_actions' }, async ({ action }) => { - expectType(action); - await Promise.resolve(action); - }), -); +app.action({ type: 'block_actions' }, async ({ action }) => { + expectType(action); +}); -expectType( - app.action({ type: 'interactive_message' }, async ({ action }) => { - expectType(action); - await Promise.resolve(action); - }), -); +app.action({ type: 'interactive_message' }, async ({ action }) => { + expectType(action); +}); -expectType( - app.action({ type: 'dialog_submission' }, async ({ action }) => { - expectType(action); - await Promise.resolve(action); - }), -); +app.action({ type: 'dialog_submission' }, async ({ action }) => { + expectType(action); +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.action('action_id', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.action('action_id', async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/command.test-d.ts b/test/types/command.test-d.ts index 08cb7c214..647971698 100644 --- a/test/types/command.test-d.ts +++ b/test/types/command.test-d.ts @@ -1,12 +1,23 @@ -import { expectType } from 'tsd'; +import { expectAssignable, expectType } from 'tsd'; import type { SlashCommand } from '../..'; import App from '../../src/App'; const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); -expectType( - app.command('/hello', async ({ command }) => { - expectType(command); - await Promise.resolve(command); - }), -); +app.command('/hello', async ({ command }) => { + expectType(command); +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.command('/action', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.command('/action', async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/options.test-d.ts b/test/types/options.test-d.ts index 6bf5ca2f7..c2d7cd524 100644 --- a/test/types/options.test-d.ts +++ b/test/types/options.test-d.ts @@ -1,74 +1,56 @@ import type { Option } from '@slack/types'; -import { expectError, expectType } from 'tsd'; +import { expectAssignable, expectType } from 'tsd'; import App from '../../src/App'; -import type { BlockSuggestion, DialogSuggestion, InteractiveMessageSuggestion, SlackOptions } from '../..'; +import type { + AckFn, + BlockOptions, + BlockSuggestion, + DialogOptions, + DialogOptionGroups, + DialogSuggestion, + InteractiveMessageSuggestion, + MessageOptions, + OptionGroups, +} from '../..'; const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); -const blockSuggestionOptions: Option[] = [ - { - text: { - type: 'plain_text', - text: 'foo', - }, - value: 'bar', - }, -]; - -// set the default to block_suggestion -expectType( - app.options('action-id-or-callback-id', async ({ options, ack }) => { - expectType(options); - // biome-ignore lint/suspicious/noExplicitAny: TODO: should the callback ID be any? seems wrong - expectType(options.callback_id); - options.block_id; - options.action_id; - // https://github.com/slackapi/bolt-js/issues/720 - await ack({ options: blockSuggestionOptions }); - await Promise.resolve(options); - }), -); +app.options('action-id-or-callback-id', async ({ options, ack }) => { + // TODO: should BlockSuggestion belong in types package? if so, assertions on its contents should also move to types package. + // defaults options to block_suggestion + expectType(options); + // biome-ignore lint/suspicious/noExplicitAny: TODO: should the callback ID be any? seems wrong + expectType(options.callback_id); + options.block_id; + options.action_id; + // ack should allow either BlockOptions or OptionGroups + // https://github.com/slackapi/bolt-js/issues/720 + expectAssignable>(ack); + expectAssignable>>(ack); +}); -// block_suggestion -expectType( - app.options<'block_suggestion'>({ action_id: 'a' }, async ({ options, ack }) => { - expectType(options); - // https://github.com/slackapi/bolt-js/issues/720 - await ack({ options: blockSuggestionOptions }); - await Promise.resolve(options); - }), -); -// FIXME: app.options({ type: 'block_suggestion', action_id: 'a' } does not work +// FIXME: app.options({ type: 'block_suggestion', action_id: 'a' } does not constrain the arguments of the handler down to `block_suggestion` // interactive_message (attachments) -expectType( - app.options<'interactive_message'>({ callback_id: 'a' }, async ({ options, ack }) => { - expectType(options); - ack({ options: blockSuggestionOptions }); - await Promise.resolve(options); - }), -); +app.options<'interactive_message'>({ callback_id: 'a' }, async ({ options, ack }) => { + expectType(options); + // ack should allow either MessageOptions or OptionGroups + // https://github.com/slackapi/bolt-js/issues/720 + expectAssignable>(ack); + expectAssignable>>(ack); +}); -expectType( - app.options({ type: 'interactive_message', callback_id: 'a' }, async ({ options, ack }) => { - // FIXME: the type should be OptionsRequest<'interactive_message'> - expectType(options); - // https://github.com/slackapi/bolt-js/issues/720 - expectError(ack({ options: blockSuggestionOptions })); - await Promise.resolve(options); - }), -); +// FIXME: app.options({ type: 'interactive_message', callback_id: 'a' } does not constrain the arguments of the handler down to `interactive_message` // dialog_suggestion (dialog) -expectType( - app.options<'dialog_suggestion'>({ callback_id: 'a' }, async ({ options, ack }) => { - expectType(options); - // https://github.com/slackapi/bolt-js/issues/720 - expectError(ack({ options: blockSuggestionOptions })); - await Promise.resolve(options); - }), -); -// FIXME: app.options({ type: 'dialog_suggestion', callback_id: 'a' } does not work +app.options<'dialog_suggestion'>({ callback_id: 'a' }, async ({ options, ack }) => { + expectType(options); + // ack should allow either MessageOptions or OptionGroups + // https://github.com/slackapi/bolt-js/issues/720 + expectAssignable>(ack); + expectAssignable>>(ack); +}); +// FIXME: app.options({ type: 'dialog_suggestion', callback_id: 'a' } does not constrain the arguments of the handler down to `dialog_sggestion` const db = { get: (_teamId: string) => { @@ -76,33 +58,45 @@ const db = { }, }; -expectType( - // Taken from https://slack.dev/bolt-js/concepts#options - // Example of responding to an external_select options request - app.options('external_action', async ({ options, ack }) => { - // Get information specific to a team or channel - // TODO: modified to satisfy TS compiler; should team be optional? - const results = options.team != null ? db.get(options.team.id) : []; - - if (results) { - // (modified to satisfy TS compiler) - const options: Option[] = []; - // Collect information in options array to send in Slack ack response - for (const result of results) { - options.push({ - text: { - type: 'plain_text', - text: result.label, - }, - value: result.value, - }); - } +// Taken from https://slack.dev/bolt-js/concepts#options +// Example of responding to an external_select options request +app.options('external_action', async ({ options, ack }) => { + // Get information specific to a team or channel + // TODO: modified to satisfy TS compiler; should team be optional? + const results = options.team != null ? db.get(options.team.id) : []; - await ack({ - options: options, + if (results) { + // (modified to satisfy TS compiler) + const options: Option[] = []; + // Collect information in options array to send in Slack ack response + for (const result of results) { + options.push({ + text: { + type: 'plain_text', + text: result.label, + }, + value: result.value, }); - } else { - await ack(); } - }), -); + + await ack({ + options: options, + }); + } else { + await ack(); + } +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.options<'block_suggestion', MyContext>('suggest', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.options('suggest', async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/view.test-d.ts b/test/types/view.test-d.ts index ffe793e2d..87c979542 100644 --- a/test/types/view.test-d.ts +++ b/test/types/view.test-d.ts @@ -1,101 +1,39 @@ -import { expectType } from 'tsd'; +import { expectAssignable, expectType } from 'tsd'; import type { SlackViewAction, ViewOutput } from '../..'; import App from '../../src/App'; const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); // view_submission -expectType( - app.view('modal-id', async ({ body, view }) => { - // TODO: the body can be more specific (ViewSubmitAction) here - expectType(body); - expectType(view); - await Promise.resolve(view); - }), -); +app.view('modal-id', async ({ body, view }) => { + // TODO: the body can be more specific (ViewSubmitAction) here + expectType(body); + expectType(view); +}); -expectType( - app.view({ type: 'view_submission', callback_id: 'modal-id' }, async ({ body, view }) => { - // TODO: the body can be more specific (ViewSubmitAction) here - expectType(body); - expectType(view); - await Promise.resolve(view); - }), -); +app.view({ type: 'view_submission', callback_id: 'modal-id' }, async ({ body, view }) => { + // TODO: the body can be more specific (ViewSubmitAction) here. need to add a type parameter (generic) to view() and 'link' constraint w/ view types. + expectType(body); + expectType(view); +}); // view_closed -expectType( - app.view({ type: 'view_closed', callback_id: 'modal-id' }, async ({ body, view }) => { - // TODO: the body can be more specific (ViewClosedAction) here - expectType(body); - expectType(view); - await Promise.resolve(view); - }), -); +app.view({ type: 'view_closed', callback_id: 'modal-id' }, async ({ body, view }) => { + // TODO: the body can be more specific (ViewClosedAction) here. need to add a type parameter (generic) to view() and 'link' constraint w/ view types. + expectType(body); + expectType(view); +}); -const viewSubmissionPayload: ViewOutput = { - id: 'V111', - team_id: 'T111', - type: 'modal', - blocks: [ - { - type: 'divider', - block_id: '+3ht', - }, - ], - private_metadata: '', - callback_id: '', - state: { - values: { - aPVYH: { - 'g/t5': { - type: 'radio_buttons', - selected_option: null, - }, - }, - '1pSa': { - h3R: { - type: 'multi_static_select', - selected_options: [], - }, - }, - 'a/Rt': { - zmPQ: { - type: 'plain_text_input', - value: null, - }, - }, - '7/wWO': { - HdJj: { - type: 'plain_text_input', - value: 'test', - }, - }, - }, - }, - hash: '1618378109.3ndA0Spf', - title: { - type: 'plain_text', - text: 'Workplace check-in', - emoji: true, - }, - clear_on_close: false, - notify_on_close: false, - close: { - type: 'plain_text', - text: 'Cancel', - emoji: true, - }, - submit: { - type: 'plain_text', - text: 'Submit', - emoji: true, - }, - previous_view_id: null, - root_view_id: 'V1234567890', - app_id: 'A02', - external_id: '', - app_installed_team_id: 'T5J4Q04QG', - bot_id: 'B00', -}; -expectType(viewSubmissionPayload); +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.view('view-id', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.view('view-id', async ({ context }) => { + expectAssignable(context); +});