From b82864cddae33fc1478d82d6ad4f5f0446abcd50 Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Wed, 1 Nov 2023 18:56:09 +0000 Subject: [PATCH 01/12] feat: Add Project Management Node sample --- node/project-management-app/.gcloudignore | 16 + node/project-management-app/README.md | 23 + node/project-management-app/aip-service.js | 110 + .../app-action-handler.js | 403 +++ node/project-management-app/app.js | 288 +++ node/project-management-app/env.js | 28 + node/project-management-app/exceptions.js | 40 + .../firestore-service.js | 280 ++ node/project-management-app/index.js | 35 + node/project-management-app/package-lock.json | 2302 +++++++++++++++++ node/project-management-app/package.json | 24 + node/project-management-app/space-service.js | 46 + node/project-management-app/user-service.js | 60 + .../user-story-service.js | 197 ++ node/project-management-app/user-story.js | 104 + node/project-management-app/user.js | 39 + .../views/edit-user-story-card.js | 211 ++ .../project-management-app/views/help-card.js | 119 + .../views/user-story-card.js | 95 + .../views/user-story-list-card.js | 113 + .../widgets/user-story-assignee-widget.js | 52 + .../widgets/user-story-buttons-widget.js | 166 ++ .../views/widgets/user-story-card-type.js | 33 + .../widgets/user-story-columns-widget.js | 78 + .../views/widgets/user-story-row-widget.js | 67 + 25 files changed, 4929 insertions(+) create mode 100644 node/project-management-app/.gcloudignore create mode 100644 node/project-management-app/README.md create mode 100644 node/project-management-app/aip-service.js create mode 100644 node/project-management-app/app-action-handler.js create mode 100644 node/project-management-app/app.js create mode 100644 node/project-management-app/env.js create mode 100644 node/project-management-app/exceptions.js create mode 100644 node/project-management-app/firestore-service.js create mode 100644 node/project-management-app/index.js create mode 100644 node/project-management-app/package-lock.json create mode 100644 node/project-management-app/package.json create mode 100644 node/project-management-app/space-service.js create mode 100644 node/project-management-app/user-service.js create mode 100644 node/project-management-app/user-story-service.js create mode 100644 node/project-management-app/user-story.js create mode 100644 node/project-management-app/user.js create mode 100644 node/project-management-app/views/edit-user-story-card.js create mode 100644 node/project-management-app/views/help-card.js create mode 100644 node/project-management-app/views/user-story-card.js create mode 100644 node/project-management-app/views/user-story-list-card.js create mode 100644 node/project-management-app/views/widgets/user-story-assignee-widget.js create mode 100644 node/project-management-app/views/widgets/user-story-buttons-widget.js create mode 100644 node/project-management-app/views/widgets/user-story-card-type.js create mode 100644 node/project-management-app/views/widgets/user-story-columns-widget.js create mode 100644 node/project-management-app/views/widgets/user-story-row-widget.js diff --git a/node/project-management-app/.gcloudignore b/node/project-management-app/.gcloudignore new file mode 100644 index 00000000..fb252d65 --- /dev/null +++ b/node/project-management-app/.gcloudignore @@ -0,0 +1,16 @@ +# This file specifies files that are *not* uploaded to Google Cloud +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +node_modules diff --git a/node/project-management-app/README.md b/node/project-management-app/README.md new file mode 100644 index 00000000..4b4101ae --- /dev/null +++ b/node/project-management-app/README.md @@ -0,0 +1,23 @@ +# Google Chat Project Management app + +This code sample creates a Google Chat app that helps users manage user stories +in a software development project. + +This example creates a Google Cloud Function using a Node.js runtime, which +responds to invocation events from Google Chat. + +## Tutorial + +For detailed implementation instructions, follow the +[Project Management Tutorial](https://developers.google.com/chat/tutorial-project-management) +in the Google Chat developer documentation. + +## Run the sample + +To run this sample, you need a Google Cloud +[project](https://cloud.google.com/resource-manager/docs/cloud-platform-resource-hierarchy#projects) +in a Google Workspace account with billing enabled, required APIs turned on, and +authentication set up. You also need to deploy this sample code to a Cloud +Function and configure a Google Chat app using the Cloud Function URL as the +connection endpoint. Once published, add the app to a space and use of the +slash commands to interact with it. diff --git a/node/project-management-app/aip-service.js b/node/project-management-app/aip-service.js new file mode 100644 index 00000000..e570069d --- /dev/null +++ b/node/project-management-app/aip-service.js @@ -0,0 +1,110 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_aip_service] + +const aiplatform = require('@google-cloud/aiplatform'); +const { env } = require('./env.js'); + +// Imports the Google Cloud Prediction service client. +const { PredictionServiceClient } = aiplatform.v1; + +// Imports the helper module for converting arbitrary protobuf.Value objects. +const { helpers } = aiplatform; + +// Specifies the location of the api endpoint +const clientOptions = { + apiEndpoint: `${env.location}-aiplatform.googleapis.com`, +}; + +const publisher = 'google'; +const model = 'text-bison'; + +// Instantiates a client. +const predictionServiceClient = new PredictionServiceClient(clientOptions); + +const generationPrompt = 'Generate a description for a user story with the following title:'; +const grammarPrompt = 'Correct the grammar of the following user story description:' +const expansionPrompt = 'Expand the following user story description:'; + +/** + * Service that executes AI text prediction. + */ +exports.AIPService = { + + /** + * Executes AI text prediction to generate a description for a user story. + * @param {!string} title The title of the user story. + * @return {Promise} The generated description. + */ + generateDescription: async function (title) { + return this.callPredict(`${generationPrompt}\n\n${title}`); + }, + + /** + * Executes AI text prediction to expand a user story description. + * @param {!string} description The description of the user story. + * @return {Promise} The expanded description. + */ + expandDescription: async function (description) { + return this.callPredict(`${expansionPrompt}\n\n${description}`); + }, + + /** + * Executes AI text prediction to correct the grammar of a user story + * description. + * @param {!string} description The description of the user story. + * @return {Promise} The corrected description. + */ + correctDescription: async function (description) { + return this.callPredict(`${grammarPrompt}\n\n${description}`); + }, + + /** + * Executes AI text prediction using the given prompt. + * @param {!string} prompt The prompt to send in the AI prediction request. + * @return {Promise} The predicted text. + */ + callPredict: async function (prompt) { + // Configure the parent resource. + const endpoint = + `projects/${env.project}/locations/${env.location}/publishers/${publisher}/models/${model}`; + + const requestPrompt = { prompt: prompt }; + const instanceValue = helpers.toValue(requestPrompt); + const instances = [instanceValue]; + + const parameter = { + temperature: 0.2, + maxOutputTokens: 256, + topP: 0.95, + topK: 40, + }; + const parameters = helpers.toValue(parameter); + + const request = { + endpoint, + instances, + parameters, + }; + + // Execute the predict request and return the first predicted text content. + const response = await predictionServiceClient.predict(request); + return response[0].predictions[0].structValue.fields.content.stringValue; + }, + +} + +// [END chat_project_management_aip_service] diff --git a/node/project-management-app/app-action-handler.js b/node/project-management-app/app-action-handler.js new file mode 100644 index 00000000..10ce6cd2 --- /dev/null +++ b/node/project-management-app/app-action-handler.js @@ -0,0 +1,403 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_app_action_handler] + +const { AIPService } = require('./aip-service'); +const { UserService } = require('./user-service'); +const { UserStoryService } = require('./user-story-service'); +const { UserStory } = require('./user-story'); +const { User } = require('./user'); +const { EditUserStoryCard } = require('./views/edit-user-story-card'); +const { UserStoryCard } = require('./views/user-story-card'); +const { UserStoryCardType } = require('./views/widgets/user-story-card-type'); + +const USERS_PREFIX = 'users/'; + +/** + * AI actions that can be executed when editing a user story. + * @enum {string} + */ +const AIAction = { + /** Generate the description for a user story. */ + GENERATE: 'GENERATE', + /** Expand the user story description. */ + EXPAND: 'EXPAND', + /** Correct the grammar of the user story description. */ + GRAMMAR: 'GRAMMAR', +}; + +/** + * Handles exceptions thrown by the UserStoryService. + * @param {!Error} e An exception thrown by the UserStoryService. + * @return {Object} A dialog status message with a user facing error message. + * @throws {Error} If the exception is not a recognized type from the app. + */ +function handleException(e) { + if (e.name === 'NotFoundException' || e.name === 'BadRequestException') { + return { + actionResponse: { + type: 'DIALOG', + dialogAction: { + actionStatus: { + statusCode: e.statusCode, + userFacingMessage: e.message + } + } + } + } + } else { + throw e; + } +} + +/** + * Chat application handler for card actions. + */ +class ChatAppActionHandler { + /** + * Instantiates the Chat app action handler. + * @param {!Object} event The event received from Google Chat. + * @param {!ChatApp} app The Chat app that is calling this action handler. + */ + constructor(event, app) { + this.event = event; + this.spaceName = event.space.name; + this.userName = event.user.name; + this.userStoryId = event.common.parameters + ? event.common.parameters.id : undefined; + this.cardType = event.common.parameters + ? event.common.parameters.cardType : undefined; + this.app = app; + } + + /** + * Executes the handler for a card action and returns a message as a response. + * @return {Promise} A message to post back to the DM or space. + */ + async execute() { + if (this.event.isDialogEvent + && this.event.dialogEventType === 'CANCEL_DIALOG') { + return { + actionResponse: { + type: 'DIALOG', + dialogAction: { + actionStatus: 'OK' + } + } + }; + } + switch (this.event.common.invokedFunction) { + case 'myUserStories': + return this.app.handleMyUserStories(); + case 'manageUserStories': + return this.app.handleManageUserStories(); + case 'cleanupUserStories': + return this.app.handleCleanupUserStories(); + case 'editUserStory': + return this.handleEditUserStory(); + case 'assignUserStory': + return this.handleAssignUserStory(); + case 'startUserStory': + return this.handleStartUserStory(); + case 'completeUserStory': + return this.handleCompleteUserStory(); + case 'cancelEditUserStory': + return this.handleCancelEditUserStory(); + case 'saveUserStory': + return this.handleSaveUserStory(); + case 'generateUserStoryDescription': + return this.handleUserStoryAIAction(AIAction.GENERATE); + case 'expandUserStoryDescription': + return this.handleUserStoryAIAction(AIAction.EXPAND); + case 'correctUserStoryDescriptionGrammar': + return this.handleUserStoryAIAction(AIAction.GRAMMAR); + default: + if (this.cardType === UserStoryCardType.SINGLE_DIALOG + || this.cardType === UserStoryCardType.LIST_DIALOG) { + return { + actionResponse: { + type: 'DIALOG', + dialogAction: { + actionStatus: { + statusCode: 'INVALID_ARGUMENT', + userFacingMessage: '⚠️ Unrecognized action.' + } + } + } + } + } + return { text: '⚠️ Unrecognized action.' }; + } + } + + /** + * Handles the edit user story command. + * @return {Promise} A message to open the user story dialog. + */ + async handleEditUserStory() { + try { + const userStory = + await UserStoryService.getUserStory(this.spaceName, this.userStoryId); + const user = userStory.data.assignee + ? await UserService.getUser( + this.spaceName, userStory.data.assignee.replace(USERS_PREFIX, '')) + : undefined; + return { + actionResponse: { + type: 'DIALOG', + dialogAction: { + dialog: { + body: new EditUserStoryCard(userStory, user) + } + } + } + }; + } catch (e) { + return handleException(e); + } + } + + /** + * Handles the assign user story command. + * @return {Promise} A message to post back to the DM or space. + */ + async handleAssignUserStory() { + // Save the user display name and avatar to storage so we can display them + // in the user story cards. + const user = new User( + this.userName.replace(USERS_PREFIX, ''), + this.event.user.displayName, + this.event.user.avatarUrl); + try { + await UserService.createOrUpdateUser(this.spaceName, user); + // Assign the user story. + const userStory = + await UserStoryService.assignUserStory( + this.spaceName, this.userStoryId, this.userName); + return this.buildResponse( + userStory, user, `User story assigned to <${this.userName}>.`); + } catch (e) { + return handleException(e); + } + } + + /** + * Handles the start user story command. + * @return {Promise} A message to post back to the DM or space. + */ + async handleStartUserStory() { + try { + const userStory = + await UserStoryService.startUserStory(this.spaceName, this.userStoryId); + const user = userStory.data.assignee + ? await UserService.getUser(this.spaceName, userStory.data.assignee) + : undefined; + return this.buildResponse( + userStory, user, `<${this.userName}> started the user story.`); + } catch (e) { + return handleException(e); + } + } + + /** + * Handles the complete user story command. + * @return {Promise} A message to post back to the DM or space. + */ + async handleCompleteUserStory() { + try { + const userStory = + await UserStoryService.completeUserStory( + this.spaceName, this.userStoryId); + const user = userStory.data.assignee + ? await UserService.getUser(this.spaceName, userStory.data.assignee) + : undefined; + return this.buildResponse( + userStory, user, `<${this.userName}> completed the user story.`); + } catch (e) { + return handleException(e); + } + } + + /** + * Handles the cancel edit user story command. + * @return {Promise} A message to open the user stories list dialog. + */ + async handleCancelEditUserStory() { + return this.app.handleManageUserStories(); + } + + /** + * Handles the save user story command. + * @return {Promise} A message to open the user story dialog. + */ + async handleSaveUserStory() { + const formInputs = this.event.common.formInputs; + const title = formInputs.title.stringInputs.value[0]; + const description = formInputs.description.stringInputs.value[0]; + const status = formInputs.status + ? formInputs.status.stringInputs.value[0] : ''; + const priority = formInputs.priority + ? formInputs.priority.stringInputs.value[0] : ''; + const size = formInputs.size + ? formInputs.size.stringInputs.value[0] : ''; + try { + const userStory = + await UserStoryService.updateUserStory( + this.spaceName, + this.userStoryId, + title, + description, + status, + priority, + size); + const user = userStory.data.assignee + ? await UserService.getUser(this.spaceName, userStory.data.assignee) + : undefined; + return this.buildResponse( + userStory, user, `<${this.userName}> edited the user story.`); + } catch (e) { + return handleException(e); + } + } + + /** + * Handles the commands to modify the user story description using AI. + * @param {AIAction} action The type of AI action to execute. + * @return {Promise} A message to re-open the edit user story dialog. + */ + async handleUserStoryAIAction(action) { + const formInputs = this.event.common.formInputs; + const title = formInputs.title.stringInputs.value[0]; + let description = formInputs.description.stringInputs.value[0]; + const status = formInputs.status + ? formInputs.status.stringInputs.value[0] : ''; + const priority = formInputs.priority + ? formInputs.priority.stringInputs.value[0] : ''; + const size = formInputs.size + ? formInputs.size.stringInputs.value[0] : ''; + const assignee = this.event.common.parameters + ? this.event.common.parameters.assignee : undefined; + try { + switch (action) { + case AIAction.GENERATE: + if (title.trim().length === 0) { + description = ''; + } else { + description = await AIPService.generateDescription(title); + } + break; + case AIAction.EXPAND: + if (description.trim().length > 0) { + description = await AIPService.expandDescription(description); + } + break; + case AIAction.GRAMMAR: + if (description.trim().length > 0) { + description = await AIPService.correctDescription(description); + } + break; + default: + // Unrecognized action. + } + // Display the (potentially unsaved) current values of the fields from the + // dialog, not the values from the database. + const userStoryData = { + title, + description, + status, + priority, + size, + assignee, + }; + const userStory = new UserStory(this.userStoryId, userStoryData); + const user = assignee + ? await UserService.getUser(this.spaceName, assignee) + : undefined; + return this.buildResponse(userStory, user, ''); + } catch (e) { + return handleException(e); + } + } + + /** + * Builds a response message to send back to Google Chat after updating the + * user story. + * + * The content of the response message depends on the type of the card that + * generated the action: + * + * - single-story card message: update the message's story card + * - story list message: update the message's story list card + * - single-story dialog: push a new dialog with an updated story card + * - story list dialog: push a new dialog with an updated story list card + * + * @param {!UserStory} userStory The updated user story. + * @param {?User} user The user assigned to the user story. + * @param {!string} text Text message describing the executed action. + * @return {Promise} A message to post back to the DM or space. + */ + async buildResponse(userStory, user, text) { + switch (this.cardType) { + case UserStoryCardType.SINGLE_MESSAGE: + return { + text: text, + cardsV2: [{ + cardId: 'userStoryCard', + card: new UserStoryCard(userStory, user) + }], + actionResponse: { + type: 'UPDATE_MESSAGE' + } + }; + case UserStoryCardType.LIST_MESSAGE: + let message = await this.app.handleMyUserStories(); + message.actionResponse = { + type: 'UPDATE_MESSAGE' + }; + return message; + case UserStoryCardType.SINGLE_DIALOG: + return { + actionResponse: { + type: 'DIALOG', + dialogAction: { + dialog: { + body: new EditUserStoryCard(userStory, user) + } + } + } + }; + case UserStoryCardType.LIST_DIALOG: + return this.app.handleManageUserStories(); + default: + return {}; + } + } + +} + +module.exports = { + /** + * Executes the Chat app action handler and returns a message as a response. + * @param {!Object} event The event received from Google Chat. + * @param {!ChatApp} app The Chat app that is calling this action handler. + * @return {Promise} A message to post back to the DM or space. + */ + execute: async function (event, app) { + return new ChatAppActionHandler(event, app).execute(); + } +}; + +// [END chat_project_management_app_action_handler] diff --git a/node/project-management-app/app.js b/node/project-management-app/app.js new file mode 100644 index 00000000..fa2d3d54 --- /dev/null +++ b/node/project-management-app/app.js @@ -0,0 +1,288 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_app] + +const AppActionHandler = require('./app-action-handler'); +const { AIPService } = require('./aip-service'); +const { SpaceService } = require('./space-service'); +const { UserService } = require('./user-service'); +const { UserStoryService } = require('./user-story-service'); +const { Status } = require('./user-story'); +const { HelpCard } = require('./views/help-card'); +const { UserStoryCard } = require('./views/user-story-card'); +const { UserStoryListCard } = require('./views/user-story-list-card'); + +const USERS_PREFIX = 'users/'; + +/** + * Slash commands supported by the Chat app. + * @enum {number} + */ +const SlashCommand = { + CREATE_USER_STORY: 1, + MY_USER_STORIES: 2, + USER_STORY: 3, + MANAGE_USER_STORIES: 4, + CLEANUP_USER_STORIES: 5, +} + +/** + * Google Chat event types. + * @enum {string} + */ +const EventType = { + MESSAGE: 'MESSAGE', + ADDED_TO_SPACE: 'ADDED_TO_SPACE', + REMOVED_FROM_SPACE: 'REMOVED_FROM_SPACE', + CARD_CLICKED: 'CARD_CLICKED', +} + +/** + * Chat application logic. + */ +class ChatApp { + /** + * Instantiates the Chat app. + * @param {!Object} event The event received from Google Chat. + */ + constructor(event) { + this.event = event; + this.spaceName = event.space.name; + this.userName = event.user.name; + } + + /** + * Executes the Chat app and returns a message as a response. + * @return {Promise} A message to post back to the DM or space. + */ + async execute() { + switch (this.event.type) { + case EventType.ADDED_TO_SPACE: + return this.handleAddedToSpace(); + case EventType.REMOVED_FROM_SPACE: + return this.handleRemovedFromSpace(); + case EventType.CARD_CLICKED: + return AppActionHandler.execute(this.event, this); + case EventType.MESSAGE: + if (this.event.message.slashCommand) { + return this.handleSlashCommand(); + } + const argumentText = + (this.event.message.argumentText || '').trim().toLowerCase(); + if (argumentText === 'user stories' + || argumentText === 'userstories') { + return this.handleMyUserStories(); + } + // The default response to a mention/DM is returning a help message. + return this.handleHelp(); + default: + return {}; + } + } + + /** + * Handles the ADDED_TO_SPACE event by sending back a welcome text message. + * It also adds the space to storage so it can later receive user stories. + * @return {Object} A welcome text message to post back to the DM or space. + */ + async handleAddedToSpace() { + await SpaceService.createSpace( + this.spaceName, this.event.space.displayName); + const message = 'Thank you for adding the Project Manager app.' + + ' Message the app for a list of available commands.'; + return { text: message }; + } + + /** + * Handles the REMOVED_FROM_SPACE event by deleting the space from storage. + */ + async handleRemovedFromSpace() { + await SpaceService.deleteSpace(this.spaceName); + return {}; + } + + /** + * Handles a slash command and returns a message as a response. + * @return {Promise} A message to post back to the DM or space. + */ + async handleSlashCommand() { + switch (Number(this.event.message.slashCommand.commandId)) { + case SlashCommand.CREATE_USER_STORY: + return this.handleCreateUserStory(); + case SlashCommand.MY_USER_STORIES: + return this.handleMyUserStories(); + case SlashCommand.USER_STORY: + return this.handleUserStory(); + case SlashCommand.MANAGE_USER_STORIES: + return this.handleManageUserStories(); + case SlashCommand.CLEANUP_USER_STORIES: + return this.handleCleanupUserStories(); + default: + return { text: '⚠️ Unrecognized command.' }; + } + } + + /** + * Handles the create user story command. + * @return {Promise} A message to post back to the DM or space. + */ + async handleCreateUserStory() { + const title = (this.event.message.argumentText || '').trim(); + if (title.length === 0) { + return { + text: 'Title is required.' + + ' Include a title in the command: */createUserStory* _title_' + }; + } + const description = await AIPService.generateDescription(title); + const userStory = + await UserStoryService.createUserStory( + this.spaceName, title, description); + return { + text: `<${this.userName}> created a user story.`, + cardsV2: [{ + cardId: 'userStoryCard', + card: new UserStoryCard(userStory) + }] + }; + } + + /** + * Handles the my user stories command. + * @return {Promise} A message to post back to the DM or space. + */ + async handleMyUserStories() { + const userStories = + await UserStoryService.listUserStoriesByUser( + this.spaceName, this.userName); + const openUserStories = userStories + .filter((userStory) => userStory.data.status !== Status.COMPLETED); + // Obtain a unique list of users assigned to the fetched user stories. + const userIds = [...new Set(openUserStories + .filter((userStory) => !!userStory.data.assignee) + .map((userStory) => userStory.data.assignee))]; + const users = await UserService.getUsers(this.spaceName, userIds); + const title = 'User Stories assigned to ' + this.event.user.displayName; + return { + cardsV2: [{ + cardId: 'userStoriesCard', + card: new UserStoryListCard( + title, + openUserStories, + users, + /* isDialog= */ false) + }] + }; + } + + /** + * Handles the user story command. + * @return {Promise} A message to post back to the DM or space. + */ + async handleUserStory() { + const id = (this.event.message.argumentText || '').trim(); + if (id.length === 0) { + return { + text: 'User story ID is required.' + + ' Include an ID in the command: */userStory* _id_' + }; + } + try { + const userStory = + await UserStoryService.getUserStory(this.spaceName, id); + const user = userStory.data.assignee + ? await UserService.getUser( + this.spaceName, userStory.data.assignee.replace(USERS_PREFIX, '')) + : undefined; + return { + cardsV2: [{ + cardId: 'userStoryCard', + card: new UserStoryCard(userStory, user) + }] + }; + } catch (e) { + if (e.name === 'NotFoundException') { + return { text: `⚠️ User story ${id} not found.` }; + } else { + throw e; + } + } + } + + /** + * Handles the manage user stories command. + * @return {Promise} A message to post back to the DM or space. + */ + async handleManageUserStories() { + const userStories = + await UserStoryService.listAllUserStories(this.spaceName); + // Obtain a unique list of users assigned to the fetched user stories. + const userIds = [...new Set(userStories + .filter(userStory => !!userStory.data.assignee) + .map(userStory => userStory.data.assignee))]; + const users = await UserService.getUsers(this.spaceName, userIds); + return { + actionResponse: { + type: 'DIALOG', + dialogAction: { + dialog: { + body: new UserStoryListCard( + /* title= */ 'User Stories', + userStories, + users, + /* isDialog= */ true) + } + } + } + }; + } + + /** + * Handles the clean up user stories command. + * @return {Promise} A message to post back to the DM or space. + */ + async handleCleanupUserStories() { + await UserStoryService.cleanupUserStories(this.spaceName); + return { text: `<${this.userName}> deleted all the user stories.` }; + } + + /** + * Returns a help message with the list of available commands. + * @return {Object} A message to post back to the DM or space. + */ + handleHelp() { + return { + cardsV2: [{ + cardId: 'helpCard', + card: new HelpCard() + }] + }; + } + +} + +module.exports = { + /** + * Executes the Chat app and returns a message as a response. + * @param {!Object} event The event received from Google Chat. + * @return {Promise} A message to post back to the DM or space. + */ + execute: async function (event) { + return new ChatApp(event).execute(); + } +}; + +// [END chat_project_management_app] diff --git a/node/project-management-app/env.js b/node/project-management-app/env.js new file mode 100644 index 00000000..bec49d27 --- /dev/null +++ b/node/project-management-app/env.js @@ -0,0 +1,28 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_env] + +/** + * Project environment settings. + */ +const env = { + project: 'developer-productivity-402319', //'YOUR_PROJECT_ID', + location: 'us-central1', //'YOUR_PROJECT_LOCATION', +}; + +exports.env = env; + +// [END chat_project_management_env] diff --git a/node/project-management-app/exceptions.js b/node/project-management-app/exceptions.js new file mode 100644 index 00000000..47fca32c --- /dev/null +++ b/node/project-management-app/exceptions.js @@ -0,0 +1,40 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_exceptions] + +/** + * A User Story is not found in storage. + */ +exports.NotFoundException = class extends Error { + constructor(message) { + super(message); + this.name = 'NotFoundException'; + this.statusCode = 'NOT_FOUND'; + } +} + +/** + * The requested operation is invalid. + */ +exports.BadRequestException = class extends Error { + constructor(message) { + super(message); + this.name = 'BadRequestException'; + this.statusCode = 'INVALID_ARGUMENT'; + } +} + +// [END chat_project_management_exceptions] diff --git a/node/project-management-app/firestore-service.js b/node/project-management-app/firestore-service.js new file mode 100644 index 00000000..5addb923 --- /dev/null +++ b/node/project-management-app/firestore-service.js @@ -0,0 +1,280 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_firestore] + +const { Firestore, FieldPath } = require('@google-cloud/firestore'); +const { NotFoundException } = require('./exceptions'); +const { UserStory } = require('./user-story'); +const { User } = require('./user'); + +const SPACES_PREFIX = 'spaces/'; +const SPACES_COLLECTION = 'spaces'; +const USER_STORIES_COLLECTION = 'userStories'; +const USERS_COLLECTION = 'users'; +const BATCH_SIZE = 50; + +// Initialize the Firestore database using Application Default Credentials. +const db = new Firestore(); + +/** + * Returns a reference to the user stories subcollection for a space. + * @param {!string} spaceName The resource name of the space. + * @return {FirebaseFirestore.CollectionReference} A reference to the user + * stories subcollection for the space. + */ +function getUserStoriesCollection(spaceName) { + return db + .collection(SPACES_COLLECTION) + .doc(spaceName.replace(SPACES_PREFIX, '')) + .collection(USER_STORIES_COLLECTION); +} + +/** + * Returns a reference to the users subcollection for a space. + * @param {!string} spaceName The resource name of the space. + * @return {FirebaseFirestore.CollectionReference} A reference to the users + * subcollection for the space. + */ +function getUsersCollection(spaceName) { + return db + .collection(SPACES_COLLECTION) + .doc(spaceName.replace(SPACES_PREFIX, '')) + .collection(USERS_COLLECTION); +} + +/** + * Batch delete all the documents returned from the specified query, with + * support for resolving a promise after all the documents are deleted. + * (Function copied from + * https://cloud.google.com/firestore/docs/manage-data/delete-data#collections). + * @param {FirebaseFirestore.Query} query The query to fetch documents from. + * @param {function()} resolve Function to resolve the promise after all the + * documents are deleted. + * @return {Promise} + */ +async function deleteQueryBatch(query, resolve) { + const snapshot = await query.get(); + + const batchSize = snapshot.size; + if (batchSize === 0) { + // When there are no documents left, we are done + resolve(); + return; + } + + // Delete documents in a batch + const batch = db.batch(); + snapshot.docs.forEach((doc) => { + batch.delete(doc.ref); + }); + await batch.commit(); + + // Recurse on the next process tick, to avoid exploding the stack. + process.nextTick(() => { + deleteQueryBatch(query, resolve); + }); +} + +/** + * Service to read and write user stories in storage using Cloud Firestore. + */ +exports.FirestoreService = { + + /** + * Adds a space to storage. + * @param {!string} spaceName The resource name of the space. + * @param {?string} displayName The display name of the space. + * @return {Promise} + */ + createSpace: async function (spaceName, displayName) { + let data = {}; + if (displayName) { + data.displayName = displayName + }; + const docRef = db + .collection(SPACES_COLLECTION) + .doc(spaceName.replace(SPACES_PREFIX, '')); + await docRef.set(data); + }, + + /** + * Deletes a space from storage. Also deletes any user stories in the space. + * @param {!string} spaceName The resource name of the space. + * @return {Promise} + */ + deleteSpace: async function (spaceName) { + await this.cleanupUserStories(spaceName); + await this.cleanupUsers(spaceName); + const docRef = db + .collection(SPACES_COLLECTION) + .doc(spaceName.replace(SPACES_PREFIX, '')); + await docRef.delete(); + }, + + /** + * Fetches a user story from storage. + * @param {!string} spaceName The resource name of the space. + * @param {!string} id The ID of the user story in storage. + * @return {Promise} The fetched user story data. + * @throws {NotFoundException} If the user story does not exist. + */ + getUserStory: async function (spaceName, id) { + const collectionRef = getUserStoriesCollection(spaceName); + const doc = await collectionRef.doc(id).get(); + if (!doc.exists) { + throw new NotFoundException('User story not found.'); + } + return new UserStory(id, doc.data()); + }, + + /** + * Creates a new user story in storage. + * @param {!string} spaceName The resource name of the space. + * @param {!Object} data The data to persist. + * @return {Promise} The created user story data. + */ + createUserStory: async function (spaceName, data) { + const collectionRef = getUserStoriesCollection(spaceName); + const docRef = await collectionRef.add(data); + const doc = await docRef.get(); + return new UserStory(doc.id, doc.data()); + }, + + /** + * Updates an existing user story in storage. + * Only the fields provided in `data` are updated. + * @param {!string} spaceName The resource name of the space. + * @param {!string} id The ID of the user story in storage. + * @param {!Object} data The data to persist. + * @return {Promise} The updated user story data. + */ + updateUserStory: async function (spaceName, id, data) { + const collectionRef = getUserStoriesCollection(spaceName); + await collectionRef.doc(id).update(data); + const doc = await collectionRef.doc(id).get() + return new UserStory(id, doc.data()); + }, + + /** + * Lists all the user stories in storage. + * @param {!string} spaceName The resource name of the space. + * @return {Promise} An array with the fetched user story data. + */ + listAllUserStories: async function (spaceName,) { + let userStories = []; + const collectionRef = getUserStoriesCollection(spaceName); + const snapshot = await collectionRef.get(); + snapshot.forEach((doc) => + userStories.push(new UserStory(doc.id, doc.data()))); + return userStories; + }, + + /** + * Lists all the user stories in storage assigned to the specified user. + * @param {!string} spaceName The resource name of the space. + * @param {!string} userId The ID of the user. + * @return {Promise} An array with the fetched user story data. + */ + listUserStoriesByUser: async function (spaceName, userId) { + let userStories = []; + const collectionRef = getUserStoriesCollection(spaceName); + const snapshot = + await collectionRef.where('assignee', '==', userId).get(); + snapshot.forEach((doc) => + userStories.push(new UserStory(doc.id, doc.data()))); + return userStories; + }, + + /** + * Deletes all the user stories in storage. + * @param {!string} spaceName The resource name of the space. + * @return {Promise} + */ + cleanupUserStories: async function (spaceName) { + const collectionRef = getUserStoriesCollection(spaceName); + const query = collectionRef.orderBy('__name__').limit(BATCH_SIZE); + return new Promise((resolve, reject) => { + deleteQueryBatch(query, resolve).catch(reject); + }); + }, + + /** + * Fetches a user from storage. + * @param {!string} spaceName The resource name of the space. + * @param {!string} userId The ID of the user. + * @return {Promise} The fetched user data. + * @throws {NotFoundException} If the user does not exist. + */ + getUser: async function (spaceName, userId) { + const collectionRef = getUsersCollection(spaceName); + const doc = await collectionRef.doc(userId).get(); + if (!doc.exists) { + throw new NotFoundException('User not found.'); + } + return new User(userId, doc.data().displayName, doc.data().avatarUrl); + }, + + /** + * Fetches multiple users from storage. + * @param {!string} spaceName The resource name of the space. + * @param {!string[]} userIds The IDs of the users. + * @return {Promise>} The fetched user data in a map. + */ + getUsers: async function (spaceName, userIds) { + let users = {}; + if (userIds.length === 0) { + return users; + } + const collectionRef = getUsersCollection(spaceName); + const snapshot = + await collectionRef.where(FieldPath.documentId(), 'in', userIds).get(); + snapshot.forEach((doc) => users[doc.id] = + new User(doc.id, doc.data().displayName, doc.data().avatarUrl)); + return users; + }, + + /** + * Adds information about a user to storage. + * @param {!string} spaceName The resource name of the space. + * @param {!User} user The user data to persist. + * @return {Promise} + */ + createOrUpdateUser: async function (spaceName, user) { + let data = { displayName: user.displayName }; + if (user.avatarUrl) { + data.avatarUrl = user.avatarUrl; + } + const collectionRef = getUsersCollection(spaceName); + const docRef = collectionRef.doc(user.id); + await docRef.set(data); + }, + + /** + * Deletes all the users in storage. + * @param {!string} spaceName The resource name of the space. + * @return {Promise} + */ + cleanupUsers: async function (spaceName) { + const collectionRef = getUsersCollection(spaceName); + const query = collectionRef.orderBy('__name__').limit(BATCH_SIZE); + return new Promise((resolve, reject) => { + deleteQueryBatch(query, resolve).catch(reject); + }); + }, + +}; + +// [END chat_project_management_firestore] diff --git a/node/project-management-app/index.js b/node/project-management-app/index.js new file mode 100644 index 00000000..dc53db37 --- /dev/null +++ b/node/project-management-app/index.js @@ -0,0 +1,35 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_index] + +const functions = require('@google-cloud/functions-framework'); +const App = require('./app'); + +functions.http('projectManagementChatApp', async (req, res) => { + if (req.method === 'GET' || !req.body.message) { + res + .status(400) + .send('This function is meant to be used in a Google Chat app.'); + } + + const event = req.body; + console.log(JSON.stringify({ message: 'Request received', event })); + const responseMessage = await App.execute(event); + res.json(responseMessage); + console.log(JSON.stringify({ message: 'Response sent', responseMessage })); +}); + +// [END chat_project_management_index] diff --git a/node/project-management-app/package-lock.json b/node/project-management-app/package-lock.json new file mode 100644 index 00000000..1ea732e6 --- /dev/null +++ b/node/project-management-app/package-lock.json @@ -0,0 +1,2302 @@ +{ + "name": "project-management-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "project-management-app", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/aiplatform": "^3.4.0", + "@google-cloud/firestore": "^7.1.0", + "@google-cloud/functions-framework": "^3.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "dependencies": { + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@google-cloud/aiplatform": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@google-cloud/aiplatform/-/aiplatform-3.4.0.tgz", + "integrity": "sha512-qA/uadEqx3NK+/aq9cfGpCF0fCyV0g6ngZUTCva3+32/Mc76aaeaGYpsNPe7eS0HIHlaO9rT4j5LTaCBHI6Tew==", + "dependencies": { + "google-gax": "^4.0.3", + "protobuf.js": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.1.0.tgz", + "integrity": "sha512-kkTC0Sb9r2lONuFF8Tr2wFfBfk0DT1/EKcTKOhsuoXUVClv3jCqGYVPtHgQsHFjdOsubS+tx9G5D5WG+obB2DA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.0.4", + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/functions-framework": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@google-cloud/functions-framework/-/functions-framework-3.3.0.tgz", + "integrity": "sha512-+4O1dX5VNRK1W1NyAia7zy5jLf88ytuz39/1kVUUaNiOf76YbMZKV0YjZwfk7uEwRrC6l2wynK1G+q8Gb5DeVw==", + "dependencies": { + "@types/express": "4.17.17", + "body-parser": "^1.18.3", + "cloudevents": "^7.0.0", + "express": "^4.16.4", + "minimist": "^1.2.7", + "on-finished": "^2.3.0", + "read-pkg-up": "^7.0.1", + "semver": "^7.3.5" + }, + "bin": { + "functions-framework": "build/src/main.js", + "functions-framework-nodejs": "build/src/main.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.6", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.6.tgz", + "integrity": "sha512-yq3qTy23u++8zdvf+h4mz4ohDFi681JAkMZZPTKh8zmUVh0AKLisFlgxcn22FMNowXz15oJ6pqgwT7DJ+PdJvg==", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.10.tgz", + "integrity": "sha512-CAqDfoaQ8ykFd9zqBDn4k6iWT9loLAlc2ETmDFS9JCD70gDcnA4L3AFEo2iV7KyAtAAHFW9ftq1Fz+Vsgq80RQ==", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.4", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.4.tgz", + "integrity": "sha512-N7UDG0/xiPQa2D/XrVJXjkWbpqHCd2sBaB32ggRF2l83RhPfamgKGF8gwwqyksS95qUS5ZYF9aF+lLPRlwI2UA==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.4.tgz", + "integrity": "sha512-2in/lrHRNmDvHPgyormtEralhPcN3An1gLjJzj2Bw145VBxkQ75JEXW6CTdMAwShiHQcYsl2d10IjQSdJSJz4g==" + }, + "node_modules/@types/connect": { + "version": "3.4.37", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.37.tgz", + "integrity": "sha512-zBUSRqkfZ59OcwXon4HVxhx5oWCJmc0OtBTK05M+p0dYjgN6iTwIL2T/WbsQZrEsdnwaF9cWQ+azOnpPvIqY3Q==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", + "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.38", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.38.tgz", + "integrity": "sha512-hXOtc0tuDHZPFwwhuBJXPbjemWtXnJjbvuuyNH2Y5Z6in+iXc63c4eXYDc7GGGqHy+iwYqAJMdaItqdnbcBKmg==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.3.tgz", + "integrity": "sha512-pP0P/9BnCj1OVvQR2lF41EkDG/lWWnDyA203b/4Fmi2eTyORnBtcDoKDwjWQthELrBvWkMOrvSOnZ8OVlW6tXA==" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" + }, + "node_modules/@types/mime": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.4.tgz", + "integrity": "sha512-1Gjee59G25MrQGk8bsNvC6fxNiRgUlGn2wlhGf95a59DrprnnHk80FIMMFG9XHMdrfsuA119ht06QPDXA1Z7tw==" + }, + "node_modules/@types/node": { + "version": "20.8.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.7.tgz", + "integrity": "sha512-21TKHHh3eUHIi2MloeptJWALuCu5H7HQTdTrWIFReA8ad+aggoX+lRes3ex7/FtpC+sVUpFMQ+QTfYr74mruiQ==", + "dependencies": { + "undici-types": "~5.25.1" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.3.tgz", + "integrity": "sha512-ehPtgRgaULsFG8x0NeYJvmyH1hmlfsNLujHe9dQEia/7MAJYdzMSi19JtchUHjmBA6XC/75dK55mzZH+RyieSg==" + }, + "node_modules/@types/qs": { + "version": "6.9.9", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.9.tgz", + "integrity": "sha512-wYLxw35euwqGvTDx6zfY1vokBFnsK0HNrzc6xNHchxfO2hpuRg74GbkEW7e3sSmPvj0TjCDT1VCa6OtHXnubsg==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.6.tgz", + "integrity": "sha512-+0autS93xyXizIYiyL02FCY8N+KkKPhILhcUSA276HxzreZ16kl+cmwvV2qAM/PuCCwPXzOXOWhiPcw20uSFcA==" + }, + "node_modules/@types/request": { + "version": "2.48.11", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.11.tgz", + "integrity": "sha512-HuihY1+Vss5RS9ZHzRyTGIzwPTdrJBkCm/mAeLRYrOQu/MGqyezKXWOK1VhCnR+SDbp9G2mRUP+OVEqCrzpcfA==", + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + } + }, + "node_modules/@types/send": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.3.tgz", + "integrity": "sha512-/7fKxvKUoETxjFUsuFlPB9YndePpxxRAOfGC/yJdc9kTjTeP5kRCTzfnE8kPUKCeyiyIZu0YQ76s50hCedI1ug==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.4.tgz", + "integrity": "sha512-aqqNfs1XTF0HDrFdlY//+SGUxmdSUbjeRXb5iaZc3x0/vMbYmdw9qvOgHWOyyLFxSSRnUuP5+724zBgfw8/WAw==", + "dependencies": { + "@types/http-errors": "*", + "@types/mime": "*", + "@types/node": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.4.tgz", + "integrity": "sha512-95Sfz4nvMAb0Nl9DTxN3j64adfwfbBPEYq14VN7zT5J5O2M9V6iZMIIQU1U+pJyl9agHYHNCqhCXgyEtIRRa5A==" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cloudevents": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cloudevents/-/cloudevents-7.0.2.tgz", + "integrity": "sha512-WiOqWsNkMZmMMZ6xa3kzx/MA+8+V+c5eGkStZIcik+Px2xCobmzcacw1EOGyfhODaQKkIv8TxXOOLzV69oXFqA==", + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "json-bigint": "^1.0.0", + "process": "^0.11.10", + "util": "^0.12.4", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=16 <=20" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/duplexify": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", + "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/express/node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==" + }, + "node_modules/gaxios": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.1.1.tgz", + "integrity": "sha512-bw8smrX+XlAoo9o1JAksBwX+hi/RG15J+NTSxmNPIclKC3ZVK6C2afwY8OSdRvOK0+ZLecUJYtj2MmjOt3Dm0w==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.0.0.tgz", + "integrity": "sha512-Ozxyi23/1Ar51wjUT2RDklK+3HxqDr8TLBNK8rBBFQ7T85iIGnXnVusauj06QyqCXRFZig8LZC+TUddWbndlpQ==", + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/google-auth-library": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.1.0.tgz", + "integrity": "sha512-1M9HdOcQNPV5BwSXqwwT238MTKodJFBxZ/V2JP397ieOLv4FjQdfYb9SooR7Mb+oUT2IJ92mLJQf804dyx0MJA==", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.0.0", + "gcp-metadata": "^6.0.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.0.5.tgz", + "integrity": "sha512-yLoYtp4zE+8OQA74oBEbNkbzI6c95W01JSL7RqC8XERKpRvj3ytZp1dgnbA6G9aRsc8pZB25xWYBcCmrbYOEhA==", + "dependencies": { + "@grpc/grpc-js": "~1.9.6", + "@grpc/proto-loader": "^0.7.0", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.0.0", + "node-fetch": "^2.6.1", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.0", + "protobufjs": "7.2.5", + "retry-request": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.0.1.tgz", + "integrity": "sha512-KcFVtoP1CVFtQu0aSk3AyAt2og66PFhZAlkUOuWKwzMLoulHXG5W5wE5xAnHb+yl3/wEFoqGW7/cDGMU8igDZQ==", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", + "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", + "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.0.tgz", + "integrity": "sha512-HQ4J+ic8hKrgIt3mqk6cVOVrW2ozL4KdvHlqpBv9vDYWx9ysAgENAdvy4FoGF+KFdhR7nQTNm5J0ctAeOwn+3g==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.0.tgz", + "integrity": "sha512-FB/YaNrpiPkyQNSNPilpn8qn0KdEfkgmJ9JP93PQyF/U4bAiXY5BiUdDhiDO4S48uSQ6AesklgVlrKiqZPzegw==", + "dependencies": { + "protobufjs": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobuf.js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/protobuf.js/-/protobuf.js-1.1.2.tgz", + "integrity": "sha512-USO7Xus/pzPw549M1TguiyoOrKEhm9VMXv+CkDufcjMC8Rd7EPbxeRQPEjCV8ua1tm0k7z9xHkogcxovZogWdA==", + "dependencies": { + "long": "~1.1.2" + } + }, + "node_modules/protobuf.js/node_modules/long": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/long/-/long-1.1.5.tgz", + "integrity": "sha512-TU6nAF5SdasnTr28c7e74P4Crbn9o3/zwo1pM22Wvg2i2vlZ4Eelxwu4QT7j21z0sDBlJDEnEZjXTZg2J8WJrg==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/protobufjs": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.5.tgz", + "integrity": "sha512-gGXRSXvxQ7UiPgfw8gevrfRWcTlSbOFg+p/N+JVJEK5VhueL2miT6qTymqAmjr1Q5WbOCyJbyrk6JfWKwlFn6A==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/retry-request": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.1.tgz", + "integrity": "sha512-ZI6vJp9rfB71mrZpw+n9p/B6HCsd7QJlSEQftZ+xfJzr3cQ9EPGKw1FF0BnViJ0fYREX6FhymBD2CARpmsFciQ==", + "dependencies": { + "@types/request": "^2.48.8", + "debug": "^4.1.1", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/retry-request/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/retry-request/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", + "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==" + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", + "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", + "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/node/project-management-app/package.json b/node/project-management-app/package.json new file mode 100644 index 00000000..74af24cf --- /dev/null +++ b/node/project-management-app/package.json @@ -0,0 +1,24 @@ +{ + "name": "project-management-app", + "version": "1.0.0", + "description": "A sample project management app for Google Chat", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/googleworkspace/google-chat-samples.git" + }, + "keywords": [ + "Google Chat", + "Project Management" + ], + "author": "Gustavo Tondello", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/aiplatform": "^3.4.0", + "@google-cloud/firestore": "^7.1.0", + "@google-cloud/functions-framework": "^3.0.0" + } +} diff --git a/node/project-management-app/space-service.js b/node/project-management-app/space-service.js new file mode 100644 index 00000000..78c1fbb8 --- /dev/null +++ b/node/project-management-app/space-service.js @@ -0,0 +1,46 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_space_service] + +const { FirestoreService } = require('./firestore-service'); + +/** + * Service that executes all the Space application logic. + */ +exports.SpaceService = { + + /** + * Creates a new space in storage with the given display name. + * @param {!string} spaceName The resource name of the space. + * @param {?string} displayName The display name of the space. + * @return {Promise} + */ + createSpace: async function (spaceName, displayName) { + return FirestoreService.createSpace(spaceName, displayName); + }, + + /** + * Deletes a space from storage. Also deletes any user stories in the space. + * @param {!string} spaceName The resource name of the space. + * @return {Promise} + */ + deleteSpace: async function (spaceName) { + return FirestoreService.deleteSpace(spaceName); + }, + +} + +// [END chat_project_management_space_service] diff --git a/node/project-management-app/user-service.js b/node/project-management-app/user-service.js new file mode 100644 index 00000000..9268e3c7 --- /dev/null +++ b/node/project-management-app/user-service.js @@ -0,0 +1,60 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_user_service] + +const { NotFoundException } = require('./exceptions'); +const { FirestoreService } = require('./firestore-service'); +const { User } = require('./user'); + +/** + * Service that executes all the User application logic. + */ +exports.UserService = { + + /** + * Fetches a user from storage. + * @param {!string} spaceName The resource name of the space. + * @param {!string} userId The ID of the user. + * @return {Promise} The fetched user data. + * @throws {NotFoundException} If the user does not exist. + */ + getUser: async function (spaceName, userId) { + return FirestoreService.getUser(spaceName, userId); + }, + + /** + * Fetches multiple users from storage. + * @param {!string} spaceName The resource name of the space. + * @param {!string[]} userIds The IDs of the users. + * @return {Promise>} The fetched user data in a map. + */ + getUsers: async function (spaceName, userIds) { + return FirestoreService.getUsers(spaceName, userIds); + }, + + /** + * Adds information about a user to storage. + * @param {!string} spaceName The resource name of the space. + * @param {!User} user The user data to persist. + * @return {Promise} + */ + createOrUpdateUser: async function (spaceName, user) { + return FirestoreService.createOrUpdateUser(spaceName, user); + }, + +} + +// [END chat_project_management_user_service] diff --git a/node/project-management-app/user-story-service.js b/node/project-management-app/user-story-service.js new file mode 100644 index 00000000..78a00044 --- /dev/null +++ b/node/project-management-app/user-story-service.js @@ -0,0 +1,197 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_user_story_service] + +const { BadRequestException, NotFoundException } = require('./exceptions'); +const { FirestoreService } = require('./firestore-service'); +const { UserStory, Status, Priority, Size } = require('./user-story'); + +const USERS_PREFIX = 'users/'; + +/** + * Service that executes all the User Story application logic. + */ +exports.UserStoryService = { + + /** + * Fetches a user story from storage. + * @param {!string} spaceName The resource name of the space. + * @param {!string} id The ID of the user story in storage. + * @return {Promise} The fetched user story data. + * @throws {NotFoundException} If the user story does not exist. + */ + getUserStory: async function (spaceName, id) { + return FirestoreService.getUserStory(spaceName, id); + }, + + /** + * Creates a new user story in storage with the given title and the status + * set to `OPEN`. + * @param {!string} spaceName The resource name of the space. + * @param {!string} title The short title of the user story. + * @param {string} description The description of the user story. + * @return {Promise} The created user story data. + * @throws {BadRequestException} If the `title` is null or empty. + */ + createUserStory: async function (spaceName, title, description) { + if (title === undefined || title === null || title.trim().length === 0) { + throw BadRequestException('Title is required.'); + } + let data = { + title: title, + status: Status.OPEN, + } + if (description) { + data.description = description; + } + return FirestoreService.createUserStory(spaceName, data); + }, + + /** + * Updates the `assignee` field of the user story. + * @param {!string} spaceName The resource name of the space. + * @param {!string} id The ID of the user story in storage. + * @param {!string} userName The resource name of the user. + * @return {Promise} The updated user story data. + * @throws {NotFoundException} If the user story does not exist. + * @throws {BadRequestException} If the user story is already completed. + */ + assignUserStory: async function (spaceName, id, userName) { + const userStory = await this.getUserStory(spaceName, id); + if (userStory.data.status === Status.COMPLETED) { + throw BadRequestException('User story is already completed.'); + } + return FirestoreService.updateUserStory(spaceName, id, { + assignee: userName.replace(USERS_PREFIX, '') + }); + }, + + /** + * Updates the fields of the user story. + * Only the provided fields will be updated. + * @param {!string} spaceName The resource name of the space. + * @param {!string} id The ID of the user story in storage. + * @param {?string} title The short title of the user story. + * @param {?string} description The long description of the user story. + * @param {?Status} status The current status of the user story. + * @param {?Priority} priority The relative priority of the user story. + * @param {?Size} size The relative size of the user story. + * @return {Promise} The updated user story data. + * @throws {NotFoundException} If the user story does not exist. + * @throws {BadRequestException} If the user story is already completed or if + * one of the provided field values is invalid. + */ + updateUserStory: async function ( + spaceName, id, title, description, status, priority, size) { + // getUserStory will throw NotFoundException if the story doesn't exist. + await this.getUserStory(spaceName, id); + let data = {}; + if (title !== undefined && title !== null) { + if (title.trim().length === 0) { + throw new BadRequestException('Title is required.'); + } + data.title = title; + } + if (description !== undefined && description !== null) { + data.description = description; + } + if (status !== undefined && status !== null) { + if (!Object.values(Status).includes(status)) { + throw new BadRequestException('Invalid status value.'); + } + data.status = status; + } + if (priority !== undefined && priority !== null) { + if (priority !== '' && !Object.values(Priority).includes(priority)) { + throw new BadRequestException('Invalid priority value.'); + } + data.priority = priority; + } + if (size !== undefined && size !== null) { + if (size !== '' && !Object.values(Size).includes(size)) { + throw new BadRequestException('Invalid size value.'); + } + data.size = size; + } + return FirestoreService.updateUserStory(spaceName, id, data); + }, + + /** + * Sets the `status` of the user story to `OPEN`. + * @param {!string} spaceName The resource name of the space. + * @param {!string} id The ID of the user story in storage. + * @return {Promise} The updated user story data. + * @throws {NotFoundException} If the user story does not exist. + * @throws {BadRequestException} If the user story is already started. + */ + startUserStory: async function (spaceName, id) { + const userStory = await this.getUserStory(spaceName, id); + if (userStory.data.status !== Status.OPEN) { + throw Error('User story is already started or completed.'); + } + const data = { status: Status.STARTED }; + return FirestoreService.updateUserStory(spaceName, id, data); + }, + + /** + * Sets the `status` of the user story to `COMPLETED`. + * @param {!string} spaceName The resource name of the space. + * @param {!string} id The ID of the user story in storage. + * @return {Promise} The updated user story data. + * @throws {NotFoundException} If the user story does not exist. + * @throws {BadRequestException} If the user story is already completed. + */ + completeUserStory: async function (spaceName, id) { + const userStory = await this.getUserStory(spaceName, id); + if (userStory.data.status === Status.COMPLETED) { + throw Error('User story is already completed.'); + } + const data = { status: Status.COMPLETED }; + return FirestoreService.updateUserStory(spaceName, id, data); + }, + + /** + * Lists all the user stories in storage. + * @param {!string} spaceName The resource name of the space. + * @return {Promise} An array with the fetched user story data. + */ + listAllUserStories: async function (spaceName) { + return FirestoreService.listAllUserStories(spaceName); + }, + + /** + * Lists all the user stories in storage assigned to the specified user. + * @param {!string} spaceName The resource name of the space. + * @param {!string} userName The resource name of the user. + * @return {Promise} An array with the fetched user story data. + */ + listUserStoriesByUser: async function (spaceName, userName) { + return FirestoreService.listUserStoriesByUser( + spaceName, userName.replace(USERS_PREFIX, '')); + }, + + /** + * Deletes all the user stories in storage. + * @param {!string} spaceName The resource name of the space. + * @return {Promise} + */ + cleanupUserStories: async function (spaceName) { + return FirestoreService.cleanupUserStories(spaceName); + }, + +} + +// [END chat_project_management_user_story_service] diff --git a/node/project-management-app/user-story.js b/node/project-management-app/user-story.js new file mode 100644 index 00000000..8a27e273 --- /dev/null +++ b/node/project-management-app/user-story.js @@ -0,0 +1,104 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_user_story] + +/** + * User story statuses. + * @enum {string} + */ +exports.Status = { + /** Work on the user story has not started yet. */ + OPEN: 'OPEN', + /** Work on the user story has started. */ + STARTED: 'STARTED', + /** Work on the user story is completed. */ + COMPLETED: 'COMPLETED', +}; + +/** + * User story status icons. + * @enum {string} + */ +exports.StatusIcon = { + /** Work on the user story has not started yet. */ + OPEN: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/pending/materialiconsoutlined/48dp/1x/outline_pending_black_48dp.png', + /** Work on the user story has started. */ + STARTED: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/av/play_circle/materialiconsoutlined/48dp/1x/outline_play_circle_black_48dp.png', + /** Work on the user story is completed. */ + COMPLETED: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/check_circle/materialiconsoutlined/48dp/1x/outline_check_circle_black_48dp.png', +} + +/** + * User story priority levels. + * @enum {string} + */ +exports.Priority = { + /** Low priority. */ + LOW: 'Low', + /** Medium priority. */ + MEDIUM: 'Medium', + /** High priority. */ + HIGH: 'High', +}; + +/** + * User story T-shirt sizes. + * @enum {string} + */ +exports.Size = { + /** Small size. */ + SMALL: 'Small', + /** Medium size. */ + MEDIUM: 'Medium', + /** Large size. */ + LARGE: 'Large', +}; + +/** + * The user-provided data for a User Story. + * @record + */ +exports.UserStoryData = class { + constructor() { + /** @type {!string} The short title of the user story. */ + this.title; + /** @type {!string} The long description of the user story. */ + this.description; + /** @type {!Status} The current status of the user story. */ + this.status; + /** @type {?Priority} The relative priority of the user story. */ + this.priority; + /** @type {?Size} The relative size of the user story. */ + this.size; + /** @type {?string} The current assignee of the user story. */ + this.assignee; + } +} + +/** + * A user story managed by the app. + * @record + */ +exports.UserStory = class { + constructor(id, data) { + /** @type {!string} The ID of the user story in storage. */ + this.id = id; + /** @type {!UserStoryData} The user-provided data of the user story. */ + this.data = data; + } +} + +// [END chat_project_management_user_story] diff --git a/node/project-management-app/user.js b/node/project-management-app/user.js new file mode 100644 index 00000000..a865ceb8 --- /dev/null +++ b/node/project-management-app/user.js @@ -0,0 +1,39 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_user] + +/** + * A user that interacted with the app. + * @record + */ +exports.User = class { + /** + * Creates a new user. + * @param {!string} id The ID of the user. + * @param {!string} displayName The display name of the user. + * @param {?string} avatarUrl The avatar URL of the user. + */ + constructor(id, displayName, avatarUrl) { + /** @type {!string} The ID of the user. */ + this.id = id; + /** @type {!string} The display name of the user. */ + this.displayName = displayName; + /** @type {?string} The avatar URL of the user. */ + this.avatarUrl = avatarUrl; + } +} + +// [END chat_project_management_user] diff --git a/node/project-management-app/views/edit-user-story-card.js b/node/project-management-app/views/edit-user-story-card.js new file mode 100644 index 00000000..75bbd195 --- /dev/null +++ b/node/project-management-app/views/edit-user-story-card.js @@ -0,0 +1,211 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_edit_user_story_card] + +const { UserStory, Status, Priority, Size } = require('../user-story'); +const { User } = require('../user'); +const { UserStoryAssigneeWidget } = require('./widgets/user-story-assignee-widget'); +const { UserStoryButtonsWidget } = require('./widgets/user-story-buttons-widget'); +const { UserStoryCardType } = require('./widgets/user-story-card-type'); + +/** + * Builds an array of SelectionInput widgets taking the items from an enum. + * @param {!Object} itemsEnum An enum with the available items. + * @param {?string} value The current value of the field. + * @return {Array} An array of SelectionInput widgets. + */ +function buildSelectionItems(itemsEnum, value) { + return Object.keys(itemsEnum).map((key) => ({ + text: itemsEnum[key], + value: itemsEnum[key], + selected: value === itemsEnum[key] + })); +} + +/** + * A Card to edit a user story. + */ +exports.EditUserStoryCard = class { + + /** + * Creates a Card with a standard view of a user story. + * @param {!UserStory} userStory A user story. + * @param {?User} user The user assigned to the story. + */ + constructor(userStory, user) { + if (userStory === null || userStory === undefined) { + return; + } + const userStoryData = userStory.data; + const parameters = [ + { + key: 'id', + value: userStory.id + }, + { + key: 'assignee', + value: userStoryData.assignee + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ]; + + this.header = { + title: userStoryData.title, + subtitle: 'ID: ' + userStory.id + }; + + this.sections = [ + { + widgets: [ + { + textInput: { + name: 'title', + label: 'Title', + type: 'SINGLE_LINE', + value: userStoryData.title || '' + } + }, + { + textInput: { + name: 'description', + label: 'Description', + type: 'MULTIPLE_LINE', + value: userStoryData.description || '' + } + }, + { + buttonList: + { + buttons: [ + { + text: 'Regenerate', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/generating_tokens/materialiconsoutlined/24dp/1x/outline_generating_tokens_black_24dp.png', + altText: 'Regenerate', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'generateUserStoryDescription', + interaction: 'OPEN_DIALOG', + parameters: parameters + } + } + }, + { + text: 'Expand', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/generating_tokens/materialiconsoutlined/24dp/1x/outline_generating_tokens_black_24dp.png', + altText: 'Expand', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'expandUserStoryDescription', + interaction: 'OPEN_DIALOG', + parameters: parameters + } + } + }, + { + text: 'Correct grammar', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/spellcheck/materialicons/24dp/1x/baseline_spellcheck_black_24dp.png', + altText: 'Correct grammar', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'correctUserStoryDescriptionGrammar', + interaction: 'OPEN_DIALOG', + parameters: parameters + } + } + } + ] + } + }, + { + selectionInput: { + name: 'status', + label: 'Status', + type: 'DROPDOWN', + items: buildSelectionItems(Status, userStoryData.status) + } + }, + { + columns: { + columnItems: [ + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + selectionInput: { + name: 'priority', + label: 'Priority', + type: 'DROPDOWN', + items: + buildSelectionItems(Priority, userStoryData.priority) + } + } + ] + }, + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + selectionInput: { + name: 'size', + label: 'Size', + type: 'DROPDOWN', + items: buildSelectionItems(Size, userStoryData.size) + } + } + ] + } + ] + } + }, + new UserStoryAssigneeWidget(userStoryData.assignee, user) + ] + }, + ]; + + // Buttons section. + const buttonListWidget = new UserStoryButtonsWidget( + userStory, + UserStoryCardType.SINGLE_DIALOG, + /* showEdit= */ false, + /* showSave= */ true); + if (buttonListWidget.buttonList) { + this.sections.push({ + widgets: [ + buttonListWidget + ] + }); + } + } + +} + +// [END chat_project_management_edit_user_story_card] diff --git a/node/project-management-app/views/help-card.js b/node/project-management-app/views/help-card.js new file mode 100644 index 00000000..fcff583c --- /dev/null +++ b/node/project-management-app/views/help-card.js @@ -0,0 +1,119 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_help_card] + +/** + * A Card with a description of the available commands. + */ +exports.HelpCard = class { + + /** + * Creates a Card with a description of the available commands. + */ + constructor() { + this.header = { + title: 'Project Manager', + subtitle: 'Agile Project Management app' + }; + + const commandsSection = { + header: 'Available commands', + widgets: [ + { + decoratedText: { + text: '/createUserStory title', + bottomLabel: 'Create a user story with the given title.' + } + }, + { + divider: {} + }, + { + decoratedText: { + text: '/userStory id', + bottomLabel: 'Displays the current status of a user story.' + } + }, + { + divider: {} + }, + { + decoratedText: { + text: '/myUserStories', + bottomLabel: 'Lists all the user stories assigned to the user.', + button: { + text: 'Try it', + onClick: { + action: { + function: 'myUserStories' + } + } + } + } + }, + { + divider: {} + }, + { + decoratedText: { + text: '/manageUserStories', + bottomLabel: 'Opens a dialog for user story management.', + button: { + text: 'Try it', + onClick: { + action: { + function: 'manageUserStories', + interaction: 'OPEN_DIALOG' + } + } + } + } + }, + { + divider: {} + }, + { + decoratedText: { + text: '/cleanupUserStories', + bottomLabel: 'Deletes all user stories in the space.', + button: { + text: 'Try it', + onClick: { + action: { + function: 'cleanupUserStories' + } + } + } + } + }, + { + divider: {} + }, + { + decoratedText: { + text: '@Project Manager userstories', + bottomLabel: 'Lists all the user stories assigned to the user.' + } + }, + ] + }; + + this.sections = [commandsSection]; + } + +} + +// [END chat_project_management_help_card] diff --git a/node/project-management-app/views/user-story-card.js b/node/project-management-app/views/user-story-card.js new file mode 100644 index 00000000..c1b3d7d4 --- /dev/null +++ b/node/project-management-app/views/user-story-card.js @@ -0,0 +1,95 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_user_story_card] + +const { UserStory, StatusIcon } = require('../user-story'); +const { User } = require('../user'); +const { UserStoryAssigneeWidget } = require('./widgets/user-story-assignee-widget'); +const { UserStoryButtonsWidget } = require('./widgets/user-story-buttons-widget'); +const { UserStoryColumnsWidget } = require('./widgets/user-story-columns-widget'); +const { UserStoryCardType } = require('./widgets/user-story-card-type'); + +/** + * A Card with a standard view of a user story. + */ +exports.UserStoryCard = class { + + /** + * Creates a Card with a standard view of a user story. + * @param {!UserStory} userStory A user story. + * @param {?User} user The user assigned to the story. + */ + constructor(userStory, user) { + if (userStory === null || userStory === undefined) { + return; + } + const userStoryData = userStory.data; + + this.header = { + title: userStoryData.title, + subtitle: 'ID: ' + userStory.id, + imageUrl: StatusIcon[userStoryData.status], + imageAltText: userStoryData.status, + imageType: 'CIRCLE' + }; + + // Description section. + this.sections = []; + if (userStoryData.description !== undefined + && userStoryData.description.length > 0) { + this.sections.push({ + widgets: [ + { + textParagraph: { + text: userStoryData.description + } + } + ] + }); + } + + // Status / information section. + this.sections.push({ + widgets: [ + new UserStoryColumnsWidget({ + label: 'Priority', + text: userStoryData.priority || '-' + }, { + label: 'Size', + text: userStoryData.size || '-' + }), + new UserStoryAssigneeWidget(userStoryData.assignee, user) + ] + }); + + // Buttons section. + const buttonListWidget = new UserStoryButtonsWidget( + userStory, + UserStoryCardType.SINGLE_MESSAGE, + /* showEdit= */ true, + /* showSave= */ false); + if (buttonListWidget.buttonList) { + this.sections.push({ + widgets: [ + buttonListWidget + ] + }); + } + } + +} + +// [END chat_project_management_user_story_card] diff --git a/node/project-management-app/views/user-story-list-card.js b/node/project-management-app/views/user-story-list-card.js new file mode 100644 index 00000000..5b9284d9 --- /dev/null +++ b/node/project-management-app/views/user-story-list-card.js @@ -0,0 +1,113 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_user_story_list_card] + +const { UserStory } = require('../user-story'); +const { User } = require('../user'); +const { UserStoryAssigneeWidget } = require('./widgets/user-story-assignee-widget'); +const { UserStoryButtonsWidget } = require('./widgets/user-story-buttons-widget'); +const { UserStoryColumnsWidget } = require('./widgets/user-story-columns-widget'); +const { UserStoryRowWidget } = require('./widgets/user-story-row-widget'); +const { UserStoryCardType } = require('./widgets/user-story-card-type'); + +/** + * A Card with a list of user stories. + */ +exports.UserStoryListCard = class { + + /** + * Creates a Card with a view of a list of user stories. + * @param {!string} title The title of the card. + * @param {!UserStory[]} userStories An array of user stories. + * @param {!Object} users A map with user data by user ID. + * @param {!boolean} isDialog Whether this card is being added to a dialog. + */ + constructor(title, userStories, users, isDialog) { + if (userStories === null || userStories === undefined) { + return; + } + + if (!isDialog) { + this.header = { + title: title + }; + } + + if (userStories.length === 0) { + this.sections = [{ + widgets: [ + { + textParagraph: { + text: 'You don\'t have any user story yet.' + } + } + ] + }]; + return; + } + + this.sections = []; + for (const userStory of userStories) { + const userStoryData = userStory.data; + const user = userStoryData.assignee + ? users[userStoryData.assignee] : null; + let userStorySection = { + collapsible: true, + uncollapsibleWidgetsCount: 1, + widgets: [ + new UserStoryRowWidget(userStory) + ] + }; + if (userStoryData.description !== undefined + && userStoryData.description.length > 0) { + userStorySection.widgets.push( + { + divider: {} + }, + { + textParagraph: { + text: userStoryData.description + } + } + ); + } + userStorySection.widgets.push( + { + divider: {} + }, + new UserStoryColumnsWidget({ + label: 'Priority', + text: userStoryData.priority || '-' + }, { + label: 'Size', + text: userStoryData.size || '-' + }), + new UserStoryAssigneeWidget(userStoryData.assignee, user) + ); + const cardType = isDialog + ? UserStoryCardType.LIST_DIALOG : UserStoryCardType.LIST_MESSAGE; + const buttonListWidget = new UserStoryButtonsWidget( + userStory, cardType, /* showEdit= */ false, /* showSave= */ false); + if (buttonListWidget.buttonList) { + userStorySection.widgets.push({ divider: {} }, buttonListWidget); + } + this.sections.push(userStorySection); + } + } + +} + +// [END chat_project_management_user_story_list_card] diff --git a/node/project-management-app/views/widgets/user-story-assignee-widget.js b/node/project-management-app/views/widgets/user-story-assignee-widget.js new file mode 100644 index 00000000..25488635 --- /dev/null +++ b/node/project-management-app/views/widgets/user-story-assignee-widget.js @@ -0,0 +1,52 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_user_story_assginee_widget] + +const { User } = require('../../user'); + +/** + * A widget that presents information about the assignee of a user story. + */ +exports.UserStoryAssigneeWidget = class { + + /** + * Creates a widget that presents information about the assignee of a user + * story. + * @param {?string} assignee The ID of the user assigned to the story. + * @param {?User} user The user assigned to the story. + */ + constructor(assignee, user) { + let userDisplayName = assignee ? 'Unknown user' : '-'; + let userAvatar = undefined; + if (user) { + userDisplayName = user.displayName; + if (user.avatarUrl) { + userAvatar = { + iconUrl: user.avatarUrl, + imageType: 'CIRCLE' + }; + } + } + this.decoratedText = { + topLabel: 'Assigned to', + text: userDisplayName, + startIcon: userAvatar + } + } + +} + +// [END chat_project_management_user_story_assginee_widget] diff --git a/node/project-management-app/views/widgets/user-story-buttons-widget.js b/node/project-management-app/views/widgets/user-story-buttons-widget.js new file mode 100644 index 00000000..47544832 --- /dev/null +++ b/node/project-management-app/views/widgets/user-story-buttons-widget.js @@ -0,0 +1,166 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_user_story_buttons_widget] + +const { UserStoryCardType } = require('./user-story-card-type'); +const { UserStory, Status } = require('../../user-story'); + +/** + * A ButtonList widget with the action buttons for a user story. + */ +exports.UserStoryButtonsWidget = class { + + /** + * Creates a ButtonList widget with the action buttons for a user story, or an + * empty object if there are no actions available. + * @param {!UserStory} userStory A user story. + * @param {!UserStoryCardType} cardType Type of UI where the card will appear. + * @param {?boolean} showEdit Whether to include an Edit button. + * @param {?boolean} showSave Whether to include a Save button. + */ + constructor(userStory, cardType, showEdit, showSave) { + if (userStory === null || userStory === undefined) { + return; + } + const userStoryData = userStory.data; + const parameters = [ + { + key: 'id', + value: userStory.id + }, + { + key: 'cardType', + value: cardType + } + ]; + const interaction = + cardType === UserStoryCardType.SINGLE_DIALOG + || cardType === UserStoryCardType.LIST_DIALOG + ? 'OPEN_DIALOG' : undefined; + let buttons = []; + if (showSave) { + buttons.push({ + text: 'Save', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/content/save/materialiconsoutlined/24dp/1x/outline_save_black_24dp.png', + altText: 'Save', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'saveUserStory', + // Save action always updates a dialog. + interaction: 'OPEN_DIALOG', + parameters: parameters + } + } + }); + } + if (userStoryData.status !== Status.COMPLETED) { + buttons.push({ + text: 'Assign to me', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/social/person/materialicons/24dp/1x/baseline_person_black_24dp.png', + altText: 'Assign to me', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'assignUserStory', + interaction: interaction, + parameters: parameters + } + } + }); + } + if (userStoryData.status === Status.OPEN) { + buttons.push({ + text: 'Start', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/av/play_arrow/materialicons/24dp/1x/baseline_play_arrow_black_24dp.png', + altText: 'Start', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'startUserStory', + interaction: interaction, + parameters: parameters + } + } + }); + } + if (userStoryData.status === Status.STARTED) { + buttons.push({ + text: 'Complete', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/done/materialicons/24dp/1x/baseline_done_black_24dp.png', + altText: 'Complete', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'completeUserStory', + interaction: interaction, + parameters: parameters + } + } + }); + } + if (showSave) { + buttons.push({ + text: 'Cancel', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/content/clear/materialicons/24dp/1x/baseline_clear_black_24dp.png', + altText: 'Cancel', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'cancelEditUserStory', + // Cancel action always updates a dialog. + interaction: 'OPEN_DIALOG', + parameters: parameters + } + } + }); + } + if (showEdit) { + buttons.push({ + text: 'Edit', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/editor/edit_note/materialiconsoutlined/24dp/1x/outline_edit_note_black_24dp.png', + altText: 'Edit', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'editUserStory', + // Edit action always opens a dialog. + interaction: 'OPEN_DIALOG', + parameters: parameters + } + } + }); + } + if (buttons.length > 0) { + this.buttonList = { buttons }; + } + } + +} + +// [END chat_project_management_user_story_buttons_widget] diff --git a/node/project-management-app/views/widgets/user-story-card-type.js b/node/project-management-app/views/widgets/user-story-card-type.js new file mode 100644 index 00000000..c6333b22 --- /dev/null +++ b/node/project-management-app/views/widgets/user-story-card-type.js @@ -0,0 +1,33 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_user_story_card_type] + +/** + * Types of user interface where user story cards can appear. + * @enum {string} + */ +exports.UserStoryCardType = { + /** Message with a single user story card. */ + SINGLE_MESSAGE: 'single_message', + /** Message with a list of the user's stories. */ + LIST_MESSAGE: 'list_message', + /** Dialog with a single user story card. */ + SINGLE_DIALOG: 'single_dialog', + /** Dialog with a list of user stories. */ + LIST_DIALOG: 'list_dialog', +}; + +// [END chat_project_management_user_story_card_type] diff --git a/node/project-management-app/views/widgets/user-story-columns-widget.js b/node/project-management-app/views/widgets/user-story-columns-widget.js new file mode 100644 index 00000000..1515c837 --- /dev/null +++ b/node/project-management-app/views/widgets/user-story-columns-widget.js @@ -0,0 +1,78 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_user_story_columns_widget] + +/** + * Returns an Icon widget with the provided URL, or null if no URL + * is provided. + * @param {?string} iconUrl The icon URL. + * @return {Object | null} An Icon widget or null. + */ +function getIconWidget(iconUrl) { + return iconUrl ? { + iconUrl: iconUrl, + imageType: 'CIRCLE' + } : null; +} + +/** + * A 2-column widget that presents information about a user story. + */ +exports.UserStoryColumnsWidget = class { + + /** + * Creates a 2-column widget that presents information about a user story. + * @param {!{label: !string, text: !string, icon: ?string}} firstColumnData + * @param {!{label: !string, text: !string, icon: ?string}} secondColumnData + */ + constructor(firstColumnData, secondColumnData) { + this.columns = { + columnItems: [ + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + decoratedText: { + topLabel: firstColumnData.label, + text: firstColumnData.text, + startIcon: getIconWidget(firstColumnData.icon) + } + } + ] + }, + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + decoratedText: { + topLabel: secondColumnData.label, + text: secondColumnData.text, + startIcon: getIconWidget(secondColumnData.icon) + } + } + ] + } + ] + } + } + +} + +// [END chat_project_management_user_story_columns_widget] diff --git a/node/project-management-app/views/widgets/user-story-row-widget.js b/node/project-management-app/views/widgets/user-story-row-widget.js new file mode 100644 index 00000000..be4c015c --- /dev/null +++ b/node/project-management-app/views/widgets/user-story-row-widget.js @@ -0,0 +1,67 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START chat_project_management_user_story_row_widget] + +const { UserStory, StatusIcon } = require('../../user-story'); + +/** + * A widget that presents information about a user story to be displayed in a + * user story list. + */ +exports.UserStoryRowWidget = class { + + /** + * Creates a widget that presents information about a user story to be + * displayed in a user story list. + * @param {!UserStory} userStory A user story. + */ + constructor(userStory) { + if (userStory === null || userStory === undefined) { + return; + } + const userStoryData = userStory.data; + this.decoratedText = { + text: userStoryData.title, + bottomLabel: 'ID: ' + userStory.id, + startIcon: { + iconUrl: StatusIcon[userStoryData.status], + altText: userStoryData.status, + imageType: 'CIRCLE' + }, + button: { + text: 'Edit', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/editor/edit_note/materialiconsoutlined/24dp/1x/outline_edit_note_black_24dp.png', + altText: 'Edit', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'editUserStory', + interaction: 'OPEN_DIALOG', + parameters: [{ + key: 'id', + value: userStory.id + }] + } + } + } + } + } + +} + +// [END chat_project_management_user_story_row_widget] From 7167f5cf6664ca6d61d8c0d06a98ae68f760cea9 Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Fri, 3 Nov 2023 19:16:49 +0000 Subject: [PATCH 02/12] feat: Arrange folder structure of Proj Man sample --- .../{ => controllers}/app-action-handler.js | 16 ++++++++-------- .../{ => controllers}/app.js | 16 ++++++++-------- node/project-management-app/env.js | 2 +- node/project-management-app/index.js | 2 +- .../{ => model}/exceptions.js | 0 .../{ => model}/user-story.js | 0 node/project-management-app/{ => model}/user.js | 0 .../{ => services}/aip-service.js | 2 +- .../{ => services}/firestore-service.js | 6 +++--- .../{ => services}/space-service.js | 0 .../{ => services}/user-service.js | 4 ++-- .../{ => services}/user-story-service.js | 4 ++-- .../views/edit-user-story-card.js | 4 ++-- .../views/user-story-card.js | 4 ++-- .../views/user-story-list-card.js | 4 ++-- .../views/widgets/user-story-assignee-widget.js | 2 +- .../views/widgets/user-story-buttons-widget.js | 2 +- .../views/widgets/user-story-row-widget.js | 2 +- 18 files changed, 35 insertions(+), 35 deletions(-) rename node/project-management-app/{ => controllers}/app-action-handler.js (96%) rename node/project-management-app/{ => controllers}/app.js (94%) rename node/project-management-app/{ => model}/exceptions.js (100%) rename node/project-management-app/{ => model}/user-story.js (100%) rename node/project-management-app/{ => model}/user.js (100%) rename node/project-management-app/{ => services}/aip-service.js (98%) rename node/project-management-app/{ => services}/firestore-service.js (98%) rename node/project-management-app/{ => services}/space-service.js (100%) rename node/project-management-app/{ => services}/user-service.js (94%) rename node/project-management-app/{ => services}/user-story-service.js (97%) diff --git a/node/project-management-app/app-action-handler.js b/node/project-management-app/controllers/app-action-handler.js similarity index 96% rename from node/project-management-app/app-action-handler.js rename to node/project-management-app/controllers/app-action-handler.js index 10ce6cd2..868e8c39 100644 --- a/node/project-management-app/app-action-handler.js +++ b/node/project-management-app/controllers/app-action-handler.js @@ -15,14 +15,14 @@ */ // [START chat_project_management_app_action_handler] -const { AIPService } = require('./aip-service'); -const { UserService } = require('./user-service'); -const { UserStoryService } = require('./user-story-service'); -const { UserStory } = require('./user-story'); -const { User } = require('./user'); -const { EditUserStoryCard } = require('./views/edit-user-story-card'); -const { UserStoryCard } = require('./views/user-story-card'); -const { UserStoryCardType } = require('./views/widgets/user-story-card-type'); +const { UserStory } = require('../model/user-story'); +const { User } = require('../model/user'); +const { AIPService } = require('../services/aip-service'); +const { UserService } = require('../services/user-service'); +const { UserStoryService } = require('../services/user-story-service'); +const { EditUserStoryCard } = require('../views/edit-user-story-card'); +const { UserStoryCard } = require('../views/user-story-card'); +const { UserStoryCardType } = require('../views/widgets/user-story-card-type'); const USERS_PREFIX = 'users/'; diff --git a/node/project-management-app/app.js b/node/project-management-app/controllers/app.js similarity index 94% rename from node/project-management-app/app.js rename to node/project-management-app/controllers/app.js index fa2d3d54..d017967d 100644 --- a/node/project-management-app/app.js +++ b/node/project-management-app/controllers/app.js @@ -15,15 +15,15 @@ */ // [START chat_project_management_app] +const { Status } = require('../model/user-story'); +const { AIPService } = require('../services/aip-service'); +const { SpaceService } = require('../services/space-service'); +const { UserService } = require('../services/user-service'); +const { UserStoryService } = require('../services/user-story-service'); +const { HelpCard } = require('../views/help-card'); +const { UserStoryCard } = require('../views/user-story-card'); +const { UserStoryListCard } = require('../views/user-story-list-card'); const AppActionHandler = require('./app-action-handler'); -const { AIPService } = require('./aip-service'); -const { SpaceService } = require('./space-service'); -const { UserService } = require('./user-service'); -const { UserStoryService } = require('./user-story-service'); -const { Status } = require('./user-story'); -const { HelpCard } = require('./views/help-card'); -const { UserStoryCard } = require('./views/user-story-card'); -const { UserStoryListCard } = require('./views/user-story-list-card'); const USERS_PREFIX = 'users/'; diff --git a/node/project-management-app/env.js b/node/project-management-app/env.js index bec49d27..2a49837a 100644 --- a/node/project-management-app/env.js +++ b/node/project-management-app/env.js @@ -20,7 +20,7 @@ */ const env = { project: 'developer-productivity-402319', //'YOUR_PROJECT_ID', - location: 'us-central1', //'YOUR_PROJECT_LOCATION', + location: 'us-central1', // replace with your GCP project location }; exports.env = env; diff --git a/node/project-management-app/index.js b/node/project-management-app/index.js index dc53db37..6b2dcb36 100644 --- a/node/project-management-app/index.js +++ b/node/project-management-app/index.js @@ -16,7 +16,7 @@ // [START chat_project_management_index] const functions = require('@google-cloud/functions-framework'); -const App = require('./app'); +const App = require('./controllers/app'); functions.http('projectManagementChatApp', async (req, res) => { if (req.method === 'GET' || !req.body.message) { diff --git a/node/project-management-app/exceptions.js b/node/project-management-app/model/exceptions.js similarity index 100% rename from node/project-management-app/exceptions.js rename to node/project-management-app/model/exceptions.js diff --git a/node/project-management-app/user-story.js b/node/project-management-app/model/user-story.js similarity index 100% rename from node/project-management-app/user-story.js rename to node/project-management-app/model/user-story.js diff --git a/node/project-management-app/user.js b/node/project-management-app/model/user.js similarity index 100% rename from node/project-management-app/user.js rename to node/project-management-app/model/user.js diff --git a/node/project-management-app/aip-service.js b/node/project-management-app/services/aip-service.js similarity index 98% rename from node/project-management-app/aip-service.js rename to node/project-management-app/services/aip-service.js index e570069d..1885c80b 100644 --- a/node/project-management-app/aip-service.js +++ b/node/project-management-app/services/aip-service.js @@ -16,7 +16,7 @@ // [START chat_project_management_aip_service] const aiplatform = require('@google-cloud/aiplatform'); -const { env } = require('./env.js'); +const { env } = require('../env.js'); // Imports the Google Cloud Prediction service client. const { PredictionServiceClient } = aiplatform.v1; diff --git a/node/project-management-app/firestore-service.js b/node/project-management-app/services/firestore-service.js similarity index 98% rename from node/project-management-app/firestore-service.js rename to node/project-management-app/services/firestore-service.js index 5addb923..527314f9 100644 --- a/node/project-management-app/firestore-service.js +++ b/node/project-management-app/services/firestore-service.js @@ -16,9 +16,9 @@ // [START chat_project_management_firestore] const { Firestore, FieldPath } = require('@google-cloud/firestore'); -const { NotFoundException } = require('./exceptions'); -const { UserStory } = require('./user-story'); -const { User } = require('./user'); +const { NotFoundException } = require('../model/exceptions'); +const { UserStory } = require('../model/user-story'); +const { User } = require('../model/user'); const SPACES_PREFIX = 'spaces/'; const SPACES_COLLECTION = 'spaces'; diff --git a/node/project-management-app/space-service.js b/node/project-management-app/services/space-service.js similarity index 100% rename from node/project-management-app/space-service.js rename to node/project-management-app/services/space-service.js diff --git a/node/project-management-app/user-service.js b/node/project-management-app/services/user-service.js similarity index 94% rename from node/project-management-app/user-service.js rename to node/project-management-app/services/user-service.js index 9268e3c7..611451e8 100644 --- a/node/project-management-app/user-service.js +++ b/node/project-management-app/services/user-service.js @@ -15,9 +15,9 @@ */ // [START chat_project_management_user_service] -const { NotFoundException } = require('./exceptions'); +const { NotFoundException } = require('../model/exceptions'); +const { User } = require('../model/user'); const { FirestoreService } = require('./firestore-service'); -const { User } = require('./user'); /** * Service that executes all the User application logic. diff --git a/node/project-management-app/user-story-service.js b/node/project-management-app/services/user-story-service.js similarity index 97% rename from node/project-management-app/user-story-service.js rename to node/project-management-app/services/user-story-service.js index 78a00044..9ef5e0b7 100644 --- a/node/project-management-app/user-story-service.js +++ b/node/project-management-app/services/user-story-service.js @@ -15,9 +15,9 @@ */ // [START chat_project_management_user_story_service] -const { BadRequestException, NotFoundException } = require('./exceptions'); +const { BadRequestException, NotFoundException } = require('../model/exceptions'); +const { UserStory, Status, Priority, Size } = require('../model/user-story'); const { FirestoreService } = require('./firestore-service'); -const { UserStory, Status, Priority, Size } = require('./user-story'); const USERS_PREFIX = 'users/'; diff --git a/node/project-management-app/views/edit-user-story-card.js b/node/project-management-app/views/edit-user-story-card.js index 75bbd195..0cea1bef 100644 --- a/node/project-management-app/views/edit-user-story-card.js +++ b/node/project-management-app/views/edit-user-story-card.js @@ -15,8 +15,8 @@ */ // [START chat_project_management_edit_user_story_card] -const { UserStory, Status, Priority, Size } = require('../user-story'); -const { User } = require('../user'); +const { UserStory, Status, Priority, Size } = require('../model/user-story'); +const { User } = require('../model/user'); const { UserStoryAssigneeWidget } = require('./widgets/user-story-assignee-widget'); const { UserStoryButtonsWidget } = require('./widgets/user-story-buttons-widget'); const { UserStoryCardType } = require('./widgets/user-story-card-type'); diff --git a/node/project-management-app/views/user-story-card.js b/node/project-management-app/views/user-story-card.js index c1b3d7d4..74351aed 100644 --- a/node/project-management-app/views/user-story-card.js +++ b/node/project-management-app/views/user-story-card.js @@ -15,8 +15,8 @@ */ // [START chat_project_management_user_story_card] -const { UserStory, StatusIcon } = require('../user-story'); -const { User } = require('../user'); +const { UserStory, StatusIcon } = require('../model/user-story'); +const { User } = require('../model/user'); const { UserStoryAssigneeWidget } = require('./widgets/user-story-assignee-widget'); const { UserStoryButtonsWidget } = require('./widgets/user-story-buttons-widget'); const { UserStoryColumnsWidget } = require('./widgets/user-story-columns-widget'); diff --git a/node/project-management-app/views/user-story-list-card.js b/node/project-management-app/views/user-story-list-card.js index 5b9284d9..c9c2fb2b 100644 --- a/node/project-management-app/views/user-story-list-card.js +++ b/node/project-management-app/views/user-story-list-card.js @@ -15,8 +15,8 @@ */ // [START chat_project_management_user_story_list_card] -const { UserStory } = require('../user-story'); -const { User } = require('../user'); +const { UserStory } = require('../model/user-story'); +const { User } = require('../model/user'); const { UserStoryAssigneeWidget } = require('./widgets/user-story-assignee-widget'); const { UserStoryButtonsWidget } = require('./widgets/user-story-buttons-widget'); const { UserStoryColumnsWidget } = require('./widgets/user-story-columns-widget'); diff --git a/node/project-management-app/views/widgets/user-story-assignee-widget.js b/node/project-management-app/views/widgets/user-story-assignee-widget.js index 25488635..f3a36632 100644 --- a/node/project-management-app/views/widgets/user-story-assignee-widget.js +++ b/node/project-management-app/views/widgets/user-story-assignee-widget.js @@ -15,7 +15,7 @@ */ // [START chat_project_management_user_story_assginee_widget] -const { User } = require('../../user'); +const { User } = require('../../model/user'); /** * A widget that presents information about the assignee of a user story. diff --git a/node/project-management-app/views/widgets/user-story-buttons-widget.js b/node/project-management-app/views/widgets/user-story-buttons-widget.js index 47544832..7c81e3bf 100644 --- a/node/project-management-app/views/widgets/user-story-buttons-widget.js +++ b/node/project-management-app/views/widgets/user-story-buttons-widget.js @@ -15,8 +15,8 @@ */ // [START chat_project_management_user_story_buttons_widget] +const { UserStory, Status } = require('../../model/user-story'); const { UserStoryCardType } = require('./user-story-card-type'); -const { UserStory, Status } = require('../../user-story'); /** * A ButtonList widget with the action buttons for a user story. diff --git a/node/project-management-app/views/widgets/user-story-row-widget.js b/node/project-management-app/views/widgets/user-story-row-widget.js index be4c015c..9d1218d3 100644 --- a/node/project-management-app/views/widgets/user-story-row-widget.js +++ b/node/project-management-app/views/widgets/user-story-row-widget.js @@ -15,7 +15,7 @@ */ // [START chat_project_management_user_story_row_widget] -const { UserStory, StatusIcon } = require('../../user-story'); +const { UserStory, StatusIcon } = require('../../model/user-story'); /** * A widget that presents information about a user story to be displayed in a From 38c2d805aa17679ce94a95101f627a167d5b1d22 Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Fri, 1 Dec 2023 19:58:58 +0000 Subject: [PATCH 03/12] Improve handling of story card update after saving --- .../controllers/app-action-handler.js | 54 ++++++++++++++----- .../views/edit-user-story-card.js | 27 +++++++--- .../widgets/user-story-buttons-widget.js | 16 ++++++ 3 files changed, 78 insertions(+), 19 deletions(-) diff --git a/node/project-management-app/controllers/app-action-handler.js b/node/project-management-app/controllers/app-action-handler.js index 868e8c39..75f64c57 100644 --- a/node/project-management-app/controllers/app-action-handler.js +++ b/node/project-management-app/controllers/app-action-handler.js @@ -118,6 +118,8 @@ class ChatAppActionHandler { return this.handleCancelEditUserStory(); case 'saveUserStory': return this.handleSaveUserStory(); + case 'refreshUserStory': + return this.handleRefreshUserStory(); case 'generateUserStoryDescription': return this.handleUserStoryAIAction(AIAction.GENERATE); case 'expandUserStoryDescription': @@ -187,8 +189,7 @@ class ChatAppActionHandler { const userStory = await UserStoryService.assignUserStory( this.spaceName, this.userStoryId, this.userName); - return this.buildResponse( - userStory, user, `User story assigned to <${this.userName}>.`); + return this.buildResponse(userStory, user, /* updated= */ true); } catch (e) { return handleException(e); } @@ -205,8 +206,7 @@ class ChatAppActionHandler { const user = userStory.data.assignee ? await UserService.getUser(this.spaceName, userStory.data.assignee) : undefined; - return this.buildResponse( - userStory, user, `<${this.userName}> started the user story.`); + return this.buildResponse(userStory, user, /* updated= */ true); } catch (e) { return handleException(e); } @@ -224,8 +224,7 @@ class ChatAppActionHandler { const user = userStory.data.assignee ? await UserService.getUser(this.spaceName, userStory.data.assignee) : undefined; - return this.buildResponse( - userStory, user, `<${this.userName}> completed the user story.`); + return this.buildResponse(userStory, user, /* updated= */ true); } catch (e) { return handleException(e); } @@ -266,8 +265,33 @@ class ChatAppActionHandler { const user = userStory.data.assignee ? await UserService.getUser(this.spaceName, userStory.data.assignee) : undefined; - return this.buildResponse( - userStory, user, `<${this.userName}> edited the user story.`); + return this.buildResponse(userStory, user, /* updated= */ true); + } catch (e) { + return handleException(e); + } + } + + /** + * Handles the refresh user story command. + * @return {Promise} A message to post back to the DM or space. + */ + async handleRefreshUserStory() { + try { + const userStory = + await UserStoryService.getUserStory(this.spaceName, this.userStoryId); + const user = userStory.data.assignee + ? await UserService.getUser( + this.spaceName, userStory.data.assignee.replace(USERS_PREFIX, '')) + : undefined; + return { + cardsV2: [{ + cardId: 'userStoryCard', + card: new UserStoryCard(userStory, user) + }], + actionResponse: { + type: 'UPDATE_MESSAGE' + } + }; } catch (e) { return handleException(e); } @@ -326,7 +350,7 @@ class ChatAppActionHandler { const user = assignee ? await UserService.getUser(this.spaceName, assignee) : undefined; - return this.buildResponse(userStory, user, ''); + return this.buildResponse(userStory, user, /* updated= */ false); } catch (e) { return handleException(e); } @@ -346,14 +370,14 @@ class ChatAppActionHandler { * * @param {!UserStory} userStory The updated user story. * @param {?User} user The user assigned to the user story. - * @param {!string} text Text message describing the executed action. + * @param {!boolean} updated Whether the user story was updated in storage. * @return {Promise} A message to post back to the DM or space. */ - async buildResponse(userStory, user, text) { + async buildResponse(userStory, user, updated) { switch (this.cardType) { case UserStoryCardType.SINGLE_MESSAGE: return { - text: text, + text: saved ? 'User story updated.' : null, cardsV2: [{ cardId: 'userStoryCard', card: new UserStoryCard(userStory, user) @@ -373,8 +397,12 @@ class ChatAppActionHandler { actionResponse: { type: 'DIALOG', dialogAction: { + actionStatus: { + statusCode: 'OK', + userFacingMessage: 'Saved.' + }, dialog: { - body: new EditUserStoryCard(userStory, user) + body: new EditUserStoryCard(userStory, user, updated) } } } diff --git a/node/project-management-app/views/edit-user-story-card.js b/node/project-management-app/views/edit-user-story-card.js index 0cea1bef..a8b50f0c 100644 --- a/node/project-management-app/views/edit-user-story-card.js +++ b/node/project-management-app/views/edit-user-story-card.js @@ -44,8 +44,9 @@ exports.EditUserStoryCard = class { * Creates a Card with a standard view of a user story. * @param {!UserStory} userStory A user story. * @param {?User} user The user assigned to the story. + * @param {?boolean} updated Whether the user story was updated in storage. */ - constructor(userStory, user) { + constructor(userStory, user, updated) { if (userStory === null || userStory === undefined) { return; } @@ -70,7 +71,23 @@ exports.EditUserStoryCard = class { subtitle: 'ID: ' + userStory.id }; - this.sections = [ + this.sections = []; + if (updated) { + this.sections.push({ + widgets: [ + { + decoratedText: { + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/info_outline/materialicons/48dp/1x/baseline_info_outline_black_48dp.png' + }, + text: 'Saved.' + } + } + ] + }); + }; + + this.sections.push( { widgets: [ { @@ -90,8 +107,7 @@ exports.EditUserStoryCard = class { } }, { - buttonList: - { + buttonList: { buttons: [ { text: 'Regenerate', @@ -188,8 +204,7 @@ exports.EditUserStoryCard = class { }, new UserStoryAssigneeWidget(userStoryData.assignee, user) ] - }, - ]; + }); // Buttons section. const buttonListWidget = new UserStoryButtonsWidget( diff --git a/node/project-management-app/views/widgets/user-story-buttons-widget.js b/node/project-management-app/views/widgets/user-story-buttons-widget.js index 7c81e3bf..22db5c6d 100644 --- a/node/project-management-app/views/widgets/user-story-buttons-widget.js +++ b/node/project-management-app/views/widgets/user-story-buttons-widget.js @@ -156,6 +156,22 @@ exports.UserStoryButtonsWidget = class { } }); } + if (cardType === UserStoryCardType.SINGLE_MESSAGE) { + buttons.push({ + text: 'Refresh', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/navigation/refresh/materialicons/24dp/1x/baseline_refresh_black_24dp.png', + altText: 'Refresh', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'refreshUserStory', + parameters: parameters + } + } + }); + } if (buttons.length > 0) { this.buttonList = { buttons }; } From 1ea4af0ec42269b40187559f8bf1fb0160f3b578 Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Mon, 4 Dec 2023 17:25:07 +0000 Subject: [PATCH 04/12] test: Add unit tests for FirestoreService. --- node/project-management-app/package-lock.json | 968 ++++++++++++++++++ node/project-management-app/package.json | 7 +- .../services/firestore-service.js | 2 +- .../test/services/firestore-service.test.js | 420 ++++++++ 4 files changed, 1395 insertions(+), 2 deletions(-) create mode 100644 node/project-management-app/test/services/firestore-service.test.js diff --git a/node/project-management-app/package-lock.json b/node/project-management-app/package-lock.json index 1ea732e6..1cf17426 100644 --- a/node/project-management-app/package-lock.json +++ b/node/project-management-app/package-lock.json @@ -12,6 +12,11 @@ "@google-cloud/aiplatform": "^3.4.0", "@google-cloud/firestore": "^7.1.0", "@google-cloud/functions-framework": "^3.0.0" + }, + "devDependencies": { + "mocha": "^10.2.0", + "proxyquire": "^2.1.3", + "sinon": "^17.0.1" } }, "node_modules/@babel/code-frame": { @@ -178,6 +183,50 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, + "node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -389,6 +438,15 @@ } } }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -408,6 +466,25 @@ "node": ">=4" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -429,6 +506,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -456,6 +539,15 @@ "node": "*" } }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -479,6 +571,33 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -504,6 +623,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -517,6 +648,33 @@ "node": ">=4" } }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -570,6 +728,12 @@ "node": ">= 0.8" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -610,6 +774,18 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -635,6 +811,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/duplexify": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", @@ -813,6 +998,31 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fill-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", + "integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==", + "dev": true, + "dependencies": { + "is-object": "~1.0.1", + "merge-descriptors": "~1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", @@ -842,6 +1052,15 @@ "node": ">=8" } }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -879,6 +1098,26 @@ "node": ">= 0.6" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -940,6 +1179,60 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/google-auth-library": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.1.0.tgz", @@ -1053,6 +1346,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -1162,6 +1464,16 @@ "node": ">=0.10.0" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1195,6 +1507,18 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -1217,6 +1541,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1239,6 +1572,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -1264,11 +1636,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -1287,6 +1689,12 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "node_modules/jwa": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", @@ -1327,6 +1735,98 @@ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", @@ -1394,6 +1894,18 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1402,11 +1914,233 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mocha": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", + "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==", + "dev": true, + "dependencies": { + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.4", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "5.0.1", + "ms": "2.1.3", + "nanoid": "3.3.3", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "workerpool": "6.2.1", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/mocha/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/mocha/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mocha/node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/mocha/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mocha/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mocha/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==", + "dev": true + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1415,6 +2149,55 @@ "node": ">= 0.6" } }, + "node_modules/nise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", + "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers/node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "dependencies": { + "isarray": "0.0.1" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -1453,6 +2236,15 @@ "semver": "bin/semver" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -1554,6 +2346,15 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1564,6 +2365,18 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -1634,6 +2447,17 @@ "node": ">= 0.10" } }, + "node_modules/proxyquire": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", + "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", + "dev": true, + "dependencies": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.1", + "resolve": "^1.11.1" + } + }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", @@ -1656,6 +2480,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1729,6 +2562,18 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1862,6 +2707,15 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/serve-static": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", @@ -1894,6 +2748,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -1975,6 +2877,18 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", @@ -2073,6 +2987,18 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2086,6 +3012,15 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", @@ -2209,6 +3144,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/workerpool": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", + "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "dev": true + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -2297,6 +3238,33 @@ "engines": { "node": ">=12" } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/node/project-management-app/package.json b/node/project-management-app/package.json index 74af24cf..8c9cdb40 100644 --- a/node/project-management-app/package.json +++ b/node/project-management-app/package.json @@ -4,7 +4,7 @@ "description": "A sample project management app for Google Chat", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "mocha -p -j 2 -t 5s test/**/*.test.js" }, "repository": { "type": "git", @@ -20,5 +20,10 @@ "@google-cloud/aiplatform": "^3.4.0", "@google-cloud/firestore": "^7.1.0", "@google-cloud/functions-framework": "^3.0.0" + }, + "devDependencies": { + "mocha": "^10.2.0", + "proxyquire": "^2.1.3", + "sinon": "^17.0.1" } } diff --git a/node/project-management-app/services/firestore-service.js b/node/project-management-app/services/firestore-service.js index 527314f9..3371f224 100644 --- a/node/project-management-app/services/firestore-service.js +++ b/node/project-management-app/services/firestore-service.js @@ -173,7 +173,7 @@ exports.FirestoreService = { * @param {!string} spaceName The resource name of the space. * @return {Promise} An array with the fetched user story data. */ - listAllUserStories: async function (spaceName,) { + listAllUserStories: async function (spaceName) { let userStories = []; const collectionRef = getUserStoriesCollection(spaceName); const snapshot = await collectionRef.get(); diff --git a/node/project-management-app/test/services/firestore-service.test.js b/node/project-management-app/test/services/firestore-service.test.js new file mode 100644 index 00000000..5e26d44d --- /dev/null +++ b/node/project-management-app/test/services/firestore-service.test.js @@ -0,0 +1,420 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { FieldPath } = require('@google-cloud/firestore'); +const assert = require('assert'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); +const { NotFoundException } = require('../../model/exceptions'); +const { UserStory } = require('../../model/user-story'); +const { User } = require('../../model/user'); + +const SPACES_COLLECTION = 'spaces'; +const USER_STORIES_COLLECTION = 'userStories'; +const USERS_COLLECTION = 'users'; +const SPACE_NAME = 'spaces/test'; +const SPACE_ID = 'test'; +const DISPLAY_NAME = 'Space'; +const USER_STORY_ID = 'abc'; +const USER_STORY_DATA = { title: 'User Story' }; +const USER_ID = '123'; +const BATCH_SIZE = 50; + +const getTestEnvironment = () => { + const queryMock = { + limit: sinon.stub().returnsThis(), + get: sinon.stub().returns({ size: 0 }), + } + const childCollectionMock = { + doc: sinon.stub().returnsThis(), + add: sinon.stub().returnsThis(), + set: sinon.stub().returnsThis(), + get: sinon.stub().returnsThis(), + update: sinon.stub().returnsThis(), + delete: sinon.stub(), + where: sinon.stub().returnsThis(), + orderBy: sinon.stub().returns(queryMock), + exists: true, + } + const spacesCollectionMock = { + collection: sinon.stub().returns(childCollectionMock), + doc: sinon.stub().returnsThis(), + set: sinon.stub(), + delete: sinon.stub(), + } + const batchMock = { + delete: sinon.stub(), + commit: sinon.stub(), + } + const firestoreMock = { + collection: sinon.stub().returns(spacesCollectionMock), + batch: sinon.stub().returns(batchMock), + }; + + return { + module: proxyquire('../../services/firestore-service', { + '@google-cloud/firestore': { + Firestore: sinon.stub().returns(firestoreMock) + } + }), + mocks: { + firestore: firestoreMock, + spaces: spacesCollectionMock, + children: childCollectionMock, + query: queryMock, + batch: batchMock, + }, + }; +}; + +describe('FirestoreService.createSpace', function () { + it('should create space', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + + await FirestoreService.createSpace(SPACE_NAME, DISPLAY_NAME); + + assert.ok( + test.mocks.firestore.collection.calledOnceWith(SPACES_COLLECTION)); + assert.ok(test.mocks.spaces.doc.calledOnceWith(SPACE_ID)); + assert.ok( + test.mocks.spaces.set.calledOnceWith({ displayName: DISPLAY_NAME })); + }); +}); + +describe('FirestoreService.deleteSpace', function () { + it('should delete space', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + + await FirestoreService.deleteSpace(SPACE_NAME); + + assert.ok( + test.mocks.firestore.collection.alwaysCalledWith(SPACES_COLLECTION)); + assert.ok(test.mocks.spaces.doc.calledWith(SPACE_ID)); + assert.ok(test.mocks.spaces.collection.calledWith(USER_STORIES_COLLECTION)); + assert.ok(test.mocks.spaces.collection.calledWith(USERS_COLLECTION)); + assert.strictEqual(test.mocks.spaces.delete.callCount, 1); + assert.strictEqual(test.mocks.query.get.callCount, 2); + }); +}); + +describe('FirestoreService.getUserStory', function () { + it('should return user story', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.children.data = () => USER_STORY_DATA; + + const userStory = + await FirestoreService.getUserStory(SPACE_NAME, USER_STORY_ID); + + assert.ok(test.mocks.children.doc.calledOnceWith(USER_STORY_ID)); + assert.strictEqual(test.mocks.children.get.callCount, 1); + assert.deepStrictEqual( + userStory, new UserStory(USER_STORY_ID, USER_STORY_DATA)); + }); + + it('should throw when user story doesn\'t exist', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.children.exists = false; + + await assert.rejects(async () => { + return FirestoreService.getUserStory(SPACE_NAME, USER_STORY_ID); + }, NotFoundException); + }); +}); + +describe('FirestoreService.createUserStory', function () { + it('should create user story', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.children.id = USER_STORY_ID; + test.mocks.children.data = () => USER_STORY_DATA; + + const userStory = + await FirestoreService.createUserStory(SPACE_NAME, USER_STORY_DATA); + + assert.ok(test.mocks.children.add.calledOnceWith(USER_STORY_DATA)); + assert.strictEqual(test.mocks.children.get.callCount, 1); + assert.deepStrictEqual( + userStory, new UserStory(USER_STORY_ID, USER_STORY_DATA)); + }); +}); + +describe('FirestoreService.updateUserStory', function () { + it('should update user story', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.children.data = () => USER_STORY_DATA; + + const userStory = + await FirestoreService.updateUserStory( + SPACE_NAME, USER_STORY_ID, USER_STORY_DATA); + + assert.ok(test.mocks.children.doc.calledWith(USER_STORY_ID)); + assert.ok(test.mocks.children.update.calledOnceWith(USER_STORY_DATA)); + assert.strictEqual(test.mocks.children.doc.callCount, 2); + assert.strictEqual(test.mocks.children.get.callCount, 1); + assert.deepStrictEqual( + userStory, new UserStory(USER_STORY_ID, USER_STORY_DATA)); + }); +}); + +describe('FirestoreService.listAllUserStories', function () { + it('should list all user stories', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.children.get = sinon.stub().returns([ + { id: '1', data: () => ({ title: 'Story 1' }) }, + { id: '2', data: () => ({ title: 'Story 2' }) }, + ]); + + const userStories = + await FirestoreService.listAllUserStories(SPACE_NAME); + + assert.ok( + test.mocks.firestore.collection.calledOnceWith(SPACES_COLLECTION)); + assert.ok(test.mocks.spaces.doc.calledOnceWith(SPACE_ID)); + assert.ok( + test.mocks.spaces.collection.calledOnceWith(USER_STORIES_COLLECTION)); + assert.strictEqual(test.mocks.children.get.callCount, 1); + assert.deepStrictEqual( + userStories, [ + new UserStory('1', { title: 'Story 1' }), + new UserStory('2', { title: 'Story 2' }), + ]); + }); +}); + +describe('FirestoreService.listUserStoriesByUser', function () { + it('should list user stories by user', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.children.get = sinon.stub().returns([ + { id: '1', data: () => ({ title: 'Story 1' }) }, + { id: '2', data: () => ({ title: 'Story 2' }) }, + ]); + + const userStories = + await FirestoreService.listUserStoriesByUser(SPACE_NAME, USER_ID); + + assert.ok( + test.mocks.firestore.collection.calledOnceWith(SPACES_COLLECTION)); + assert.ok(test.mocks.spaces.doc.calledOnceWith(SPACE_ID)); + assert.ok( + test.mocks.spaces.collection.calledOnceWith(USER_STORIES_COLLECTION)); + assert.ok( + test.mocks.children.where.calledOnceWith('assignee', '==', USER_ID)); + assert.strictEqual(test.mocks.children.get.callCount, 1); + assert.deepStrictEqual( + userStories, [ + new UserStory('1', { title: 'Story 1' }), + new UserStory('2', { title: 'Story 2' }), + ]); + }); +}); + +describe('FirestoreService.cleanupUserStories', function () { + it('should delete all user stories', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.query.get.onFirstCall().returns({ + size: 2, + docs: [ + { ref: 'abc' }, + { ref: 'def' }, + ] + }); + test.mocks.query.get.onSecondCall().returns({ size: 0 }); + + await FirestoreService.cleanupUserStories(SPACE_NAME); + + assert.ok( + test.mocks.firestore.collection.calledOnceWith(SPACES_COLLECTION)); + assert.ok(test.mocks.spaces.doc.calledWith(SPACE_ID)); + assert.ok(test.mocks.spaces.collection.calledWith(USER_STORIES_COLLECTION)); + assert.ok(test.mocks.children.orderBy.calledOnceWith('__name__')); + assert.ok(test.mocks.query.limit.calledOnceWith(BATCH_SIZE)); + assert.strictEqual(test.mocks.query.get.callCount, 2); + assert.strictEqual(test.mocks.firestore.batch.callCount, 1); + assert.strictEqual(test.mocks.batch.delete.callCount, 2); + assert.strictEqual(test.mocks.batch.commit.callCount, 1); + }); + + it('should do nothing if there are zero user stories', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.query.get.returns({ size: 0 }); + + await FirestoreService.cleanupUserStories(SPACE_NAME); + + assert.ok( + test.mocks.firestore.collection.calledOnceWith(SPACES_COLLECTION)); + assert.ok(test.mocks.spaces.doc.calledWith(SPACE_ID)); + assert.ok(test.mocks.spaces.collection.calledWith(USER_STORIES_COLLECTION)); + assert.ok(test.mocks.children.orderBy.calledOnceWith('__name__')); + assert.ok(test.mocks.query.limit.calledOnceWith(BATCH_SIZE)); + assert.strictEqual(test.mocks.query.get.callCount, 1); + assert.ok(test.mocks.firestore.batch.notCalled); + assert.ok(test.mocks.batch.delete.notCalled); + assert.ok(test.mocks.batch.commit.notCalled); + }); +}); + +describe('FirestoreService.getUser', function () { + it('should return user', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.children.data = () => ({ + displayName: 'User', + avatarUrl: 'avatar', + }); + + const user = + await FirestoreService.getUser(SPACE_NAME, USER_ID); + + assert.ok(test.mocks.children.doc.calledOnceWith(USER_ID)); + assert.strictEqual(test.mocks.children.get.callCount, 1); + assert.deepStrictEqual(user, new User(USER_ID, 'User', 'avatar')); + }); + + it('should throw when user doesn\'t exist', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.children.exists = false; + + await assert.rejects(async () => { + return FirestoreService.getUser(SPACE_NAME, USER_ID); + }, NotFoundException); + }); +}); + +describe('FirestoreService.getUsers', function () { + it('should list users', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.children.get = sinon.stub().returns([ + { id: '1', data: () => ({ displayName: 'User 1' }) }, + { id: '2', data: () => ({ displayName: 'User 2' }) }, + ]); + + const users = + await FirestoreService.getUsers(SPACE_NAME, ['1', '2']); + + assert.ok( + test.mocks.firestore.collection.calledOnceWith(SPACES_COLLECTION)); + assert.ok(test.mocks.spaces.doc.calledOnceWith(SPACE_ID)); + assert.ok( + test.mocks.spaces.collection.calledOnceWith(USERS_COLLECTION)); + assert.ok( + test.mocks.children.where.calledOnceWith( + FieldPath.documentId(), 'in', ['1', '2'])); + assert.strictEqual(test.mocks.children.get.callCount, 1); + assert.deepStrictEqual( + users, { + '1': new User('1', 'User 1'), + '2': new User('2', 'User 2'), + }); + }); + + it('should return empty array if input is empty', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + + const users = + await FirestoreService.getUsers(SPACE_NAME, []); + + assert.ok(test.mocks.firestore.collection.notCalled); + assert.ok(test.mocks.spaces.doc.notCalled); + assert.ok(test.mocks.spaces.collection.notCalled); + assert.ok(test.mocks.children.get.notCalled); + assert.deepStrictEqual(users, {}); + }); +}); + +describe('FirestoreService.createOrUpdateUser', function () { + it('should create or update user', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + + await FirestoreService.createOrUpdateUser( + SPACE_NAME, new User(USER_ID, 'User', 'avatar')); + + assert.ok(test.mocks.children.doc.calledOnceWith(USER_ID)); + assert.ok(test.mocks.children.set.calledOnceWith({ + displayName: 'User', + avatarUrl: 'avatar', + })); + }); + + it('should throw when user doesn\'t exist', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.children.exists = false; + + await assert.rejects(async () => { + return FirestoreService.getUser(SPACE_NAME, USER_ID); + }, NotFoundException); + }); +}); + +describe('FirestoreService.cleanupUsers', function () { + it('should delete all users', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.query.get.onFirstCall().returns({ + size: 2, + docs: [ + { ref: 'abc' }, + { ref: 'def' }, + ] + }); + test.mocks.query.get.onSecondCall().returns({ size: 0 }); + + await FirestoreService.cleanupUsers(SPACE_NAME); + + assert.ok( + test.mocks.firestore.collection.calledOnceWith(SPACES_COLLECTION)); + assert.ok(test.mocks.spaces.doc.calledWith(SPACE_ID)); + assert.ok(test.mocks.spaces.collection.calledWith(USERS_COLLECTION)); + assert.ok(test.mocks.children.orderBy.calledOnceWith('__name__')); + assert.ok(test.mocks.query.limit.calledOnceWith(BATCH_SIZE)); + assert.strictEqual(test.mocks.query.get.callCount, 2); + assert.strictEqual(test.mocks.firestore.batch.callCount, 1); + assert.strictEqual(test.mocks.batch.delete.callCount, 2); + assert.strictEqual(test.mocks.batch.commit.callCount, 1); + }); + + it('should do nothing if there are zero users', async function () { + const test = getTestEnvironment(); + const FirestoreService = test.module.FirestoreService; + test.mocks.query.get.returns({ size: 0 }); + + await FirestoreService.cleanupUsers(SPACE_NAME); + + assert.ok( + test.mocks.firestore.collection.calledOnceWith(SPACES_COLLECTION)); + assert.ok(test.mocks.spaces.doc.calledWith(SPACE_ID)); + assert.ok(test.mocks.spaces.collection.calledWith(USERS_COLLECTION)); + assert.ok(test.mocks.children.orderBy.calledOnceWith('__name__')); + assert.ok(test.mocks.query.limit.calledOnceWith(BATCH_SIZE)); + assert.strictEqual(test.mocks.query.get.callCount, 1); + assert.ok(test.mocks.firestore.batch.notCalled); + assert.ok(test.mocks.batch.delete.notCalled); + assert.ok(test.mocks.batch.commit.notCalled); + }); +}); From 1d007cad7e1deb617e75d8689b4e79bd73f9c75d Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Mon, 4 Dec 2023 21:42:56 +0000 Subject: [PATCH 05/12] test: Add unit tests for remaining services. --- .../test/services/space-service.test.js | 65 +++ .../test/services/user-service.test.js | 85 ++++ .../test/services/user-story-service.test.js | 384 ++++++++++++++++++ 3 files changed, 534 insertions(+) create mode 100644 node/project-management-app/test/services/space-service.test.js create mode 100644 node/project-management-app/test/services/user-service.test.js create mode 100644 node/project-management-app/test/services/user-story-service.test.js diff --git a/node/project-management-app/test/services/space-service.test.js b/node/project-management-app/test/services/space-service.test.js new file mode 100644 index 00000000..3332ef00 --- /dev/null +++ b/node/project-management-app/test/services/space-service.test.js @@ -0,0 +1,65 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +const SPACE_NAME = 'spaces/test'; +const DISPLAY_NAME = 'Space'; + +const getTestEnvironment = () => { + const firestoreServiceMock = { + createSpace: sinon.stub(), + deleteSpace: sinon.stub(), + }; + + return { + module: proxyquire('../../services/space-service', { + './firestore-service': { + FirestoreService: firestoreServiceMock, + } + }), + mocks: { + firestoreService: firestoreServiceMock, + }, + }; +}; + +describe('SpaceService.createSpace', function () { + it('should create space', async function () { + const test = getTestEnvironment(); + const SpaceService = test.module.SpaceService; + + await SpaceService.createSpace(SPACE_NAME, DISPLAY_NAME); + + assert.ok( + test.mocks.firestoreService.createSpace.calledOnceWith( + SPACE_NAME, DISPLAY_NAME)); + }); +}); + +describe('SpaceService.deleteSpace', function () { + it('should delete space', async function () { + const test = getTestEnvironment(); + const SpaceService = test.module.SpaceService; + + await SpaceService.deleteSpace(SPACE_NAME); + + assert.ok( + test.mocks.firestoreService.deleteSpace.calledOnceWith(SPACE_NAME)); + }); +}); diff --git a/node/project-management-app/test/services/user-service.test.js b/node/project-management-app/test/services/user-service.test.js new file mode 100644 index 00000000..f9ba9050 --- /dev/null +++ b/node/project-management-app/test/services/user-service.test.js @@ -0,0 +1,85 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); +const { User } = require('../../model/user'); + +const SPACE_NAME = 'spaces/test'; +const USER_ID = '123'; +const USER = new User(USER_ID, 'Display Name', 'avatar.png'); + +const getTestEnvironment = () => { + const firestoreServiceMock = { + getUser: sinon.stub(), + getUsers: sinon.stub(), + createOrUpdateUser: sinon.stub(), + }; + + return { + module: proxyquire('../../services/user-service', { + './firestore-service': { + FirestoreService: firestoreServiceMock, + } + }), + mocks: { + firestoreService: firestoreServiceMock, + }, + }; +}; + +describe('UserService.getUser', function () { + it('should return user', async function () { + const test = getTestEnvironment(); + const UserService = test.module.UserService; + test.mocks.firestoreService.getUser.returns(USER); + + const user = await UserService.getUser(SPACE_NAME, USER_ID); + + assert.ok( + test.mocks.firestoreService.getUser.calledOnceWith(SPACE_NAME, USER_ID)); + assert.deepStrictEqual(user, USER); + }); +}); + +describe('UserService.getUsers', function () { + it('should return users', async function () { + const test = getTestEnvironment(); + const UserService = test.module.UserService; + test.mocks.firestoreService.getUsers.returns({ '123': USER }); + + const users = await UserService.getUsers(SPACE_NAME, [USER_ID]); + + assert.ok( + test.mocks.firestoreService.getUsers.calledOnceWith( + SPACE_NAME, [USER_ID])); + assert.deepStrictEqual(users, { '123': USER }); + }); +}); + +describe('UserService.createOrUpdateUser', function () { + it('should create or update user', async function () { + const test = getTestEnvironment(); + const UserService = test.module.UserService; + + await UserService.createOrUpdateUser(SPACE_NAME, USER); + + assert.ok( + test.mocks.firestoreService.createOrUpdateUser.calledOnceWith( + SPACE_NAME, USER)); + }); +}); diff --git a/node/project-management-app/test/services/user-story-service.test.js b/node/project-management-app/test/services/user-story-service.test.js new file mode 100644 index 00000000..1b46816d --- /dev/null +++ b/node/project-management-app/test/services/user-story-service.test.js @@ -0,0 +1,384 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); +const { BadRequestException } = require('../../model/exceptions'); +const { UserStory, Status, Priority, Size } = require('../../model/user-story'); + +const SPACE_NAME = 'spaces/test'; +const USER_NAME = 'users/123'; +const USER_ID = '123'; +const USER_STORY_ID = 'abc'; +const USER_STORY_DATA = { + title: 'User Story', + description: 'User Story description.', + status: Status.OPEN, +}; +const USER_STORY = new UserStory(USER_STORY_ID, USER_STORY_DATA); + +const getTestEnvironment = () => { + const firestoreServiceMock = { + getUserStory: sinon.stub().returns(USER_STORY), + createUserStory: sinon.stub(), + updateUserStory: sinon.stub(), + listAllUserStories: sinon.stub(), + listUserStoriesByUser: sinon.stub(), + cleanupUserStories: sinon.stub(), + }; + + return { + module: proxyquire('../../services/user-story-service', { + './firestore-service': { + FirestoreService: firestoreServiceMock, + } + }), + mocks: { + firestoreService: firestoreServiceMock, + }, + }; +}; + +describe('UserStoryService.getUserStory', function () { + it('should return user story', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + + const userStory = + await UserStoryService.getUserStory(SPACE_NAME, USER_STORY_ID); + + assert.ok( + test.mocks.firestoreService.getUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID)); + assert.deepStrictEqual(userStory, USER_STORY); + }); +}); + +describe('UserStoryService.createUserStory', function () { + it('should create user story', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + test.mocks.firestoreService.createUserStory.returns(USER_STORY); + + const userStory = + await UserStoryService.createUserStory( + SPACE_NAME, USER_STORY_DATA.title, USER_STORY_DATA.description); + + assert.ok( + test.mocks.firestoreService.createUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_DATA)); + assert.deepStrictEqual(userStory, USER_STORY); + }); + + it('should throw if title is empty', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + + assert.rejects(async () => + await UserStoryService.createUserStory(SPACE_NAME, /* title= */ ''), + BadRequestException); + }); + + it('should throw if title is null', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + + assert.rejects(async () => + await UserStoryService.createUserStory(SPACE_NAME, /* title= */ null), + BadRequestException); + }); + + it('should throw if title is undefined', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + + assert.rejects(async () => + await UserStoryService.createUserStory(SPACE_NAME), + BadRequestException); + }); +}); + +describe('UserStoryService.assignUserStory', function () { + it('should assign user story', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + const updatedUserStory = + new UserStory(USER_STORY_ID, { ...USER_STORY_DATA, assignee: USER_ID }); + test.mocks.firestoreService.updateUserStory.returns(updatedUserStory); + + const userStory = + await UserStoryService.assignUserStory( + SPACE_NAME, USER_STORY_ID, USER_NAME); + + assert.ok( + test.mocks.firestoreService.updateUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID, { assignee: USER_ID })); + assert.deepStrictEqual(userStory, updatedUserStory); + }); + + it('should throw if story is completed', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + test.mocks.firestoreService.getUserStory.returns( + new UserStory(USER_STORY_ID, { + ...USER_STORY_DATA, + status: Status.COMPLETED, + })); + + assert.rejects(async () => + await UserStoryService.assignUserStory( + SPACE_NAME, USER_STORY_ID, USER_NAME), + BadRequestException); + }); +}); + +describe('UserStoryService.updateUserStory', function () { + it('should update all user story fields', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + const updatedUserStory = + new UserStory(USER_STORY_ID, { + title: 'New title', + description: 'New description', + status: Status.STARTED, + priority: Priority.LOW, + size: Size.SMALL, + }); + test.mocks.firestoreService.updateUserStory.returns(updatedUserStory); + + const userStory = + await UserStoryService.updateUserStory( + SPACE_NAME, + USER_STORY_ID, + 'New title', + 'New description', + Status.STARTED, + Priority.LOW, + Size.SMALL); + + assert.ok( + test.mocks.firestoreService.updateUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID, { + title: 'New title', + description: 'New description', + status: Status.STARTED, + priority: Priority.LOW, + size: Size.SMALL, + })); + assert.deepStrictEqual(userStory, updatedUserStory); + }); + + it('should not update undefined fields', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + test.mocks.firestoreService.updateUserStory.returns(USER_STORY); + + const userStory = + await UserStoryService.updateUserStory(SPACE_NAME, USER_STORY_ID); + + assert.ok( + test.mocks.firestoreService.updateUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID, {})); + assert.deepStrictEqual(userStory, USER_STORY); + }); + + it('should throw if title is empty', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + + assert.rejects(async () => + await UserStoryService.updateUserStory( + SPACE_NAME, + USER_STORY_ID, + /* title= */ ''), + BadRequestException); + }); + + it('should throw if status is empty', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + + assert.rejects(async () => + await UserStoryService.updateUserStory( + SPACE_NAME, + USER_STORY_ID, + 'New Title', + /* status= */ ''), + BadRequestException); + }); + + it('should throw if priority is empty', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + + assert.rejects(async () => + await UserStoryService.updateUserStory( + SPACE_NAME, + USER_STORY_ID, + 'New Title', + Status.STARTED, + /* priority= */ ''), + BadRequestException); + }); + + it('should throw if size is empty', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + + assert.rejects(async () => + await UserStoryService.updateUserStory( + SPACE_NAME, + USER_STORY_ID, + 'New Title', + Status.STARTED, + Priority.LOW, + /* size= */ ''), + BadRequestException); + }); +}); + +describe('UserStoryService.startUserStory', function () { + it('should start user story', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + const updatedUserStory = + new UserStory(USER_STORY_ID, { + ...USER_STORY_DATA, + status: Status.STARTED, + }); + test.mocks.firestoreService.updateUserStory.returns(updatedUserStory); + + const userStory = + await UserStoryService.startUserStory(SPACE_NAME, USER_STORY_ID); + + assert.ok( + test.mocks.firestoreService.updateUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID, { status: Status.STARTED })); + assert.deepStrictEqual(userStory, updatedUserStory); + }); + + it('should throw if story is started', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + test.mocks.firestoreService.getUserStory.returns( + new UserStory(USER_STORY_ID, { + ...USER_STORY_DATA, + status: Status.STARTED, + })); + + assert.rejects(async () => + await UserStoryService.startUserStory( + SPACE_NAME, USER_STORY_ID, USER_NAME), + BadRequestException); + }); + + it('should throw if story is completed', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + test.mocks.firestoreService.getUserStory.returns( + new UserStory(USER_STORY_ID, { + ...USER_STORY_DATA, + status: Status.COMPLETED, + })); + + assert.rejects(async () => + await UserStoryService.startUserStory( + SPACE_NAME, USER_STORY_ID, USER_NAME), + BadRequestException); + }); +}); + +describe('UserStoryService.completeUserStory', function () { + it('should complete user story', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + const updatedUserStory = + new UserStory(USER_STORY_ID, { + ...USER_STORY_DATA, + status: Status.COMPLETED, + }); + test.mocks.firestoreService.updateUserStory.returns(updatedUserStory); + + const userStory = + await UserStoryService.completeUserStory(SPACE_NAME, USER_STORY_ID); + + assert.ok( + test.mocks.firestoreService.updateUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID, { status: Status.COMPLETED })); + assert.deepStrictEqual(userStory, updatedUserStory); + }); + + it('should throw if story is completed', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + test.mocks.firestoreService.getUserStory.returns( + new UserStory(USER_STORY_ID, { + ...USER_STORY_DATA, + status: Status.COMPLETED, + })); + + assert.rejects(async () => + await UserStoryService.completeUserStory( + SPACE_NAME, USER_STORY_ID, USER_NAME), + BadRequestException); + }); +}); + +describe('UserStoryService.listAllUserStories', function () { + it('should list all user stories', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + test.mocks.firestoreService.listAllUserStories.returns([USER_STORY]); + + const userStories = + await UserStoryService.listAllUserStories(SPACE_NAME); + + assert.ok( + test.mocks.firestoreService.listAllUserStories.calledOnceWith( + SPACE_NAME)); + assert.deepStrictEqual(userStories, [USER_STORY]); + }); +}); + +describe('UserStoryService.listUserStoriesByUser', function () { + it('should list user stories by user', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + test.mocks.firestoreService.listUserStoriesByUser.returns([USER_STORY]); + + const userStories = + await UserStoryService.listUserStoriesByUser(SPACE_NAME, USER_NAME); + + assert.ok( + test.mocks.firestoreService.listUserStoriesByUser.calledOnceWith( + SPACE_NAME, USER_ID)); + assert.deepStrictEqual(userStories, [USER_STORY]); + }); +}); + +describe('UserStoryService.cleanupUserStories', function () { + it('should delete all user stories', async function () { + const test = getTestEnvironment(); + const UserStoryService = test.module.UserStoryService; + + await UserStoryService.cleanupUserStories(SPACE_NAME); + + assert.ok( + test.mocks.firestoreService.cleanupUserStories.calledOnceWith( + SPACE_NAME)); + }); +}); From 1e9b19aca4a836d7bad5161d3f462b03493bda9b Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Tue, 5 Dec 2023 15:23:22 +0000 Subject: [PATCH 06/12] test: Add unit tests for AIPService. --- .../test/services/aip-service.test.js | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 node/project-management-app/test/services/aip-service.test.js diff --git a/node/project-management-app/test/services/aip-service.test.js b/node/project-management-app/test/services/aip-service.test.js new file mode 100644 index 00000000..e7b4605c --- /dev/null +++ b/node/project-management-app/test/services/aip-service.test.js @@ -0,0 +1,149 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +const getTestEnvironment = () => { + const env = { + project: 'project-id', + location: 'location-id', + }; + + const predictionServiceClientMock = { + predict: sinon.stub().returns([{ + predictions: [{ + structValue: { + fields: { + content: { + stringValue: 'Predicted text.' + } + } + } + }] + }]), + }; + + const aiplatformV1Mock = { + PredictionServiceClient: sinon.stub().returns(predictionServiceClientMock), + }; + + const helpersMock = { + toValue: sinon.stub().returnsArg(0), + } + + return { + module: proxyquire('../../services/aip-service', { + '@google-cloud/aiplatform': { + v1: aiplatformV1Mock, + helpers: helpersMock, + }, + '../env.js': { + env: env, + } + }), + mocks: { + aiplatformV1: aiplatformV1Mock, + helpers: helpersMock, + predictionServiceClient: predictionServiceClientMock, + }, + }; +}; + +const getExpectedRequest = (prompt) => ({ + endpoint: 'projects/project-id/locations/location-id/publishers/google/models/text-bison', + instances: [{ prompt: prompt }], + parameters: { + temperature: 0.2, + maxOutputTokens: 256, + topP: 0.95, + topK: 40, + }, +}); + +describe('AIPService.generateDescription', function () { + it('should call prediction service with generate description prompt', + async function () { + const test = getTestEnvironment(); + const AIPService = test.module.AIPService; + const prompt = + 'Generate a description for a user story with the following title:' + + '\n\nTitle'; + + const predictedText = await AIPService.generateDescription('Title'); + + assert.strictEqual(predictedText, 'Predicted text.'); + assert.strictEqual(test.mocks.helpers.toValue.callCount, 2); + assert.ok( + test.mocks.predictionServiceClient.predict.calledOnceWith( + getExpectedRequest(prompt))); + }); +}); + +describe('AIPService.expandDescription', function () { + it('should call prediction service with expand description prompt', + async function () { + const test = getTestEnvironment(); + const AIPService = test.module.AIPService; + const prompt = + 'Expand the following user story description:\n\nDescription'; + + const predictedText = await AIPService.expandDescription('Description'); + + assert.strictEqual(predictedText, 'Predicted text.'); + assert.strictEqual(test.mocks.helpers.toValue.callCount, 2); + assert.ok( + test.mocks.predictionServiceClient.predict.calledOnceWith( + getExpectedRequest(prompt))); + }); +}); + +describe('AIPService.correctDescription', function () { + it('should call prediction service with correct grammar prompt', + async function () { + const test = getTestEnvironment(); + const AIPService = test.module.AIPService; + const prompt = + 'Correct the grammar of the following user story description:' + + '\n\nDescription'; + + const predictedText = await AIPService.correctDescription('Description'); + + assert.strictEqual(predictedText, 'Predicted text.'); + assert.strictEqual(test.mocks.helpers.toValue.callCount, 2); + assert.ok( + test.mocks.predictionServiceClient.predict.calledOnceWith( + getExpectedRequest(prompt))); + }); +}); + +describe('AIPService.callPredict', function () { + it('should call prediction service', async function () { + const test = getTestEnvironment(); + const AIPService = test.module.AIPService; + const prompt = + 'Generate a description for a user story with the following title:'; + + const predictedText = await AIPService.callPredict('Prompt.'); + + assert.strictEqual(predictedText, 'Predicted text.'); + assert.strictEqual(test.mocks.helpers.toValue.callCount, 2); + assert.ok( + test.mocks.predictionServiceClient.predict.calledOnceWith( + getExpectedRequest('Prompt.'))); + }); +}); From e09242688f5e89ff5124ca884fe4e4d810426f29 Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Tue, 5 Dec 2023 19:02:30 +0000 Subject: [PATCH 07/12] Add unit tests for the views and widgets. --- node/project-management-app/package.json | 2 +- .../test/views/edit-user-story-card.test.js | 352 +++++++++++ .../test/views/help-card.test.js | 114 ++++ .../test/views/user-story-card.test.js | 179 ++++++ .../test/views/user-story-list-card.test.js | 184 ++++++ .../user-story-assignee-widget.test.js | 86 +++ .../widgets/user-story-buttons-widget.test.js | 567 ++++++++++++++++++ .../widgets/user-story-columns-widget.test.js | 72 +++ .../widgets/user-story-row-widget.test.js | 78 +++ 9 files changed, 1633 insertions(+), 1 deletion(-) create mode 100644 node/project-management-app/test/views/edit-user-story-card.test.js create mode 100644 node/project-management-app/test/views/help-card.test.js create mode 100644 node/project-management-app/test/views/user-story-card.test.js create mode 100644 node/project-management-app/test/views/user-story-list-card.test.js create mode 100644 node/project-management-app/test/views/widgets/user-story-assignee-widget.test.js create mode 100644 node/project-management-app/test/views/widgets/user-story-buttons-widget.test.js create mode 100644 node/project-management-app/test/views/widgets/user-story-columns-widget.test.js create mode 100644 node/project-management-app/test/views/widgets/user-story-row-widget.test.js diff --git a/node/project-management-app/package.json b/node/project-management-app/package.json index 8c9cdb40..c40e8480 100644 --- a/node/project-management-app/package.json +++ b/node/project-management-app/package.json @@ -4,7 +4,7 @@ "description": "A sample project management app for Google Chat", "main": "index.js", "scripts": { - "test": "mocha -p -j 2 -t 5s test/**/*.test.js" + "test": "mocha -p -j 2 -t 5s \"test/**/*.test.js\"" }, "repository": { "type": "git", diff --git a/node/project-management-app/test/views/edit-user-story-card.test.js b/node/project-management-app/test/views/edit-user-story-card.test.js new file mode 100644 index 00000000..d1117d65 --- /dev/null +++ b/node/project-management-app/test/views/edit-user-story-card.test.js @@ -0,0 +1,352 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const { UserStory, Status, Priority, Size } = require('../../model/user-story'); +const { User } = require('../../model/user'); +const { EditUserStoryCard } = require('../../views/edit-user-story-card'); +const { UserStoryAssigneeWidget } = require('../../views/widgets/user-story-assignee-widget'); +const { UserStoryButtonsWidget } = require('../../views/widgets/user-story-buttons-widget'); +const { UserStoryCardType } = require('../../views/widgets/user-story-card-type'); + +const USER_STORY = new UserStory('id', { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.COMPLETED, + priority: Priority.LOW, + size: Size.SMALL, +}); + +const USER = new User('123', 'Display Name', 'avatar.jpg'); + +describe('EditUserStoryCard', function () { + it('should return Edit User Story card', function () { + const expected = Object.assign(Object.create(EditUserStoryCard.prototype), { + header: { + title: 'Title', + subtitle: 'ID: id' + }, + sections: [ + { + widgets: [ + { + textInput: { + name: 'title', + label: 'Title', + type: 'SINGLE_LINE', + value: 'Title' + } + }, + { + textInput: { + name: 'description', + label: 'Description', + type: 'MULTIPLE_LINE', + value: 'Description' + } + }, + { + buttonList: { + buttons: [ + { + text: 'Regenerate', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/generating_tokens/materialiconsoutlined/24dp/1x/outline_generating_tokens_black_24dp.png', + altText: 'Regenerate', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'generateUserStoryDescription', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'assignee', + value: '123' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + }, + { + text: 'Expand', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/generating_tokens/materialiconsoutlined/24dp/1x/outline_generating_tokens_black_24dp.png', + altText: 'Expand', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'expandUserStoryDescription', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'assignee', + value: '123' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + }, + { + text: 'Correct grammar', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/spellcheck/materialicons/24dp/1x/baseline_spellcheck_black_24dp.png', + altText: 'Correct grammar', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'correctUserStoryDescriptionGrammar', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'assignee', + value: '123' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + } + ] + } + }, + { + selectionInput: { + name: 'status', + label: 'Status', + type: 'DROPDOWN', + items: [ + { + text: 'OPEN', + value: 'OPEN', + selected: false + }, + { + text: 'STARTED', + value: 'STARTED', + selected: false + }, + { + text: 'COMPLETED', + value: 'COMPLETED', + selected: true + }, + ] + } + }, + { + columns: { + columnItems: [ + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + selectionInput: { + name: 'priority', + label: 'Priority', + type: 'DROPDOWN', + items: [ + { + text: 'Low', + value: 'Low', + selected: true + }, + { + text: 'Medium', + value: 'Medium', + selected: false + }, + { + text: 'High', + value: 'High', + selected: false + }, + ] + } + } + ] + }, + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + selectionInput: { + name: 'size', + label: 'Size', + type: 'DROPDOWN', + items: [ + { + text: 'Small', + value: 'Small', + selected: true + }, + { + text: 'Medium', + value: 'Medium', + selected: false + }, + { + text: 'Large', + value: 'Large', + selected: false + }, + ] + } + } + ] + } + ] + } + }, + Object.assign(Object.create(UserStoryAssigneeWidget.prototype), { + decoratedText: { + topLabel: 'Assigned to', + text: 'Display Name', + startIcon: { + iconUrl: 'avatar.jpg', + imageType: 'CIRCLE', + } + } + }) + ] + }, + { + widgets: [ + Object.assign(Object.create(UserStoryButtonsWidget.prototype), { + buttonList: { + buttons: [ + { + text: 'Save', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/content/save/materialiconsoutlined/24dp/1x/outline_save_black_24dp.png', + altText: 'Save', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'saveUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + }, + { + text: 'Cancel', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/content/clear/materialicons/24dp/1x/baseline_clear_black_24dp.png', + altText: 'Cancel', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'cancelEditUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + }, + ] + } + }) + ] + }, + ] + }); + + const actual = + new EditUserStoryCard(USER_STORY, USER, /* updated= */ false); + + assert.deepStrictEqual(actual, expected); + }); + + it('should add message if updated is true', function () { + const actual = + new EditUserStoryCard(USER_STORY, USER, /* updated= */ true); + + assert.deepStrictEqual(actual.sections[0], { + widgets: [ + { + decoratedText: { + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/info_outline/materialicons/48dp/1x/baseline_info_outline_black_48dp.png' + }, + text: 'Saved.' + } + } + ] + }); + }); + + it('should return empty object if user story is null', function () { + const actual = new EditUserStoryCard(null); + + assert.strictEqual(Object.keys(actual).length, 0); + }); + + it('should return empty object if user story is undefined', function () { + const actual = new EditUserStoryCard(); + + assert.strictEqual(Object.keys(actual).length, 0); + }); +}); diff --git a/node/project-management-app/test/views/help-card.test.js b/node/project-management-app/test/views/help-card.test.js new file mode 100644 index 00000000..e0bbdcfe --- /dev/null +++ b/node/project-management-app/test/views/help-card.test.js @@ -0,0 +1,114 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const { HelpCard } = require('../../views/help-card'); + +describe('HelpCard', function () { + it('should return Help card', function () { + const expected = Object.assign(Object.create(HelpCard.prototype), { + header: { + title: 'Project Manager', + subtitle: 'Agile Project Management app' + }, + sections: [{ + header: 'Available commands', + widgets: [ + { + decoratedText: { + text: '/createUserStory title', + bottomLabel: 'Create a user story with the given title.' + } + }, + { + divider: {} + }, + { + decoratedText: { + text: '/userStory id', + bottomLabel: 'Displays the current status of a user story.' + } + }, + { + divider: {} + }, + { + decoratedText: { + text: '/myUserStories', + bottomLabel: 'Lists all the user stories assigned to the user.', + button: { + text: 'Try it', + onClick: { + action: { + function: 'myUserStories' + } + } + } + } + }, + { + divider: {} + }, + { + decoratedText: { + text: '/manageUserStories', + bottomLabel: 'Opens a dialog for user story management.', + button: { + text: 'Try it', + onClick: { + action: { + function: 'manageUserStories', + interaction: 'OPEN_DIALOG' + } + } + } + } + }, + { + divider: {} + }, + { + decoratedText: { + text: '/cleanupUserStories', + bottomLabel: 'Deletes all user stories in the space.', + button: { + text: 'Try it', + onClick: { + action: { + function: 'cleanupUserStories' + } + } + } + } + }, + { + divider: {} + }, + { + decoratedText: { + text: '@Project Manager userstories', + bottomLabel: 'Lists all the user stories assigned to the user.' + } + }, + ] + }] + }); + + const actual = new HelpCard(); + + assert.deepStrictEqual(actual, expected); + }); +}); diff --git a/node/project-management-app/test/views/user-story-card.test.js b/node/project-management-app/test/views/user-story-card.test.js new file mode 100644 index 00000000..cd1c9b48 --- /dev/null +++ b/node/project-management-app/test/views/user-story-card.test.js @@ -0,0 +1,179 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const { UserStory, StatusIcon, Status, Priority, Size } = require('../../model/user-story'); +const { User } = require('../../model/user'); +const { UserStoryCard } = require('../../views/user-story-card'); +const { UserStoryAssigneeWidget } = require('../../views/widgets/user-story-assignee-widget'); +const { UserStoryButtonsWidget } = require('../../views/widgets/user-story-buttons-widget'); +const { UserStoryColumnsWidget } = require('../../views/widgets/user-story-columns-widget'); +const { UserStoryCardType } = require('../../views/widgets/user-story-card-type'); + +describe('UserStoryCard', function () { + it('should return User Story card', function () { + const userStory = new UserStory('id', { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.COMPLETED, + priority: Priority.LOW, + size: Size.SMALL, + }); + const user = new User('123', 'Display Name', 'avatar.jpg'); + const expected = Object.assign(Object.create(UserStoryCard.prototype), { + header: { + title: 'Title', + subtitle: 'ID: id', + imageUrl: StatusIcon[Status.COMPLETED], + imageAltText: 'COMPLETED', + imageType: 'CIRCLE', + }, + sections: [ + { + widgets: [ + { + textParagraph: { + text: 'Description', + } + } + ] + }, + { + widgets: [ + Object.assign(Object.create(UserStoryColumnsWidget.prototype), { + columns: { + columnItems: [ + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + decoratedText: { + topLabel: 'Priority', + text: 'Low', + startIcon: null, + } + } + ] + }, + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + decoratedText: { + topLabel: 'Size', + text: 'Small', + startIcon: null, + } + } + ] + } + ] + } + }), + Object.assign(Object.create(UserStoryAssigneeWidget.prototype), { + decoratedText: { + topLabel: 'Assigned to', + text: 'Display Name', + startIcon: { + iconUrl: 'avatar.jpg', + imageType: 'CIRCLE', + } + } + }) + ] + }, + { + widgets: [ + Object.assign(Object.create(UserStoryButtonsWidget.prototype), { + buttonList: { + buttons: [ + { + text: 'Edit', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/editor/edit_note/materialiconsoutlined/24dp/1x/outline_edit_note_black_24dp.png', + altText: 'Edit', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'editUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_MESSAGE + } + ] + } + } + }, + { + text: 'Refresh', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/navigation/refresh/materialicons/24dp/1x/baseline_refresh_black_24dp.png', + altText: 'Refresh', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'refreshUserStory', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_MESSAGE + } + ] + } + } + } + ] + } + }) + ] + }, + ] + }); + + const actual = new UserStoryCard(userStory, user); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return empty object if user story is null', function () { + const actual = new UserStoryCard(null); + + assert.strictEqual(Object.keys(actual).length, 0); + }); + + it('should return empty object if user story is undefined', function () { + const actual = new UserStoryCard(); + + assert.strictEqual(Object.keys(actual).length, 0); + }); +}); diff --git a/node/project-management-app/test/views/user-story-list-card.test.js b/node/project-management-app/test/views/user-story-list-card.test.js new file mode 100644 index 00000000..2f151832 --- /dev/null +++ b/node/project-management-app/test/views/user-story-list-card.test.js @@ -0,0 +1,184 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const { UserStory, StatusIcon, Status, Priority, Size } = require('../../model/user-story'); +const { User } = require('../../model/user'); +const { UserStoryListCard } = require('../../views/user-story-list-card'); +const { UserStoryAssigneeWidget } = require('../../views/widgets/user-story-assignee-widget'); +const { UserStoryColumnsWidget } = require('../../views/widgets/user-story-columns-widget'); +const { UserStoryRowWidget } = require('../../views/widgets/user-story-row-widget'); + +const USER_STORIES = [ + new UserStory('id', { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.COMPLETED, + priority: Priority.LOW, + size: Size.SMALL, + }) +]; + +describe('UserStoryListCard', function () { + it('should return User Story list card', function () { + const user = new User('123', 'Display Name', 'avatar.jpg'); + const expected = Object.assign(Object.create(UserStoryListCard.prototype), { + sections: [{ + collapsible: true, + uncollapsibleWidgetsCount: 1, + widgets: [ + Object.assign(Object.create(UserStoryRowWidget.prototype), { + decoratedText: { + text: 'Title', + bottomLabel: 'ID: id', + startIcon: { + iconUrl: StatusIcon[Status.COMPLETED], + altText: 'COMPLETED', + imageType: 'CIRCLE' + }, + button: { + text: 'Edit', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/editor/edit_note/materialiconsoutlined/24dp/1x/outline_edit_note_black_24dp.png', + altText: 'Edit', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'editUserStory', + interaction: 'OPEN_DIALOG', + parameters: [{ + key: 'id', + value: 'id' + }] + } + } + } + } + }), + { + divider: {} + }, + { + textParagraph: { + text: 'Description' + } + }, + { + divider: {} + }, + Object.assign(Object.create(UserStoryColumnsWidget.prototype), { + columns: { + columnItems: [ + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + decoratedText: { + topLabel: 'Priority', + text: 'Low', + startIcon: null, + } + } + ] + }, + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + decoratedText: { + topLabel: 'Size', + text: 'Small', + startIcon: null, + } + } + ] + } + ] + } + }), + Object.assign(Object.create(UserStoryAssigneeWidget.prototype), { + decoratedText: { + topLabel: 'Assigned to', + text: 'Display Name', + startIcon: { + iconUrl: 'avatar.jpg', + imageType: 'CIRCLE', + } + } + }), + ] + }] + }); + + const actual = + new UserStoryListCard( + 'Title', + USER_STORIES, + /* users= */ { '123': user }, + /* isDialog= */ true); + + assert.deepStrictEqual(actual, expected); + }); + + it('should add title if isDialog is false', function () { + const actual = + new UserStoryListCard( + 'Title', + USER_STORIES, + /* users= */ {}, + /* isDialog= */ false); + + assert.deepStrictEqual(actual.header, { title: 'Title' }); + }); + + it('should return message if user story list is empty', function () { + const expected = Object.assign(Object.create(UserStoryListCard.prototype), { + sections: [{ + widgets: [{ + textParagraph: { + text: 'You don\'t have any user story yet.' + } + }] + }] + }); + + const actual = new UserStoryListCard( + 'Title', + /* userStories= */ [], + /* users= */ {}, + /* isDialog= */ true); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return empty object if user story list is null', function () { + const actual = new UserStoryListCard('Title', null); + + assert.strictEqual(Object.keys(actual).length, 0); + }); + + it('should return empty object if user story list is undefined', function () { + const actual = new UserStoryListCard('Title'); + + assert.strictEqual(Object.keys(actual).length, 0); + }); +}); diff --git a/node/project-management-app/test/views/widgets/user-story-assignee-widget.test.js b/node/project-management-app/test/views/widgets/user-story-assignee-widget.test.js new file mode 100644 index 00000000..d96a0597 --- /dev/null +++ b/node/project-management-app/test/views/widgets/user-story-assignee-widget.test.js @@ -0,0 +1,86 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const { UserStoryAssigneeWidget } = require('../../../views/widgets/user-story-assignee-widget'); + +describe('UserStoryAssigneeWidget', function () { + it('should return widget with user data', function () { + const expected = + Object.assign(Object.create(UserStoryAssigneeWidget.prototype), { + decoratedText: { + topLabel: 'Assigned to', + text: 'Display Name', + startIcon: { + iconUrl: 'avatar.jpg', + imageType: 'CIRCLE' + } + } + }); + + const actual = new UserStoryAssigneeWidget('123', { + displayName: 'Display Name', + avatarUrl: 'avatar.jpg' + }); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return widget with unknown user', function () { + const expected = + Object.assign(Object.create(UserStoryAssigneeWidget.prototype), { + decoratedText: { + topLabel: 'Assigned to', + text: 'Unknown user', + startIcon: undefined, + } + }); + + const actual = new UserStoryAssigneeWidget('123'); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return widget with null user assigned', function () { + const expected = + Object.assign(Object.create(UserStoryAssigneeWidget.prototype), { + decoratedText: { + topLabel: 'Assigned to', + text: '-', + startIcon: undefined, + } + }); + + const actual = new UserStoryAssigneeWidget(null); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return widget with no user assigned', function () { + const expected = + Object.assign(Object.create(UserStoryAssigneeWidget.prototype), { + decoratedText: { + topLabel: 'Assigned to', + text: '-', + startIcon: undefined, + } + }); + + const actual = new UserStoryAssigneeWidget(); + + assert.deepStrictEqual(actual, expected); + }); +}); diff --git a/node/project-management-app/test/views/widgets/user-story-buttons-widget.test.js b/node/project-management-app/test/views/widgets/user-story-buttons-widget.test.js new file mode 100644 index 00000000..245caed1 --- /dev/null +++ b/node/project-management-app/test/views/widgets/user-story-buttons-widget.test.js @@ -0,0 +1,567 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const { UserStory, Status, Priority, Size } = require('../../../model/user-story'); +const { UserStoryButtonsWidget } = require('../../../views/widgets/user-story-buttons-widget'); +const { UserStoryCardType } = require('../../../views/widgets/user-story-card-type'); + +describe('UserStoryButtonsWidget', function () { + it('should return widget for open story', function () { + const userStory = new UserStory('id', { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.OPEN, + priority: Priority.LOW, + size: Size.SMALL, + }); + const expected = + Object.assign(Object.create(UserStoryButtonsWidget.prototype), { + buttonList: { + buttons: [ + { + text: 'Assign to me', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/social/person/materialicons/24dp/1x/baseline_person_black_24dp.png', + altText: 'Assign to me', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'assignUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + }, + { + text: 'Start', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/av/play_arrow/materialicons/24dp/1x/baseline_play_arrow_black_24dp.png', + altText: 'Start', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'startUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + } + ] + } + }); + + const actual = new UserStoryButtonsWidget( + userStory, + UserStoryCardType.SINGLE_DIALOG, + /* showEdit= */ false, + /* showSave= */ false); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return widget for open story with edit button', function () { + const userStory = new UserStory('id', { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.OPEN, + priority: Priority.LOW, + size: Size.SMALL, + }); + const expected = + Object.assign(Object.create(UserStoryButtonsWidget.prototype), { + buttonList: { + buttons: [ + { + text: 'Assign to me', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/social/person/materialicons/24dp/1x/baseline_person_black_24dp.png', + altText: 'Assign to me', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'assignUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + }, + { + text: 'Start', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/av/play_arrow/materialicons/24dp/1x/baseline_play_arrow_black_24dp.png', + altText: 'Start', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'startUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + }, + { + text: 'Edit', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/editor/edit_note/materialiconsoutlined/24dp/1x/outline_edit_note_black_24dp.png', + altText: 'Edit', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'editUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + } + ] + } + }); + + const actual = new UserStoryButtonsWidget( + userStory, + UserStoryCardType.SINGLE_DIALOG, + /* showEdit= */ true, + /* showSave= */ false); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return widget for open story with save button', function () { + const userStory = new UserStory('id', { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.OPEN, + priority: Priority.LOW, + size: Size.SMALL, + }); + const expected = + Object.assign(Object.create(UserStoryButtonsWidget.prototype), { + buttonList: { + buttons: [ + { + text: 'Save', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/content/save/materialiconsoutlined/24dp/1x/outline_save_black_24dp.png', + altText: 'Save', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'saveUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + }, + { + text: 'Assign to me', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/social/person/materialicons/24dp/1x/baseline_person_black_24dp.png', + altText: 'Assign to me', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'assignUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + }, + { + text: 'Start', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/av/play_arrow/materialicons/24dp/1x/baseline_play_arrow_black_24dp.png', + altText: 'Start', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'startUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + }, + { + text: 'Cancel', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/content/clear/materialicons/24dp/1x/baseline_clear_black_24dp.png', + altText: 'Cancel', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'cancelEditUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + } + ] + } + }); + + const actual = new UserStoryButtonsWidget( + userStory, + UserStoryCardType.SINGLE_DIALOG, + /* showEdit= */ false, + /* showSave= */ true); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return widget for open story with refresh button', function () { + const userStory = new UserStory('id', { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.OPEN, + priority: Priority.LOW, + size: Size.SMALL, + }); + const expected = + Object.assign(Object.create(UserStoryButtonsWidget.prototype), { + buttonList: { + buttons: [ + { + text: 'Assign to me', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/social/person/materialicons/24dp/1x/baseline_person_black_24dp.png', + altText: 'Assign to me', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'assignUserStory', + interaction: undefined, + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_MESSAGE + } + ] + } + } + }, + { + text: 'Start', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/av/play_arrow/materialicons/24dp/1x/baseline_play_arrow_black_24dp.png', + altText: 'Start', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'startUserStory', + interaction: undefined, + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_MESSAGE + } + ] + } + } + }, + { + text: 'Refresh', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/navigation/refresh/materialicons/24dp/1x/baseline_refresh_black_24dp.png', + altText: 'Refresh', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'refreshUserStory', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_MESSAGE + } + ] + } + } + } + ] + } + }); + + const actual = new UserStoryButtonsWidget( + userStory, + UserStoryCardType.SINGLE_MESSAGE, + /* showEdit= */ false, + /* showSave= */ false); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return widget for started story', function () { + const userStory = new UserStory('id', { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.STARTED, + priority: Priority.LOW, + size: Size.SMALL, + }); + const expected = + Object.assign(Object.create(UserStoryButtonsWidget.prototype), { + buttonList: { + buttons: [ + { + text: 'Assign to me', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/social/person/materialicons/24dp/1x/baseline_person_black_24dp.png', + altText: 'Assign to me', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'assignUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + }, + { + text: 'Complete', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/action/done/materialicons/24dp/1x/baseline_done_black_24dp.png', + altText: 'Complete', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'completeUserStory', + interaction: 'OPEN_DIALOG', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_DIALOG + } + ] + } + } + } + ] + } + }); + + const actual = new UserStoryButtonsWidget( + userStory, + UserStoryCardType.SINGLE_DIALOG, + /* showEdit= */ false, + /* showSave= */ false); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return widget for completed story with refresh button', function () { + const userStory = new UserStory('id', { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.COMPLETED, + priority: Priority.LOW, + size: Size.SMALL, + }); + const expected = + Object.assign(Object.create(UserStoryButtonsWidget.prototype), { + buttonList: { + buttons: [ + { + text: 'Refresh', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/navigation/refresh/materialicons/24dp/1x/baseline_refresh_black_24dp.png', + altText: 'Refresh', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'refreshUserStory', + parameters: [ + { + key: 'id', + value: 'id' + }, + { + key: 'cardType', + value: UserStoryCardType.SINGLE_MESSAGE + } + ] + } + } + } + ] + } + }); + + const actual = new UserStoryButtonsWidget( + userStory, + UserStoryCardType.SINGLE_MESSAGE, + /* showEdit= */ false, + /* showSave= */ false); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return empty object for completed story', function () { + const userStory = new UserStory('id', { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.COMPLETED, + priority: Priority.LOW, + size: Size.SMALL, + }); + + const actual = new UserStoryButtonsWidget( + userStory, + UserStoryCardType.SINGLE_DIALOG, + /* showEdit= */ false, + /* showSave= */ false); + + assert.strictEqual(Object.keys(actual).length, 0); + }); + + it('should return empty object if user story is null', function () { + const actual = new UserStoryButtonsWidget(null); + + assert.strictEqual(Object.keys(actual).length, 0); + }); + + it('should return empty object if user story is undefined', function () { + const actual = new UserStoryButtonsWidget(); + + assert.strictEqual(Object.keys(actual).length, 0); + }); +}); diff --git a/node/project-management-app/test/views/widgets/user-story-columns-widget.test.js b/node/project-management-app/test/views/widgets/user-story-columns-widget.test.js new file mode 100644 index 00000000..cafa4da8 --- /dev/null +++ b/node/project-management-app/test/views/widgets/user-story-columns-widget.test.js @@ -0,0 +1,72 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const { UserStoryColumnsWidget } = require('../../../views/widgets/user-story-columns-widget'); + +describe('UserStoryColumnsWidget', function () { + it('should return widget with user story data', function () { + const expected = + Object.assign(Object.create(UserStoryColumnsWidget.prototype), { + columns: { + columnItems: [ + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + decoratedText: { + topLabel: 'First Column', + text: 'First Value', + startIcon: { + iconUrl: 'icon.png', + imageType: 'CIRCLE' + } + } + } + ] + }, + { + horizontalSizeStyle: 'FILL_AVAILABLE_SPACE', + horizontalAlignment: 'START', + verticalAlignment: 'CENTER', + widgets: [ + { + decoratedText: { + topLabel: 'Second Column', + text: 'Second Value', + startIcon: null + } + } + ] + } + ] + } + }); + + const actual = new UserStoryColumnsWidget({ + label: 'First Column', + text: 'First Value', + icon: 'icon.png' + }, { + label: 'Second Column', + text: 'Second Value', + }); + + assert.deepStrictEqual(actual, expected); + }); +}); diff --git a/node/project-management-app/test/views/widgets/user-story-row-widget.test.js b/node/project-management-app/test/views/widgets/user-story-row-widget.test.js new file mode 100644 index 00000000..0b347900 --- /dev/null +++ b/node/project-management-app/test/views/widgets/user-story-row-widget.test.js @@ -0,0 +1,78 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const { UserStory, StatusIcon, Status, Priority, Size } = require('../../../model/user-story'); +const { UserStoryRowWidget } = require('../../../views/widgets/user-story-row-widget'); + +describe('UserStoryRowWidget', function () { + it('should return widget with user story data', function () { + const userStory = new UserStory('id', { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.COMPLETED, + priority: Priority.LOW, + size: Size.SMALL, + }); + const expected = + Object.assign(Object.create(UserStoryRowWidget.prototype), { + decoratedText: { + text: 'Title', + bottomLabel: 'ID: id', + startIcon: { + iconUrl: StatusIcon[Status.COMPLETED], + altText: 'COMPLETED', + imageType: 'CIRCLE' + }, + button: { + text: 'Edit', + icon: { + iconUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/png/editor/edit_note/materialiconsoutlined/24dp/1x/outline_edit_note_black_24dp.png', + altText: 'Edit', + imageType: 'CIRCLE' + }, + onClick: { + action: { + function: 'editUserStory', + interaction: 'OPEN_DIALOG', + parameters: [{ + key: 'id', + value: 'id' + }] + } + } + } + } + }); + + const actual = new UserStoryRowWidget(userStory); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return empty object if user story is null', function () { + const actual = new UserStoryRowWidget(null); + + assert.strictEqual(Object.keys(actual).length, 0); + }); + + it('should return empty object if user story is undefined', function () { + const actual = new UserStoryRowWidget(); + + assert.strictEqual(Object.keys(actual).length, 0); + }); +}); From e9eb869b078362057c28e453a60741ff3e4097f4 Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Thu, 7 Dec 2023 18:42:05 +0000 Subject: [PATCH 08/12] test: Add unit tests for app controllers. --- .../controllers/app-action-handler.js | 2 +- .../controllers/app-action-handler.test.js | 992 ++++++++++++++++++ .../test/controllers/app.test.js | 423 ++++++++ 3 files changed, 1416 insertions(+), 1 deletion(-) create mode 100644 node/project-management-app/test/controllers/app-action-handler.test.js create mode 100644 node/project-management-app/test/controllers/app.test.js diff --git a/node/project-management-app/controllers/app-action-handler.js b/node/project-management-app/controllers/app-action-handler.js index 75f64c57..0880651b 100644 --- a/node/project-management-app/controllers/app-action-handler.js +++ b/node/project-management-app/controllers/app-action-handler.js @@ -377,7 +377,7 @@ class ChatAppActionHandler { switch (this.cardType) { case UserStoryCardType.SINGLE_MESSAGE: return { - text: saved ? 'User story updated.' : null, + text: updated ? 'User story updated.' : null, cardsV2: [{ cardId: 'userStoryCard', card: new UserStoryCard(userStory, user) diff --git a/node/project-management-app/test/controllers/app-action-handler.test.js b/node/project-management-app/test/controllers/app-action-handler.test.js new file mode 100644 index 00000000..f8b857eb --- /dev/null +++ b/node/project-management-app/test/controllers/app-action-handler.test.js @@ -0,0 +1,992 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); +const { BadRequestException, NotFoundException } = require('../../model/exceptions'); +const { UserStory, Status } = require('../../model/user-story'); +const { User } = require('../../model/user'); +const { EditUserStoryCard } = require('../../views/edit-user-story-card'); +const { UserStoryCard } = require('../../views/user-story-card'); +const { UserStoryCardType } = require('../../views/widgets/user-story-card-type'); + +const USER_STORY_ID = 'user-story-id'; +const USER_STORY = new UserStory(USER_STORY_ID, { + title: 'Title', + description: 'Description', + assignee: 'users/123', + status: Status.OPEN, +}); +const SPACE_NAME = 'spaces/ABC'; +const SPACE_DISPLAY_NAME = 'The Space'; +const USER_ID = '123'; +const USER_NAME = 'users/123'; +const USER_DISPLAY_NAME = 'Display Name'; +const USER_AVATAR = 'avatar.jpg'; +const USER = new User(USER_ID, USER_DISPLAY_NAME, USER_AVATAR); +const EVENT = { + type: 'CARD_CLICKED', + space: { + name: SPACE_NAME, + displayName: SPACE_DISPLAY_NAME, + }, + user: { + name: USER_NAME, + displayName: USER_DISPLAY_NAME, + avatarUrl: USER_AVATAR, + }, + common: { + } +} +const EXCEPTIONS = [ + new BadRequestException(), + new NotFoundException(), +]; + +const getTestEnvironment = () => { + const aipServiceMock = { + generateDescription: sinon.stub().returns('New Description'), + expandDescription: sinon.stub().returns('Expanded Description'), + correctDescription: sinon.stub().returns('Corrected Description'), + }; + const userServiceMock = { + getUser: sinon.stub().returns(USER), + createOrUpdateUser: sinon.stub(), + }; + const userStoryServiceMock = { + getUserStory: sinon.stub().returns(USER_STORY), + assignUserStory: sinon.stub().returns(USER_STORY), + startUserStory: sinon.stub().returns(USER_STORY), + completeUserStory: sinon.stub().returns(USER_STORY), + updateUserStory: sinon.stub().returns(USER_STORY), + }; + const chatAppMock = { + handleMyUserStories: sinon.stub().returns({ + text: 'handleMyUserStories' + }), + handleManageUserStories: sinon.stub().returns({ + text: 'handleManageUserStories' + }), + handleCleanupUserStories: sinon.stub().returns({ + text: 'handleCleanupUserStories' + }), + }; + + return { + AppActionHandler: proxyquire('../../controllers/app-action-handler', { + '../services/aip-service': { + AIPService: aipServiceMock, + }, + '../services/user-service': { + UserService: userServiceMock, + }, + '../services/user-story-service': { + UserStoryService: userStoryServiceMock, + }, + }), + mocks: { + aipService: aipServiceMock, + userService: userServiceMock, + userStoryService: userStoryServiceMock, + chatApp: chatAppMock, + }, + }; +}; + +const assertExceptionResponseIsValid = (response, e) => { + assert.deepStrictEqual(response, { + actionResponse: { + type: 'DIALOG', + dialogAction: { + actionStatus: { + statusCode: e.statusCode, + userFacingMessage: e.message + } + } + } + }); +}; + +const assertResponseIsValid = (test, response, cardType, updated) => { + switch (cardType) { + case UserStoryCardType.SINGLE_MESSAGE: + assert.strictEqual(response.text, updated ? 'User story updated.' : null); + assert.strictEqual(response.actionResponse.type, 'UPDATE_MESSAGE'); + assert.strictEqual(response.cardsV2.length, 1); + assert.strictEqual(response.cardsV2[0].cardId, 'userStoryCard'); + assert.ok(response.cardsV2[0].card instanceof UserStoryCard); + break; + case UserStoryCardType.LIST_MESSAGE: + assert.deepStrictEqual(response, { + text: 'handleMyUserStories', + type: 'UPDATE_MESSAGE', + }); + assert.strictEqual(test.mocks.chatApp.handleMyUserStories.callCount, 1); + break; + case UserStoryCardType.SINGLE_DIALOG: + assert.strictEqual(response.actionResponse.type, 'DIALOG'); + assert.deepStrictEqual(response.actionResponse.dialogAction.actionStatus, + { + statusCode: 'OK', + userFacingMessage: 'Saved.' + } + ); + assert.ok( + response.actionResponse.dialogAction.dialog.body + instanceof EditUserStoryCard); + break; + case UserStoryCardType.LIST_DIALOG: + assert.deepStrictEqual(response, { text: 'handleManageUserStories' }); + assert.strictEqual( + test.mocks.chatApp.handleManageUserStories.callCount, 1); + break; + default: + assert.deepStrictEqual(response, {}); + } +}; + +describe('AppActionHandler', function () { + describe('CANCEL_DIALOG event', function () { + it('should return action response', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + isDialogEvent: true, + dialogEventType: 'CANCEL_DIALOG', + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assert.deepStrictEqual(response, { + actionResponse: { + type: 'DIALOG', + dialogAction: { + actionStatus: 'OK' + } + } + }); + }); + }); + + describe('myUserStories function', function () { + it('should call app.handleMyUserStories()', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'myUserStories' + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assert.deepStrictEqual(response, { text: 'handleMyUserStories' }); + assert.strictEqual(test.mocks.chatApp.handleMyUserStories.callCount, 1); + }); + }); + + describe('manageUserStories function', function () { + it('should call app.handleManageUserStories()', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'manageUserStories' + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assert.deepStrictEqual(response, { text: 'handleManageUserStories' }); + assert.strictEqual( + test.mocks.chatApp.handleManageUserStories.callCount, 1); + }); + }); + + describe('cleanupUserStories function', function () { + it('should call app.handleCleanupUserStories()', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'cleanupUserStories' + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assert.deepStrictEqual(response, { text: 'handleCleanupUserStories' }); + assert.strictEqual( + test.mocks.chatApp.handleCleanupUserStories.callCount, 1); + }); + }); + + describe('editUserStory function', function () { + it('should get user story and return response', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'editUserStory', + parameters: { + id: USER_STORY_ID + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assert.strictEqual(response.actionResponse.type, 'DIALOG'); + assert.ok( + response.actionResponse.dialogAction.dialog.body + instanceof EditUserStoryCard); + assert.ok(test.mocks.userStoryService.getUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID)); + assert.ok(test.mocks.userService.getUser.calledOnceWith( + SPACE_NAME, USER_ID)); + }); + + for (const e of EXCEPTIONS) { + it(`should handle ${e.name}`, async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'editUserStory', + parameters: { + id: USER_STORY_ID + } + } + }; + test.mocks.userStoryService.getUserStory.throws(e); + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertExceptionResponseIsValid(response, e); + }); + } + }); + + describe('assignUserStory function', function () { + for (const cardType in UserStoryCardType) { + context(`Card type: ${cardType}`, function () { + it('should update user story and return response', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'assignUserStory', + parameters: { + id: USER_STORY_ID, + cardType: cardType, + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertResponseIsValid(test, response, cardType, /* updated= */ true); + assert.ok(test.mocks.userService.createOrUpdateUser.calledOnceWith( + SPACE_NAME, USER)); + assert.ok(test.mocks.userStoryService.assignUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID, USER_NAME)); + }); + }); + } + + for (const e of EXCEPTIONS) { + it(`should handle ${e.name}`, async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'assignUserStory', + parameters: { + id: USER_STORY_ID + } + } + }; + test.mocks.userStoryService.assignUserStory.throws(e); + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertExceptionResponseIsValid(response, e); + }); + } + }); + + describe('startUserStory function', function () { + for (const cardType in UserStoryCardType) { + context(`Card type: ${cardType}`, function () { + it('should update user story and return response', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'startUserStory', + parameters: { + id: USER_STORY_ID, + cardType: cardType, + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertResponseIsValid(test, response, cardType, /* updated= */ true); + assert.ok(test.mocks.userStoryService.startUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID)); + assert.ok(test.mocks.userService.getUser.calledOnceWith( + SPACE_NAME, USER_NAME)); + }); + }); + } + + for (const e of EXCEPTIONS) { + it(`should handle ${e.name}`, async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'startUserStory', + parameters: { + id: USER_STORY_ID + } + } + }; + test.mocks.userStoryService.startUserStory.throws(e); + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertExceptionResponseIsValid(response, e); + }); + } + }); + + describe('completeUserStory function', function () { + for (const cardType in UserStoryCardType) { + context(`Card type: ${cardType}`, function () { + it('should update user story and return response', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'completeUserStory', + parameters: { + id: USER_STORY_ID, + cardType: cardType, + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertResponseIsValid(test, response, cardType, /* updated= */ true); + assert.ok(test.mocks.userStoryService.completeUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID)); + assert.ok(test.mocks.userService.getUser.calledOnceWith( + SPACE_NAME, USER_NAME)); + }); + }); + } + + for (const e of EXCEPTIONS) { + it(`should handle ${e.name}`, async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'completeUserStory', + parameters: { + id: USER_STORY_ID + } + } + }; + test.mocks.userStoryService.completeUserStory.throws(e); + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertExceptionResponseIsValid(response, e); + }); + } + }); + + describe('cancelEditUserStory function', function () { + it('should call app.handleManageUserStories()', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'cancelEditUserStory' + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assert.deepStrictEqual(response, { text: 'handleManageUserStories' }); + assert.strictEqual( + test.mocks.chatApp.handleManageUserStories.callCount, 1); + }); + }); + + describe('saveUserStory function', function () { + for (const cardType in UserStoryCardType) { + context(`Card type: ${cardType}`, function () { + it('should update user story and return response', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'saveUserStory', + parameters: { + id: USER_STORY_ID, + cardType: cardType, + }, + formInputs: { + title: { + stringInputs: { + value: ['New Title'] + } + }, + description: { + stringInputs: { + value: ['New Description'] + } + }, + status: { + stringInputs: { + value: ['STARTED'] + } + }, + priority: { + stringInputs: { + value: ['Low'] + } + }, + size: { + stringInputs: { + value: ['Small'] + } + }, + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertResponseIsValid(test, response, cardType, /* updated= */ true); + assert.ok(test.mocks.userStoryService.updateUserStory.calledOnceWith( + SPACE_NAME, + USER_STORY_ID, + 'New Title', + 'New Description', + 'STARTED', + 'Low', + 'Small')); + assert.ok(test.mocks.userService.getUser.calledOnceWith( + SPACE_NAME, USER_NAME)); + }); + }); + } + + for (const e of EXCEPTIONS) { + it(`should handle ${e.name}`, async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'saveUserStory', + parameters: { + id: USER_STORY_ID + }, + formInputs: { + title: { + stringInputs: { + value: ['New Title'] + } + }, + description: { + stringInputs: { + value: ['New Description'] + } + }, + } + } + }; + test.mocks.userStoryService.updateUserStory.throws(e); + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertExceptionResponseIsValid(response, e); + }); + } + }); + + describe('refreshUserStory function', function () { + it('should get user story and return response', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'refreshUserStory', + parameters: { + id: USER_STORY_ID + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assert.strictEqual(response.actionResponse.type, 'UPDATE_MESSAGE'); + assert.strictEqual(response.cardsV2.length, 1); + assert.strictEqual(response.cardsV2[0].cardId, 'userStoryCard'); + assert.ok(response.cardsV2[0].card instanceof UserStoryCard); + assert.ok(test.mocks.userStoryService.getUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID)); + assert.ok(test.mocks.userService.getUser.calledOnceWith( + SPACE_NAME, USER_ID)); + }); + + for (const e of EXCEPTIONS) { + it(`should handle ${e.name}`, async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'refreshUserStory', + parameters: { + id: USER_STORY_ID + } + } + }; + test.mocks.userStoryService.getUserStory.throws(e); + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertExceptionResponseIsValid(response, e); + }); + } + }); + + describe('generateUserStoryDescription function', function () { + for (const cardType in UserStoryCardType) { + context(`Card type: ${cardType}`, function () { + it('should call AIP service and return response', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'generateUserStoryDescription', + parameters: { + id: USER_STORY_ID, + cardType: cardType, + assignee: USER_NAME, + }, + formInputs: { + title: { + stringInputs: { + value: ['New Title'] + } + }, + description: { + stringInputs: { + value: ['New Description'] + } + }, + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertResponseIsValid(test, response, cardType, /* updated= */ false); + assert.ok(test.mocks.aipService.generateDescription.calledOnceWith( + 'New Title')); + assert.ok(test.mocks.userService.getUser.calledOnceWith( + SPACE_NAME, USER_NAME)); + }); + + it('should not all AIP service if title is empty', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'generateUserStoryDescription', + parameters: { + id: USER_STORY_ID, + cardType: cardType, + }, + formInputs: { + title: { + stringInputs: { + value: [''] + } + }, + description: { + stringInputs: { + value: [''] + } + }, + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertResponseIsValid(test, response, cardType, /* updated= */ false); + assert.ok(test.mocks.aipService.generateDescription.notCalled); + }); + }); + } + + for (const e of EXCEPTIONS) { + it(`should handle ${e.name}`, async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'generateUserStoryDescription', + parameters: { + id: USER_STORY_ID + }, + formInputs: { + title: { + stringInputs: { + value: ['New Title'] + } + }, + description: { + stringInputs: { + value: ['New Description'] + } + }, + } + } + }; + test.mocks.aipService.generateDescription.throws(e); + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertExceptionResponseIsValid(response, e); + }); + } + }); + + describe('expandUserStoryDescription function', function () { + for (const cardType in UserStoryCardType) { + context(`Card type: ${cardType}`, function () { + it('should call AIP service and return response', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'expandUserStoryDescription', + parameters: { + id: USER_STORY_ID, + cardType: cardType, + assignee: USER_NAME, + }, + formInputs: { + title: { + stringInputs: { + value: ['New Title'] + } + }, + description: { + stringInputs: { + value: ['New Description'] + } + }, + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertResponseIsValid(test, response, cardType, /* updated= */ false); + assert.ok(test.mocks.aipService.expandDescription.calledOnceWith( + 'New Description')); + assert.ok(test.mocks.userService.getUser.calledOnceWith( + SPACE_NAME, USER_NAME)); + }); + + it('should not all AIP service if description is empty', + async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'expandUserStoryDescription', + parameters: { + id: USER_STORY_ID, + cardType: cardType, + }, + formInputs: { + title: { + stringInputs: { + value: [''] + } + }, + description: { + stringInputs: { + value: [''] + } + }, + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertResponseIsValid(test, response, cardType, /* updated= */ false); + assert.ok(test.mocks.aipService.expandDescription.notCalled); + }); + }); + } + + for (const e of EXCEPTIONS) { + it(`should handle ${e.name}`, async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'expandUserStoryDescription', + parameters: { + id: USER_STORY_ID + }, + formInputs: { + title: { + stringInputs: { + value: ['New Title'] + } + }, + description: { + stringInputs: { + value: ['New Description'] + } + }, + } + } + }; + test.mocks.aipService.expandDescription.throws(e); + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertExceptionResponseIsValid(response, e); + }); + } + }); + + describe('correctUserStoryDescriptionGrammar function', function () { + for (const cardType in UserStoryCardType) { + context(`Card type: ${cardType}`, function () { + it('should call AIP service and return response', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'correctUserStoryDescriptionGrammar', + parameters: { + id: USER_STORY_ID, + cardType: cardType, + assignee: USER_NAME, + }, + formInputs: { + title: { + stringInputs: { + value: ['New Title'] + } + }, + description: { + stringInputs: { + value: ['New Description'] + } + }, + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertResponseIsValid(test, response, cardType, /* updated= */ false); + assert.ok(test.mocks.aipService.correctDescription.calledOnceWith( + 'New Description')); + assert.ok(test.mocks.userService.getUser.calledOnceWith( + SPACE_NAME, USER_NAME)); + }); + + it('should not all AIP service if description is empty', + async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'correctUserStoryDescriptionGrammar', + parameters: { + id: USER_STORY_ID, + cardType: cardType, + }, + formInputs: { + title: { + stringInputs: { + value: [''] + } + }, + description: { + stringInputs: { + value: [''] + } + }, + } + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertResponseIsValid(test, response, cardType, /* updated= */ false); + assert.ok(test.mocks.aipService.correctDescription.notCalled); + }); + }); + } + + for (const e of EXCEPTIONS) { + it(`should handle ${e.name}`, async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'correctUserStoryDescriptionGrammar', + parameters: { + id: USER_STORY_ID + }, + formInputs: { + title: { + stringInputs: { + value: ['New Title'] + } + }, + description: { + stringInputs: { + value: ['New Description'] + } + }, + } + } + }; + test.mocks.aipService.correctDescription.throws(e); + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assertExceptionResponseIsValid(response, e); + }); + } + }); + + describe('Invalid function', function () { + for (const cardType in UserStoryCardType) { + context(`Card type: ${cardType}`, function () { + it('should return error message', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + const event = { + ...EVENT, + common: { + invokedFunction: 'invalid', + parameters: { + id: USER_STORY_ID, + }, + } + }; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + if (this.cardType === UserStoryCardType.SINGLE_DIALOG + || this.cardType === UserStoryCardType.LIST_DIALOG) { + assert.deepStrictEqual(response, { + actionResponse: { + type: 'DIALOG', + dialogAction: { + actionStatus: { + statusCode: 'INVALID_ARGUMENT', + userFacingMessage: '⚠️ Unrecognized action.' + } + } + } + }); + } else { + assert.deepStrictEqual(response, { + text: '⚠️ Unrecognized action.' + }); + } + }); + }); + } + }); +}); diff --git a/node/project-management-app/test/controllers/app.test.js b/node/project-management-app/test/controllers/app.test.js new file mode 100644 index 00000000..eb8de09a --- /dev/null +++ b/node/project-management-app/test/controllers/app.test.js @@ -0,0 +1,423 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); +const { NotFoundException } = require('../../model/exceptions'); +const { UserStory, Status } = require('../../model/user-story'); +const { User } = require('../../model/user'); +const { HelpCard } = require('../../views/help-card'); +const { UserStoryCard } = require('../../views/user-story-card'); +const { UserStoryListCard } = require('../../views/user-story-list-card'); + +const USER_STORY_ID = 'user-story-id'; +const USER_STORY = new UserStory(USER_STORY_ID, { + title: 'Title', + description: 'Description', + assignee: '123', + status: Status.OPEN, +}); +const SPACE_NAME = 'spaces/ABC'; +const SPACE_DISPLAY_NAME = 'The Space'; +const USER_NAME = 'users/123'; +const USER_ID = '123'; +const USER = new User(USER_ID, 'Display Name', 'avatar.jpg'); +const EVENT = { + type: 'MESSAGE', + space: { + name: SPACE_NAME, + displayName: SPACE_DISPLAY_NAME, + }, + user: { + name: USER_NAME, + }, +} + +const getTestEnvironment = () => { + const aipServiceMock = { + generateDescription: sinon.stub().returns('Description'), + }; + const spaceServiceMock = { + createSpace: sinon.stub(), + deleteSpace: sinon.stub(), + }; + const userServiceMock = { + getUser: sinon.stub().returns(USER), + getUsers: sinon.stub().returns({ '123': USER }), + }; + const userStoryServiceMock = { + getUserStory: sinon.stub().returns(USER_STORY), + createUserStory: sinon.stub().returns(USER_STORY), + listAllUserStories: sinon.stub().returns([USER_STORY]), + listUserStoriesByUser: sinon.stub().returns([USER_STORY]), + cleanupUserStories: sinon.stub(), + }; + const appActionHandlerMock = { + execute: sinon.stub().returns({ text: 'Card clicked.' }), + }; + + return { + App: proxyquire('../../controllers/app', { + '../services/aip-service': { + AIPService: aipServiceMock, + }, + '../services/space-service': { + SpaceService: spaceServiceMock, + }, + '../services/user-service': { + UserService: userServiceMock, + }, + '../services/user-story-service': { + UserStoryService: userStoryServiceMock, + }, + './app-action-handler': appActionHandlerMock, + }), + mocks: { + aipService: aipServiceMock, + spaceService: spaceServiceMock, + userService: userServiceMock, + userStoryService: userStoryServiceMock, + appActionHandler: appActionHandlerMock, + }, + }; +}; + +describe('App', function () { + describe('ADDED_TO_SPACE event', function () { + it('should create space and return welcome message', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { ...EVENT, type: 'ADDED_TO_SPACE' }; + + const response = await App.execute(event); + + assert.deepStrictEqual(response, { + text: 'Thank you for adding the Project Manager app.' + + ' Message the app for a list of available commands.' + }); + assert.ok(test.mocks.spaceService.createSpace.calledOnceWith( + SPACE_NAME, SPACE_DISPLAY_NAME)); + }); + }); + + describe('REMOVED_FROM_SPACE event', function () { + it('should delete space and return empty response', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { ...EVENT, type: 'REMOVED_FROM_SPACE' }; + + const response = await App.execute(event); + + assert.deepStrictEqual(response, {}); + assert.ok(test.mocks.spaceService.deleteSpace.calledOnceWith(SPACE_NAME)); + }); + }); + + describe('CARD_CLICKED event', function () { + it('should execute AppActionHandler', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { ...EVENT, type: 'CARD_CLICKED' }; + + const response = await App.execute(event); + + assert.deepStrictEqual(response, { text: 'Card clicked.' }); + assert.ok(test.mocks.appActionHandler.execute.calledOnceWith(event)); + }); + }); + + describe('MESSAGE event with /createUserStory command', function () { + it('should create user story and return response', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { + ...EVENT, + message: { + argumentText: 'Title', + slashCommand: { + commandId: 1 + } + } + }; + + const response = await App.execute(event); + + assert.strictEqual( + response.text, `<${USER_NAME}> created a user story.`); + assert.strictEqual(response.cardsV2.length, 1); + assert.strictEqual(response.cardsV2[0].cardId, 'userStoryCard'); + assert.ok(response.cardsV2[0].card instanceof UserStoryCard); + assert.ok( + test.mocks.aipService.generateDescription.calledOnceWith('Title')); + assert.ok( + test.mocks.userStoryService.createUserStory.calledOnceWith( + SPACE_NAME, 'Title', 'Description')); + }); + + it('should return error message if title is empty', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { + ...EVENT, + message: { + argumentText: '', + slashCommand: { + commandId: 1 + } + } + }; + + const response = await App.execute(event); + + assert.deepStrictEqual(response, { + text: 'Title is required.' + + ' Include a title in the command: */createUserStory* _title_' + }); + assert.ok(test.mocks.userStoryService.createUserStory.notCalled); + }); + }); + + describe('MESSAGE event with /myUserStories command', function () { + it('should list user stories and return response', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { + ...EVENT, + message: { + slashCommand: { + commandId: 2 + } + } + }; + + const response = await App.execute(event); + + assert.strictEqual(response.cardsV2.length, 1); + assert.strictEqual(response.cardsV2[0].cardId, 'userStoriesCard'); + assert.ok(response.cardsV2[0].card instanceof UserStoryListCard); + assert.ok( + test.mocks.userStoryService.listUserStoriesByUser.calledOnceWith( + SPACE_NAME, USER_NAME)); + assert.ok( + test.mocks.userService.getUsers.calledOnceWith(SPACE_NAME, [USER_ID])); + }); + }); + + describe('MESSAGE event with /userStory command', function () { + it('should get user story and return response', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { + ...EVENT, + message: { + argumentText: USER_STORY_ID, + slashCommand: { + commandId: 3 + } + } + }; + + const response = await App.execute(event); + + assert.strictEqual(response.cardsV2.length, 1); + assert.strictEqual(response.cardsV2[0].cardId, 'userStoryCard'); + assert.ok(response.cardsV2[0].card instanceof UserStoryCard); + assert.ok( + test.mocks.userStoryService.getUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID)); + assert.ok( + test.mocks.userService.getUser.calledOnceWith(SPACE_NAME, USER_ID)); + }); + + it('should return error message if story is not found', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { + ...EVENT, + message: { + argumentText: USER_STORY_ID, + slashCommand: { + commandId: 3 + } + } + }; + test.mocks.userStoryService.getUserStory.throws(new NotFoundException()); + + const response = await App.execute(event); + + assert.deepStrictEqual(response, { + text: `⚠️ User story ${USER_STORY_ID} not found.` + }); + assert.ok( + test.mocks.userStoryService.getUserStory.calledOnceWith( + SPACE_NAME, USER_STORY_ID)); + }); + + it('should return error message if title is empty', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { + ...EVENT, + message: { + argumentText: '', + slashCommand: { + commandId: 3 + } + } + }; + + const response = await App.execute(event); + + assert.deepStrictEqual(response, { + text: 'User story ID is required.' + + ' Include an ID in the command: */userStory* _id_' + }); + assert.ok(test.mocks.userStoryService.getUserStory.notCalled); + }); + }); + + describe('MESSAGE event with /manageUserStories command', function () { + it('should list user stories and return response', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { + ...EVENT, + message: { + slashCommand: { + commandId: 4 + } + } + }; + + const response = await App.execute(event); + + assert.strictEqual(response.actionResponse.type, 'DIALOG'); + assert.ok( + response.actionResponse.dialogAction.dialog.body + instanceof UserStoryListCard); + assert.ok( + test.mocks.userStoryService.listAllUserStories.calledOnceWith( + SPACE_NAME)); + assert.ok( + test.mocks.userService.getUsers.calledOnceWith(SPACE_NAME, [USER_ID])); + }); + }); + + describe('MESSAGE event with /cleanupUserStories command', function () { + it('should delete user stories and return response', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { + ...EVENT, + message: { + slashCommand: { + commandId: 5 + } + } + }; + + const response = await App.execute(event); + + assert.deepStrictEqual(response, { + text: `<${USER_NAME}> deleted all the user stories.` + }); + assert.ok( + test.mocks.userStoryService.cleanupUserStories.calledOnceWith( + SPACE_NAME)); + }); + }); + + describe('MESSAGE event with invalid slash command', function () { + it('should return error message', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { + ...EVENT, + message: { + slashCommand: { + commandId: 99 + } + } + }; + + const response = await App.execute(event); + + assert.deepStrictEqual(response, { + text: '⚠️ Unrecognized command.' + }); + }); + }); + + describe('MESSAGE event with user stories argument text', function () { + const acceptedArguments = [ + 'user stories', + 'USER STORIES', + 'User Stories', + ' user stories ', + 'userstories', + 'USERSTORIES', + 'UserStories', + ' userstories ', + ]; + for (const argumentText of acceptedArguments) { + context(`with argument text: ${argumentText}`, function () { + it('should list user stories and return response', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { + ...EVENT, + message: { + text: argumentText, + argumentText: argumentText, + } + }; + + const response = await App.execute(event); + + assert.strictEqual(response.cardsV2.length, 1); + assert.strictEqual(response.cardsV2[0].cardId, 'userStoriesCard'); + assert.ok(response.cardsV2[0].card instanceof UserStoryListCard); + assert.ok( + test.mocks.userStoryService.listUserStoriesByUser.calledOnceWith( + SPACE_NAME, USER_NAME)); + assert.ok( + test.mocks.userService.getUsers.calledOnceWith(SPACE_NAME, [USER_ID])); + }); + }); + } + }); + + describe('MESSAGE event without any commands', function () { + it('should return help card', async function () { + const test = getTestEnvironment(); + const App = test.App; + const event = { + ...EVENT, + message: { + text: 'Any', + argumentText: 'Any', + } + }; + + const response = await App.execute(event); + + assert.strictEqual(response.cardsV2.length, 1); + assert.strictEqual(response.cardsV2[0].cardId, 'helpCard'); + assert.ok(response.cardsV2[0].card instanceof HelpCard); + }); + }); +}); From 6f432e7071ec58ca7aacce9b1f4817de91946550 Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Thu, 7 Dec 2023 19:18:50 +0000 Subject: [PATCH 09/12] test: Add unit test for Cloud Function. --- node/project-management-app/env.js | 3 +- node/project-management-app/index.js | 10 +- .../project-management-app/test/index.test.js | 104 ++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 node/project-management-app/test/index.test.js diff --git a/node/project-management-app/env.js b/node/project-management-app/env.js index 2a49837a..8536dd65 100644 --- a/node/project-management-app/env.js +++ b/node/project-management-app/env.js @@ -19,8 +19,9 @@ * Project environment settings. */ const env = { - project: 'developer-productivity-402319', //'YOUR_PROJECT_ID', + project: 'project-id', // replace with your GCP project ID location: 'us-central1', // replace with your GCP project location + logging: true, // whether to log the request & response on each function call }; exports.env = env; diff --git a/node/project-management-app/index.js b/node/project-management-app/index.js index 6b2dcb36..2c9fe95e 100644 --- a/node/project-management-app/index.js +++ b/node/project-management-app/index.js @@ -17,19 +17,25 @@ const functions = require('@google-cloud/functions-framework'); const App = require('./controllers/app'); +const { env } = require('./env.js'); functions.http('projectManagementChatApp', async (req, res) => { if (req.method === 'GET' || !req.body.message) { res .status(400) .send('This function is meant to be used in a Google Chat app.'); + return; } const event = req.body; - console.log(JSON.stringify({ message: 'Request received', event })); + if (env.logging) { + console.log(JSON.stringify({ message: 'Request received', event })); + } const responseMessage = await App.execute(event); res.json(responseMessage); - console.log(JSON.stringify({ message: 'Response sent', responseMessage })); + if (env.logging) { + console.log(JSON.stringify({ message: 'Response sent', responseMessage })); + } }); // [END chat_project_management_index] diff --git a/node/project-management-app/test/index.test.js b/node/project-management-app/test/index.test.js new file mode 100644 index 00000000..444d4d71 --- /dev/null +++ b/node/project-management-app/test/index.test.js @@ -0,0 +1,104 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { getFunction } = require('@google-cloud/functions-framework/testing'); +const assert = require('assert'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +const getTestEnvironment = () => { + const app = { + execute: sinon.stub().returns({ text: 'App executed.' }), + }; + const res = { + send: sinon.stub().returnsThis(), + json: sinon.stub().returnsThis(), + status: sinon.stub().returnsThis(), + }; + + return { + index: proxyquire('../index', { + './controllers/app': app, + './env.js': { + env: { + logging: false, // disable request/response logging during tests + }, + } + }), + mocks: { app, res }, + }; +}; + +describe('projectManagementChatApp', function () { + it('should execute app and send response', async function () { + const test = getTestEnvironment(); + const projectManagementChatApp = getFunction('projectManagementChatApp'); + const req = { + method: 'POST', + body: { + type: 'MESSAGE', + message: {}, + } + }; + + await projectManagementChatApp(req, test.mocks.res); + + assert.ok(test.mocks.app.execute.calledOnceWith({ + type: 'MESSAGE', + message: {} + })) + assert.ok(test.mocks.res.json.calledOnceWith({ + text: 'App executed.' + })); + }); + + it('should send status 400 if event message is undefined', async function () { + const test = getTestEnvironment(); + const projectManagementChatApp = getFunction('projectManagementChatApp'); + const req = { + method: 'POST', + body: { + type: 'MESSAGE', + } + }; + + await projectManagementChatApp(req, test.mocks.res); + + assert.ok(test.mocks.app.execute.notCalled); + assert.ok(test.mocks.res.status.calledOnceWith(400)); + assert.ok(test.mocks.res.send.calledOnceWith( + 'This function is meant to be used in a Google Chat app.')); + }); + + it('should send status 400 if request method is GET', async function () { + const test = getTestEnvironment(); + const projectManagementChatApp = getFunction('projectManagementChatApp'); + const req = { + method: 'GET', + body: { + type: 'MESSAGE', + message: {}, + } + }; + + await projectManagementChatApp(req, test.mocks.res); + + assert.ok(test.mocks.app.execute.notCalled); + assert.ok(test.mocks.res.status.calledOnceWith(400)); + assert.ok(test.mocks.res.send.calledOnceWith( + 'This function is meant to be used in a Google Chat app.')); + }); +}); From 07ecfa878c20499c337ade09e52d69cf74c32207 Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Thu, 7 Dec 2023 19:44:56 +0000 Subject: [PATCH 10/12] Update README for project management sample. --- node/project-management-app/README.md | 29 ++++++++++++--------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/node/project-management-app/README.md b/node/project-management-app/README.md index 4b4101ae..0ec7a4ac 100644 --- a/node/project-management-app/README.md +++ b/node/project-management-app/README.md @@ -1,23 +1,20 @@ # Google Chat Project Management app -This code sample creates a Google Chat app that helps users manage user stories -in a software development project. +This code sample shows how to make a Google Chat app that a team can use to +manage projects in real time. -This example creates a Google Cloud Function using a Node.js runtime, which -responds to invocation events from Google Chat. +The Chat app is implemented as a Google Cloud Function using a Node.js runtime, +which responds to +[interaction events](https://developers.google.com/chat/api/guides/message-formats/events) +from Google Chat. + +It uses [Vertex AI](https://cloud.google.com/vertex-ai) to help teams write user +stories (which represent features of a software system from the point of view of +a user for the team to develop) and persists the stories in a +[Firestore](https://firebase.google.com/docs/firestore) database. ## Tutorial -For detailed implementation instructions, follow the -[Project Management Tutorial](https://developers.google.com/chat/tutorial-project-management) +For detailed instructions to deploy and run this sample, follow the tutorial +[Manage projects with Google Chat, Vertex AI, and Cloud Firestore](https://developers.google.com/chat/tutorial-project-management) in the Google Chat developer documentation. - -## Run the sample - -To run this sample, you need a Google Cloud -[project](https://cloud.google.com/resource-manager/docs/cloud-platform-resource-hierarchy#projects) -in a Google Workspace account with billing enabled, required APIs turned on, and -authentication set up. You also need to deploy this sample code to a Cloud -Function and configure a Google Chat app using the Cloud Function URL as the -connection endpoint. Once published, add the app to a space and use of the -slash commands to interact with it. From a435a44c640f100150e8b606caa2451629c404b6 Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Tue, 12 Dec 2023 19:17:27 +0000 Subject: [PATCH 11/12] feat: Project Management Node sample PR review --- node/project-management-app/.gcloudignore | 1 + node/project-management-app/README.md | 10 +- .../controllers/app-action-handler.js | 341 ++++++++-------- .../project-management-app/controllers/app.js | 71 ++-- node/project-management-app/env.js | 3 - node/project-management-app/index.js | 13 +- .../model/exceptions.js | 7 +- .../model/user-story.js | 11 +- node/project-management-app/model/user.js | 9 +- node/project-management-app/package.json | 4 - .../services/aip-service.js | 13 +- .../services/firestore-service.js | 26 +- .../services/space-service.js | 8 +- .../services/user-service.js | 8 +- .../services/user-story-service.js | 11 +- .../controllers/app-action-handler.test.js | 365 +++++------------- .../test/controllers/app.test.js | 6 +- .../views/edit-user-story-card.js | 11 +- .../project-management-app/views/help-card.js | 7 +- .../views/user-story-card.js | 11 +- .../views/user-story-list-card.js | 11 +- .../widgets/user-story-assignee-widget.js | 10 +- .../widgets/user-story-buttons-widget.js | 10 +- .../views/widgets/user-story-card-type.js | 3 - .../widgets/user-story-columns-widget.js | 10 +- .../views/widgets/user-story-row-widget.js | 10 +- 26 files changed, 462 insertions(+), 528 deletions(-) diff --git a/node/project-management-app/.gcloudignore b/node/project-management-app/.gcloudignore index fb252d65..03c6cd8a 100644 --- a/node/project-management-app/.gcloudignore +++ b/node/project-management-app/.gcloudignore @@ -14,3 +14,4 @@ .gitignore node_modules +test diff --git a/node/project-management-app/README.md b/node/project-management-app/README.md index 0ec7a4ac..1d5f4706 100644 --- a/node/project-management-app/README.md +++ b/node/project-management-app/README.md @@ -11,10 +11,14 @@ from Google Chat. It uses [Vertex AI](https://cloud.google.com/vertex-ai) to help teams write user stories (which represent features of a software system from the point of view of a user for the team to develop) and persists the stories in a -[Firestore](https://firebase.google.com/docs/firestore) database. +[Firestore](https://cloud.google.com/firestore/docs) database. ## Tutorial -For detailed instructions to deploy and run this sample, follow the tutorial -[Manage projects with Google Chat, Vertex AI, and Cloud Firestore](https://developers.google.com/chat/tutorial-project-management) +For detailed instructions to deploy and run this sample, follow the +[dedicated tutorial](https://developers.google.com/chat/tutorial-project-management) in the Google Chat developer documentation. + +## Scripts + +- `npm run test` : Executes all the unit tests. diff --git a/node/project-management-app/controllers/app-action-handler.js b/node/project-management-app/controllers/app-action-handler.js index 0880651b..b5453826 100644 --- a/node/project-management-app/controllers/app-action-handler.js +++ b/node/project-management-app/controllers/app-action-handler.js @@ -13,7 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_app_action_handler] + +/** + * @fileoverview Application logic to handle card click + * [Chat events](https://developers.devsite.corp.google.com/chat/api/guides/message-formats/events#card-clicked). + */ const { UserStory } = require('../model/user-story'); const { User } = require('../model/user'); @@ -24,6 +28,7 @@ const { EditUserStoryCard } = require('../views/edit-user-story-card'); const { UserStoryCard } = require('../views/user-story-card'); const { UserStoryCardType } = require('../views/widgets/user-story-card-type'); +/** The prefix used by the Google Chat API in the User resource name. */ const USERS_PREFIX = 'users/'; /** @@ -42,29 +47,38 @@ const AIAction = { /** * Handles exceptions thrown by the UserStoryService. * @param {!Error} e An exception thrown by the UserStoryService. - * @return {Object} A dialog status message with a user facing error message. + * @return {Object} A + * [dialog](https://developers.google.com/chat/how-tos/dialogs) status message + * with a user facing error message. * @throws {Error} If the exception is not a recognized type from the app. */ -function handleException(e) { +function handleException(e, isDialogEvent) { if (e.name === 'NotFoundException' || e.name === 'BadRequestException') { - return { - actionResponse: { - type: 'DIALOG', - dialogAction: { - actionStatus: { - statusCode: e.statusCode, - userFacingMessage: e.message + if (isDialogEvent) { + return { + actionResponse: { + type: 'DIALOG', + dialogAction: { + actionStatus: { + statusCode: e.statusCode, + userFacingMessage: e.message + } } } - } + }; } + return { + text: `⚠️ ${e.message}` + }; } else { throw e; } } /** - * Chat application handler for card actions. + * Chat application handler for + * [card](https://developers.google.com/chat/api/guides/v1/messages/create#create) + * actions. */ class ChatAppActionHandler { /** @@ -84,8 +98,11 @@ class ChatAppActionHandler { } /** - * Executes the handler for a card action and returns a message as a response. - * @return {Promise} A message to post back to the DM or space. + * Executes the handler for a card + * [action](https://developers.google.com/chat/ui/read-form-data) and returns + * a [message](https://developers.google.com/chat/messages-overview) as a + * response. + * @return {Promise} A message to post back to the space. */ async execute() { if (this.event.isDialogEvent @@ -99,6 +116,19 @@ class ChatAppActionHandler { } }; } + try { + const response = await this.handleInvokedFunction(); + return response; + } catch (e) { + return handleException(e, this.event.isDialogEvent); + } + } + + /** + * Handles card actions for invoked functions. + * @return {Promise} A message to post back to the space. + */ + async handleInvokedFunction() { switch (this.event.common.invokedFunction) { case 'myUserStories': return this.app.handleMyUserStories(); @@ -127,22 +157,25 @@ class ChatAppActionHandler { case 'correctUserStoryDescriptionGrammar': return this.handleUserStoryAIAction(AIAction.GRAMMAR); default: - if (this.cardType === UserStoryCardType.SINGLE_DIALOG - || this.cardType === UserStoryCardType.LIST_DIALOG) { - return { - actionResponse: { - type: 'DIALOG', - dialogAction: { - actionStatus: { - statusCode: 'INVALID_ARGUMENT', - userFacingMessage: '⚠️ Unrecognized action.' - } - } + break; + } + // If the switch above did not return anything, the provided function name + // was not recognized. + if (this.cardType === UserStoryCardType.SINGLE_DIALOG + || this.cardType === UserStoryCardType.LIST_DIALOG) { + return { + actionResponse: { + type: 'DIALOG', + dialogAction: { + actionStatus: { + statusCode: 'INVALID_ARGUMENT', + userFacingMessage: '⚠️ Unrecognized action.' } } } - return { text: '⚠️ Unrecognized action.' }; + } } + return { text: '⚠️ Unrecognized action.' }; } /** @@ -150,31 +183,27 @@ class ChatAppActionHandler { * @return {Promise} A message to open the user story dialog. */ async handleEditUserStory() { - try { - const userStory = - await UserStoryService.getUserStory(this.spaceName, this.userStoryId); - const user = userStory.data.assignee - ? await UserService.getUser( - this.spaceName, userStory.data.assignee.replace(USERS_PREFIX, '')) - : undefined; - return { - actionResponse: { - type: 'DIALOG', - dialogAction: { - dialog: { - body: new EditUserStoryCard(userStory, user) - } + const userStory = + await UserStoryService.getUserStory(this.spaceName, this.userStoryId); + const user = userStory.data.assignee + ? await UserService.getUser( + this.spaceName, userStory.data.assignee.replace(USERS_PREFIX, '')) + : undefined; + return { + actionResponse: { + type: 'DIALOG', + dialogAction: { + dialog: { + body: new EditUserStoryCard(userStory, user) } } - }; - } catch (e) { - return handleException(e); - } + } + }; } /** * Handles the assign user story command. - * @return {Promise} A message to post back to the DM or space. + * @return {Promise} A message to post back to the space. */ async handleAssignUserStory() { // Save the user display name and avatar to storage so we can display them @@ -183,51 +212,39 @@ class ChatAppActionHandler { this.userName.replace(USERS_PREFIX, ''), this.event.user.displayName, this.event.user.avatarUrl); - try { - await UserService.createOrUpdateUser(this.spaceName, user); - // Assign the user story. - const userStory = - await UserStoryService.assignUserStory( - this.spaceName, this.userStoryId, this.userName); - return this.buildResponse(userStory, user, /* updated= */ true); - } catch (e) { - return handleException(e); - } + await UserService.createOrUpdateUser(this.spaceName, user); + // Assign the user story. + const userStory = + await UserStoryService.assignUserStory( + this.spaceName, this.userStoryId, this.userName); + return this.buildResponse(userStory, /* updated= */ true, user); } /** * Handles the start user story command. - * @return {Promise} A message to post back to the DM or space. + * @return {Promise} A message to post back to the space. */ async handleStartUserStory() { - try { - const userStory = - await UserStoryService.startUserStory(this.spaceName, this.userStoryId); - const user = userStory.data.assignee - ? await UserService.getUser(this.spaceName, userStory.data.assignee) - : undefined; - return this.buildResponse(userStory, user, /* updated= */ true); - } catch (e) { - return handleException(e); - } + const userStory = + await UserStoryService.startUserStory(this.spaceName, this.userStoryId); + const user = userStory.data.assignee + ? await UserService.getUser(this.spaceName, userStory.data.assignee) + : undefined; + return this.buildResponse(userStory, /* updated= */ true, user); } /** * Handles the complete user story command. - * @return {Promise} A message to post back to the DM or space. + * @return {Promise} A message to post back to the space. */ async handleCompleteUserStory() { - try { - const userStory = - await UserStoryService.completeUserStory( - this.spaceName, this.userStoryId); - const user = userStory.data.assignee - ? await UserService.getUser(this.spaceName, userStory.data.assignee) - : undefined; - return this.buildResponse(userStory, user, /* updated= */ true); - } catch (e) { - return handleException(e); - } + const userStory = + await UserStoryService.completeUserStory( + this.spaceName, this.userStoryId); + const user = userStory.data.assignee + ? await UserService.getUser(this.spaceName, userStory.data.assignee) + : undefined; + return this.buildResponse(userStory, /* updated= */ true, user); } /** @@ -252,49 +269,41 @@ class ChatAppActionHandler { ? formInputs.priority.stringInputs.value[0] : ''; const size = formInputs.size ? formInputs.size.stringInputs.value[0] : ''; - try { - const userStory = - await UserStoryService.updateUserStory( - this.spaceName, - this.userStoryId, - title, - description, - status, - priority, - size); - const user = userStory.data.assignee - ? await UserService.getUser(this.spaceName, userStory.data.assignee) - : undefined; - return this.buildResponse(userStory, user, /* updated= */ true); - } catch (e) { - return handleException(e); - } + const userStory = + await UserStoryService.updateUserStory( + this.spaceName, + this.userStoryId, + title, + description, + status, + priority, + size); + const user = userStory.data.assignee + ? await UserService.getUser(this.spaceName, userStory.data.assignee) + : undefined; + return this.buildResponse(userStory, /* updated= */ true, user); } /** * Handles the refresh user story command. - * @return {Promise} A message to post back to the DM or space. + * @return {Promise} A message to post back to the space. */ async handleRefreshUserStory() { - try { - const userStory = - await UserStoryService.getUserStory(this.spaceName, this.userStoryId); - const user = userStory.data.assignee - ? await UserService.getUser( - this.spaceName, userStory.data.assignee.replace(USERS_PREFIX, '')) - : undefined; - return { - cardsV2: [{ - cardId: 'userStoryCard', - card: new UserStoryCard(userStory, user) - }], - actionResponse: { - type: 'UPDATE_MESSAGE' - } - }; - } catch (e) { - return handleException(e); - } + const userStory = + await UserStoryService.getUserStory(this.spaceName, this.userStoryId); + const user = userStory.data.assignee + ? await UserService.getUser( + this.spaceName, userStory.data.assignee.replace(USERS_PREFIX, '')) + : undefined; + return { + cardsV2: [{ + cardId: 'userStoryCard', + card: new UserStoryCard(userStory, user) + }], + actionResponse: { + type: 'UPDATE_MESSAGE' + } + }; } /** @@ -314,46 +323,42 @@ class ChatAppActionHandler { ? formInputs.size.stringInputs.value[0] : ''; const assignee = this.event.common.parameters ? this.event.common.parameters.assignee : undefined; - try { - switch (action) { - case AIAction.GENERATE: - if (title.trim().length === 0) { - description = ''; - } else { - description = await AIPService.generateDescription(title); - } - break; - case AIAction.EXPAND: - if (description.trim().length > 0) { - description = await AIPService.expandDescription(description); - } - break; - case AIAction.GRAMMAR: - if (description.trim().length > 0) { - description = await AIPService.correctDescription(description); - } - break; - default: - // Unrecognized action. - } - // Display the (potentially unsaved) current values of the fields from the - // dialog, not the values from the database. - const userStoryData = { - title, - description, - status, - priority, - size, - assignee, - }; - const userStory = new UserStory(this.userStoryId, userStoryData); - const user = assignee - ? await UserService.getUser(this.spaceName, assignee) - : undefined; - return this.buildResponse(userStory, user, /* updated= */ false); - } catch (e) { - return handleException(e); + switch (action) { + case AIAction.GENERATE: + if (title.trim().length === 0) { + description = ''; + } else { + description = await AIPService.generateDescription(title); + } + break; + case AIAction.EXPAND: + if (description.trim().length > 0) { + description = await AIPService.expandDescription(description); + } + break; + case AIAction.GRAMMAR: + if (description.trim().length > 0) { + description = await AIPService.correctDescription(description); + } + break; + default: + // Unrecognized action. } + // Display the (potentially unsaved) current values of the fields from the + // dialog, not the values from the database. + const userStoryData = { + title, + description, + status, + priority, + size, + assignee, + }; + const userStory = new UserStory(this.userStoryId, userStoryData); + const user = assignee + ? await UserService.getUser(this.spaceName, assignee) + : undefined; + return this.buildResponse(userStory, /* updated= */ false, user); } /** @@ -369,11 +374,11 @@ class ChatAppActionHandler { * - story list dialog: push a new dialog with an updated story list card * * @param {!UserStory} userStory The updated user story. - * @param {?User} user The user assigned to the user story. * @param {!boolean} updated Whether the user story was updated in storage. - * @return {Promise} A message to post back to the DM or space. + * @param {?User} user The user assigned to the user story. + * @return {Promise} A message to post back to the space. */ - async buildResponse(userStory, user, updated) { + async buildResponse(userStory, updated, user) { switch (this.cardType) { case UserStoryCardType.SINGLE_MESSAGE: return { @@ -410,7 +415,13 @@ class ChatAppActionHandler { case UserStoryCardType.LIST_DIALOG: return this.app.handleManageUserStories(); default: - return {}; + return { + text: updated ? 'User story updated.' : null, + cardsV2: [{ + cardId: 'userStoryCard', + card: new UserStoryCard(userStory, user) + }], + }; } } @@ -418,14 +429,18 @@ class ChatAppActionHandler { module.exports = { /** - * Executes the Chat app action handler and returns a message as a response. - * @param {!Object} event The event received from Google Chat. + * Executes the Chat app + * [action](https://developers.google.com/chat/ui/read-form-data) handler and + * returns a + * [message](https://developers.google.com/chat/messages-overview) as a + * response. + * @param {!Object} event The + * [event](https://developers.google.com/chat/api/guides/message-formats/events) + * received from Google Chat. * @param {!ChatApp} app The Chat app that is calling this action handler. - * @return {Promise} A message to post back to the DM or space. + * @return {Promise} A message to post back to the space. */ execute: async function (event, app) { return new ChatAppActionHandler(event, app).execute(); } }; - -// [END chat_project_management_app_action_handler] diff --git a/node/project-management-app/controllers/app.js b/node/project-management-app/controllers/app.js index d017967d..8572831b 100644 --- a/node/project-management-app/controllers/app.js +++ b/node/project-management-app/controllers/app.js @@ -13,9 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_app] + +/** + * @fileoverview The main application logic. Processes the + * [Chat event](https://developers.devsite.corp.google.com/chat/api/guides/message-formats/events#card-clicked). + * It handles Chat app mentions, slash commands, and calls the + * {@code AppActionHandler} to handle card click events. + */ const { Status } = require('../model/user-story'); +const { User } = require('../model/user'); const { AIPService } = require('../services/aip-service'); const { SpaceService } = require('../services/space-service'); const { UserService } = require('../services/user-service'); @@ -25,10 +32,12 @@ const { UserStoryCard } = require('../views/user-story-card'); const { UserStoryListCard } = require('../views/user-story-list-card'); const AppActionHandler = require('./app-action-handler'); +/** The prefix used by the Google Chat API in the User resource name. */ const USERS_PREFIX = 'users/'; /** - * Slash commands supported by the Chat app. + * [Slash commands](https://developers.google.com/chat/how-tos/slash-commands) + * supported by the Chat app. * @enum {number} */ const SlashCommand = { @@ -40,7 +49,8 @@ const SlashCommand = { } /** - * Google Chat event types. + * Google Chat + * [event types](https://developers.google.com/chat/api/guides/message-formats/events). * @enum {string} */ const EventType = { @@ -56,7 +66,9 @@ const EventType = { class ChatApp { /** * Instantiates the Chat app. - * @param {!Object} event The event received from Google Chat. + * @param {!Object} event The + * [event](https://developers.google.com/chat/api/guides/message-formats/events) + * received from Google Chat. */ constructor(event) { this.event = event; @@ -65,8 +77,10 @@ class ChatApp { } /** - * Executes the Chat app and returns a message as a response. - * @return {Promise} A message to post back to the DM or space. + * Executes the Chat app and returns a + * [message](https://developers.google.com/chat/messages-overview) as a + * response. + * @return {Promise} A message to post back to the space. */ async execute() { switch (this.event.type) { @@ -80,6 +94,11 @@ class ChatApp { if (this.event.message.slashCommand) { return this.handleSlashCommand(); } + // Respond to mentions with the text "user stories" or "userstories" + // by executing the command "My User Stories". We trim and lower case + // the argument text sent by the user in the message before the + // comparison, so we trigger the command regardless of the text's + // capitalizatino or whitespace. const argumentText = (this.event.message.argumentText || '').trim().toLowerCase(); if (argumentText === 'user stories' @@ -96,12 +115,12 @@ class ChatApp { /** * Handles the ADDED_TO_SPACE event by sending back a welcome text message. * It also adds the space to storage so it can later receive user stories. - * @return {Object} A welcome text message to post back to the DM or space. + * @return {Object} A welcome text message to post back to the space. */ async handleAddedToSpace() { await SpaceService.createSpace( this.spaceName, this.event.space.displayName); - const message = 'Thank you for adding the Project Manager app.' + + const message = 'Thank you for adding the Project Management app.' + ' Message the app for a list of available commands.'; return { text: message }; } @@ -116,7 +135,7 @@ class ChatApp { /** * Handles a slash command and returns a message as a response. - * @return {Promise} A message to post back to the DM or space. + * @return {Promise} A message to post back to the space. */ async handleSlashCommand() { switch (Number(this.event.message.slashCommand.commandId)) { @@ -137,7 +156,7 @@ class ChatApp { /** * Handles the create user story command. - * @return {Promise} A message to post back to the DM or space. + * @return {Promise} A message to post back to the space. */ async handleCreateUserStory() { const title = (this.event.message.argumentText || '').trim(); @@ -162,7 +181,7 @@ class ChatApp { /** * Handles the my user stories command. - * @return {Promise} A message to post back to the DM or space. + * @return {Promise} A message to post back to the space. */ async handleMyUserStories() { const userStories = @@ -170,11 +189,11 @@ class ChatApp { this.spaceName, this.userName); const openUserStories = userStories .filter((userStory) => userStory.data.status !== Status.COMPLETED); - // Obtain a unique list of users assigned to the fetched user stories. - const userIds = [...new Set(openUserStories - .filter((userStory) => !!userStory.data.assignee) - .map((userStory) => userStory.data.assignee))]; - const users = await UserService.getUsers(this.spaceName, userIds); + const user = new User( + this.userName.replace(USERS_PREFIX, ''), + this.event.user.displayName, + this.event.user.avatarUrl); + const users = { [user.id]: user }; const title = 'User Stories assigned to ' + this.event.user.displayName; return { cardsV2: [{ @@ -190,7 +209,7 @@ class ChatApp { /** * Handles the user story command. - * @return {Promise} A message to post back to the DM or space. + * @return {Promise} A message to post back to the space. */ async handleUserStory() { const id = (this.event.message.argumentText || '').trim(); @@ -224,7 +243,7 @@ class ChatApp { /** * Handles the manage user stories command. - * @return {Promise} A message to post back to the DM or space. + * @return {Promise} A message to post back to the space. */ async handleManageUserStories() { const userStories = @@ -252,7 +271,7 @@ class ChatApp { /** * Handles the clean up user stories command. - * @return {Promise} A message to post back to the DM or space. + * @return {Promise} A message to post back to the space. */ async handleCleanupUserStories() { await UserStoryService.cleanupUserStories(this.spaceName); @@ -261,7 +280,7 @@ class ChatApp { /** * Returns a help message with the list of available commands. - * @return {Object} A message to post back to the DM or space. + * @return {Object} A message to post back to the space. */ handleHelp() { return { @@ -276,13 +295,15 @@ class ChatApp { module.exports = { /** - * Executes the Chat app and returns a message as a response. - * @param {!Object} event The event received from Google Chat. - * @return {Promise} A message to post back to the DM or space. + * Executes the Chat app and returns a + * [message](https://developers.google.com/chat/messages-overview) as a + * response. + * @param {!Object} event The + * [event](https://developers.google.com/chat/api/guides/message-formats/events) + * received from Google Chat. + * @return {Promise} A message to post back to the space. */ execute: async function (event) { return new ChatApp(event).execute(); } }; - -// [END chat_project_management_app] diff --git a/node/project-management-app/env.js b/node/project-management-app/env.js index 8536dd65..936874e6 100644 --- a/node/project-management-app/env.js +++ b/node/project-management-app/env.js @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_env] /** * Project environment settings. @@ -25,5 +24,3 @@ const env = { }; exports.env = env; - -// [END chat_project_management_env] diff --git a/node/project-management-app/index.js b/node/project-management-app/index.js index 2c9fe95e..b3462681 100644 --- a/node/project-management-app/index.js +++ b/node/project-management-app/index.js @@ -13,12 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_index] +/** + * @fileoverview Definition of a Cloud Function that responds to interaction + * events from the Project Management Google Chat app. + */ + +/** [Cloud Functions](https://cloud.google.com/functions/docs) client library */ const functions = require('@google-cloud/functions-framework'); const App = require('./controllers/app'); const { env } = require('./env.js'); +/** + * Cloud Function that responds to interaction events from Google Chat. It uses + * the {@code App} object to handle the Project Management application logic. + */ functions.http('projectManagementChatApp', async (req, res) => { if (req.method === 'GET' || !req.body.message) { res @@ -37,5 +46,3 @@ functions.http('projectManagementChatApp', async (req, res) => { console.log(JSON.stringify({ message: 'Response sent', responseMessage })); } }); - -// [END chat_project_management_index] diff --git a/node/project-management-app/model/exceptions.js b/node/project-management-app/model/exceptions.js index 47fca32c..03dd59dd 100644 --- a/node/project-management-app/model/exceptions.js +++ b/node/project-management-app/model/exceptions.js @@ -13,7 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_exceptions] + +/** + * @fileoverview Exception types used internally by the application logic. + */ /** * A User Story is not found in storage. @@ -36,5 +39,3 @@ exports.BadRequestException = class extends Error { this.statusCode = 'INVALID_ARGUMENT'; } } - -// [END chat_project_management_exceptions] diff --git a/node/project-management-app/model/user-story.js b/node/project-management-app/model/user-story.js index 8a27e273..cf220579 100644 --- a/node/project-management-app/model/user-story.js +++ b/node/project-management-app/model/user-story.js @@ -13,7 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_user_story] + +/** + * @fileoverview Definition of classes and enums that the application services + * use to store and pass user story data between functions. They set the data + * model for the Firestore database. + */ /** * User story statuses. @@ -55,7 +60,7 @@ exports.Priority = { }; /** - * User story T-shirt sizes. + * User story sizes. * @enum {string} */ exports.Size = { @@ -100,5 +105,3 @@ exports.UserStory = class { this.data = data; } } - -// [END chat_project_management_user_story] diff --git a/node/project-management-app/model/user.js b/node/project-management-app/model/user.js index a865ceb8..34f628ad 100644 --- a/node/project-management-app/model/user.js +++ b/node/project-management-app/model/user.js @@ -13,7 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_user] + +/** + * @fileoverview Definition of classes and enums that the application services + * use to store and pass user data between functions. They set the data model + * for the Firestore database. + */ /** * A user that interacted with the app. @@ -35,5 +40,3 @@ exports.User = class { this.avatarUrl = avatarUrl; } } - -// [END chat_project_management_user] diff --git a/node/project-management-app/package.json b/node/project-management-app/package.json index c40e8480..6d06f481 100644 --- a/node/project-management-app/package.json +++ b/node/project-management-app/package.json @@ -6,10 +6,6 @@ "scripts": { "test": "mocha -p -j 2 -t 5s \"test/**/*.test.js\"" }, - "repository": { - "type": "git", - "url": "git+https://github.com/googleworkspace/google-chat-samples.git" - }, "keywords": [ "Google Chat", "Project Management" diff --git a/node/project-management-app/services/aip-service.js b/node/project-management-app/services/aip-service.js index 1885c80b..f6c0331e 100644 --- a/node/project-management-app/services/aip-service.js +++ b/node/project-management-app/services/aip-service.js @@ -13,8 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_aip_service] +/** + * @fileoverview Service that calls the Vertex AI API for generative AI text + * prediction. + */ + +/** + * [Vertex AI Platform](https://cloud.google.com/vertex-ai/docs) client library. + */ const aiplatform = require('@google-cloud/aiplatform'); const { env } = require('../env.js'); @@ -29,12 +36,14 @@ const clientOptions = { apiEndpoint: `${env.location}-aiplatform.googleapis.com`, }; +// Specify the Vertex AI model we use to generate text. const publisher = 'google'; const model = 'text-bison'; // Instantiates a client. const predictionServiceClient = new PredictionServiceClient(clientOptions); +// Prompts used to generate text using Vertex AI. const generationPrompt = 'Generate a description for a user story with the following title:'; const grammarPrompt = 'Correct the grammar of the following user story description:' const expansionPrompt = 'Expand the following user story description:'; @@ -106,5 +115,3 @@ exports.AIPService = { }, } - -// [END chat_project_management_aip_service] diff --git a/node/project-management-app/services/firestore-service.js b/node/project-management-app/services/firestore-service.js index 3371f224..d47d47a9 100644 --- a/node/project-management-app/services/firestore-service.js +++ b/node/project-management-app/services/firestore-service.js @@ -13,17 +13,39 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_firestore] +/** + * @fileoverview Service that handles database operations. + * + * The database contains a collection to store the + * [Chat spaces](https://developers.google.com/chat/concepts#messages-and-spaces) + * that the app is installed in, with subcollections for user stories and + * [Chat users](https://developers.google.com/chat/identify-reference-users): + * + * - `spaces` collection + * - `userStories` subcollection + * - `users` subcollection + */ + +/** [Firestore](https://cloud.google.com/firestore/docs) client library. */ const { Firestore, FieldPath } = require('@google-cloud/firestore'); const { NotFoundException } = require('../model/exceptions'); const { UserStory } = require('../model/user-story'); const { User } = require('../model/user'); +/** The prefix used by the Google Chat API in the Space resource name. */ const SPACES_PREFIX = 'spaces/'; + +/** The name of the spaces collection in the database. */ const SPACES_COLLECTION = 'spaces'; + +/** The name of the user stories subcollection in the database. */ const USER_STORIES_COLLECTION = 'userStories'; + +/** The name of the users subcollection in the database. */ const USERS_COLLECTION = 'users'; + +/** The size of the batch for collection clean up operations. */ const BATCH_SIZE = 50; // Initialize the Firestore database using Application Default Credentials. @@ -276,5 +298,3 @@ exports.FirestoreService = { }, }; - -// [END chat_project_management_firestore] diff --git a/node/project-management-app/services/space-service.js b/node/project-management-app/services/space-service.js index 78c1fbb8..5b245886 100644 --- a/node/project-management-app/services/space-service.js +++ b/node/project-management-app/services/space-service.js @@ -13,7 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_space_service] + +/** + * @fileoverview Service with the application logic specific to working with + * [Chat spaces](https://developers.google.com/chat/concepts#messages-and-spaces). + */ const { FirestoreService } = require('./firestore-service'); @@ -42,5 +46,3 @@ exports.SpaceService = { }, } - -// [END chat_project_management_space_service] diff --git a/node/project-management-app/services/user-service.js b/node/project-management-app/services/user-service.js index 611451e8..8fe2a35b 100644 --- a/node/project-management-app/services/user-service.js +++ b/node/project-management-app/services/user-service.js @@ -13,7 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_user_service] + +/** + * @fileoverview Service with the application logic specific to working with + * [Chat users](https://developers.google.com/chat/identify-reference-users). + */ const { NotFoundException } = require('../model/exceptions'); const { User } = require('../model/user'); @@ -56,5 +60,3 @@ exports.UserService = { }, } - -// [END chat_project_management_user_service] diff --git a/node/project-management-app/services/user-story-service.js b/node/project-management-app/services/user-story-service.js index 9ef5e0b7..0296cc54 100644 --- a/node/project-management-app/services/user-story-service.js +++ b/node/project-management-app/services/user-story-service.js @@ -13,12 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_user_story_service] + +/** + * @fileoverview Service with the application logic specific to working with + * user stories. + */ const { BadRequestException, NotFoundException } = require('../model/exceptions'); const { UserStory, Status, Priority, Size } = require('../model/user-story'); const { FirestoreService } = require('./firestore-service'); +/** The prefix used by the Google Chat API in the User resource name. */ const USERS_PREFIX = 'users/'; /** @@ -42,7 +47,7 @@ exports.UserStoryService = { * set to `OPEN`. * @param {!string} spaceName The resource name of the space. * @param {!string} title The short title of the user story. - * @param {string} description The description of the user story. + * @param {?string} description The description of the user story. * @return {Promise} The created user story data. * @throws {BadRequestException} If the `title` is null or empty. */ @@ -193,5 +198,3 @@ exports.UserStoryService = { }, } - -// [END chat_project_management_user_story_service] diff --git a/node/project-management-app/test/controllers/app-action-handler.test.js b/node/project-management-app/test/controllers/app-action-handler.test.js index f8b857eb..b61dd495 100644 --- a/node/project-management-app/test/controllers/app-action-handler.test.js +++ b/node/project-management-app/test/controllers/app-action-handler.test.js @@ -52,10 +52,6 @@ const EVENT = { common: { } } -const EXCEPTIONS = [ - new BadRequestException(), - new NotFoundException(), -]; const getTestEnvironment = () => { const aipServiceMock = { @@ -107,20 +103,6 @@ const getTestEnvironment = () => { }; }; -const assertExceptionResponseIsValid = (response, e) => { - assert.deepStrictEqual(response, { - actionResponse: { - type: 'DIALOG', - dialogAction: { - actionStatus: { - statusCode: e.statusCode, - userFacingMessage: e.message - } - } - } - }); -}; - const assertResponseIsValid = (test, response, cardType, updated) => { switch (cardType) { case UserStoryCardType.SINGLE_MESSAGE: @@ -155,7 +137,11 @@ const assertResponseIsValid = (test, response, cardType, updated) => { test.mocks.chatApp.handleManageUserStories.callCount, 1); break; default: - assert.deepStrictEqual(response, {}); + assert.strictEqual(response.text, updated ? 'User story updated.' : null); + assert.strictEqual(response.cardsV2.length, 1); + assert.strictEqual(response.cardsV2[0].cardId, 'userStoryCard'); + assert.ok(response.cardsV2[0].card instanceof UserStoryCard); + break; } }; @@ -269,28 +255,6 @@ describe('AppActionHandler', function () { assert.ok(test.mocks.userService.getUser.calledOnceWith( SPACE_NAME, USER_ID)); }); - - for (const e of EXCEPTIONS) { - it(`should handle ${e.name}`, async function () { - const test = getTestEnvironment(); - const AppActionHandler = test.AppActionHandler; - const event = { - ...EVENT, - common: { - invokedFunction: 'editUserStory', - parameters: { - id: USER_STORY_ID - } - } - }; - test.mocks.userStoryService.getUserStory.throws(e); - - const response = - await AppActionHandler.execute(event, test.mocks.chatApp); - - assertExceptionResponseIsValid(response, e); - }); - } }); describe('assignUserStory function', function () { @@ -321,28 +285,6 @@ describe('AppActionHandler', function () { }); }); } - - for (const e of EXCEPTIONS) { - it(`should handle ${e.name}`, async function () { - const test = getTestEnvironment(); - const AppActionHandler = test.AppActionHandler; - const event = { - ...EVENT, - common: { - invokedFunction: 'assignUserStory', - parameters: { - id: USER_STORY_ID - } - } - }; - test.mocks.userStoryService.assignUserStory.throws(e); - - const response = - await AppActionHandler.execute(event, test.mocks.chatApp); - - assertExceptionResponseIsValid(response, e); - }); - } }); describe('startUserStory function', function () { @@ -373,28 +315,6 @@ describe('AppActionHandler', function () { }); }); } - - for (const e of EXCEPTIONS) { - it(`should handle ${e.name}`, async function () { - const test = getTestEnvironment(); - const AppActionHandler = test.AppActionHandler; - const event = { - ...EVENT, - common: { - invokedFunction: 'startUserStory', - parameters: { - id: USER_STORY_ID - } - } - }; - test.mocks.userStoryService.startUserStory.throws(e); - - const response = - await AppActionHandler.execute(event, test.mocks.chatApp); - - assertExceptionResponseIsValid(response, e); - }); - } }); describe('completeUserStory function', function () { @@ -425,28 +345,6 @@ describe('AppActionHandler', function () { }); }); } - - for (const e of EXCEPTIONS) { - it(`should handle ${e.name}`, async function () { - const test = getTestEnvironment(); - const AppActionHandler = test.AppActionHandler; - const event = { - ...EVENT, - common: { - invokedFunction: 'completeUserStory', - parameters: { - id: USER_STORY_ID - } - } - }; - test.mocks.userStoryService.completeUserStory.throws(e); - - const response = - await AppActionHandler.execute(event, test.mocks.chatApp); - - assertExceptionResponseIsValid(response, e); - }); - } }); describe('cancelEditUserStory function', function () { @@ -455,6 +353,7 @@ describe('AppActionHandler', function () { const AppActionHandler = test.AppActionHandler; const event = { ...EVENT, + isDialogEvent: true, common: { invokedFunction: 'cancelEditUserStory' } @@ -477,6 +376,7 @@ describe('AppActionHandler', function () { const AppActionHandler = test.AppActionHandler; const event = { ...EVENT, + isDialogEvent: true, common: { invokedFunction: 'saveUserStory', parameters: { @@ -530,40 +430,6 @@ describe('AppActionHandler', function () { }); }); } - - for (const e of EXCEPTIONS) { - it(`should handle ${e.name}`, async function () { - const test = getTestEnvironment(); - const AppActionHandler = test.AppActionHandler; - const event = { - ...EVENT, - common: { - invokedFunction: 'saveUserStory', - parameters: { - id: USER_STORY_ID - }, - formInputs: { - title: { - stringInputs: { - value: ['New Title'] - } - }, - description: { - stringInputs: { - value: ['New Description'] - } - }, - } - } - }; - test.mocks.userStoryService.updateUserStory.throws(e); - - const response = - await AppActionHandler.execute(event, test.mocks.chatApp); - - assertExceptionResponseIsValid(response, e); - }); - } }); describe('refreshUserStory function', function () { @@ -592,28 +458,6 @@ describe('AppActionHandler', function () { assert.ok(test.mocks.userService.getUser.calledOnceWith( SPACE_NAME, USER_ID)); }); - - for (const e of EXCEPTIONS) { - it(`should handle ${e.name}`, async function () { - const test = getTestEnvironment(); - const AppActionHandler = test.AppActionHandler; - const event = { - ...EVENT, - common: { - invokedFunction: 'refreshUserStory', - parameters: { - id: USER_STORY_ID - } - } - }; - test.mocks.userStoryService.getUserStory.throws(e); - - const response = - await AppActionHandler.execute(event, test.mocks.chatApp); - - assertExceptionResponseIsValid(response, e); - }); - } }); describe('generateUserStoryDescription function', function () { @@ -624,6 +468,7 @@ describe('AppActionHandler', function () { const AppActionHandler = test.AppActionHandler; const event = { ...EVENT, + isDialogEvent: true, common: { invokedFunction: 'generateUserStoryDescription', parameters: { @@ -656,11 +501,12 @@ describe('AppActionHandler', function () { SPACE_NAME, USER_NAME)); }); - it('should not all AIP service if title is empty', async function () { + it('should not call AIP service if title is empty', async function () { const test = getTestEnvironment(); const AppActionHandler = test.AppActionHandler; const event = { ...EVENT, + isDialogEvent: true, common: { invokedFunction: 'generateUserStoryDescription', parameters: { @@ -690,40 +536,6 @@ describe('AppActionHandler', function () { }); }); } - - for (const e of EXCEPTIONS) { - it(`should handle ${e.name}`, async function () { - const test = getTestEnvironment(); - const AppActionHandler = test.AppActionHandler; - const event = { - ...EVENT, - common: { - invokedFunction: 'generateUserStoryDescription', - parameters: { - id: USER_STORY_ID - }, - formInputs: { - title: { - stringInputs: { - value: ['New Title'] - } - }, - description: { - stringInputs: { - value: ['New Description'] - } - }, - } - } - }; - test.mocks.aipService.generateDescription.throws(e); - - const response = - await AppActionHandler.execute(event, test.mocks.chatApp); - - assertExceptionResponseIsValid(response, e); - }); - } }); describe('expandUserStoryDescription function', function () { @@ -734,6 +546,7 @@ describe('AppActionHandler', function () { const AppActionHandler = test.AppActionHandler; const event = { ...EVENT, + isDialogEvent: true, common: { invokedFunction: 'expandUserStoryDescription', parameters: { @@ -766,12 +579,13 @@ describe('AppActionHandler', function () { SPACE_NAME, USER_NAME)); }); - it('should not all AIP service if description is empty', + it('should not call AIP service if description is empty', async function () { const test = getTestEnvironment(); const AppActionHandler = test.AppActionHandler; const event = { ...EVENT, + isDialogEvent: true, common: { invokedFunction: 'expandUserStoryDescription', parameters: { @@ -801,40 +615,6 @@ describe('AppActionHandler', function () { }); }); } - - for (const e of EXCEPTIONS) { - it(`should handle ${e.name}`, async function () { - const test = getTestEnvironment(); - const AppActionHandler = test.AppActionHandler; - const event = { - ...EVENT, - common: { - invokedFunction: 'expandUserStoryDescription', - parameters: { - id: USER_STORY_ID - }, - formInputs: { - title: { - stringInputs: { - value: ['New Title'] - } - }, - description: { - stringInputs: { - value: ['New Description'] - } - }, - } - } - }; - test.mocks.aipService.expandDescription.throws(e); - - const response = - await AppActionHandler.execute(event, test.mocks.chatApp); - - assertExceptionResponseIsValid(response, e); - }); - } }); describe('correctUserStoryDescriptionGrammar function', function () { @@ -845,6 +625,7 @@ describe('AppActionHandler', function () { const AppActionHandler = test.AppActionHandler; const event = { ...EVENT, + isDialogEvent: true, common: { invokedFunction: 'correctUserStoryDescriptionGrammar', parameters: { @@ -877,12 +658,13 @@ describe('AppActionHandler', function () { SPACE_NAME, USER_NAME)); }); - it('should not all AIP service if description is empty', + it('should not call AIP service if description is empty', async function () { const test = getTestEnvironment(); const AppActionHandler = test.AppActionHandler; const event = { ...EVENT, + isDialogEvent: true, common: { invokedFunction: 'correctUserStoryDescriptionGrammar', parameters: { @@ -912,40 +694,6 @@ describe('AppActionHandler', function () { }); }); } - - for (const e of EXCEPTIONS) { - it(`should handle ${e.name}`, async function () { - const test = getTestEnvironment(); - const AppActionHandler = test.AppActionHandler; - const event = { - ...EVENT, - common: { - invokedFunction: 'correctUserStoryDescriptionGrammar', - parameters: { - id: USER_STORY_ID - }, - formInputs: { - title: { - stringInputs: { - value: ['New Title'] - } - }, - description: { - stringInputs: { - value: ['New Description'] - } - }, - } - } - }; - test.mocks.aipService.correctDescription.throws(e); - - const response = - await AppActionHandler.execute(event, test.mocks.chatApp); - - assertExceptionResponseIsValid(response, e); - }); - } }); describe('Invalid function', function () { @@ -989,4 +737,85 @@ describe('AppActionHandler', function () { }); } }); + + describe('Exceptions', function () { + const event = { + ...EVENT, + common: { + invokedFunction: 'saveUserStory', + parameters: { + id: USER_STORY_ID + }, + formInputs: { + title: { + stringInputs: { + value: ['New Title'] + } + }, + description: { + stringInputs: { + value: ['New Description'] + } + }, + } + } + }; + const exceptions = [ + new BadRequestException(), + new NotFoundException(), + ]; + + context('Is Dialog Event', function () { + for (const e of exceptions) { + it(`should handle ${e.name}`, async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + test.mocks.userStoryService.updateUserStory.throws(e); + event.isDialogEvent = true; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assert.deepStrictEqual(response, { + actionResponse: { + type: 'DIALOG', + dialogAction: { + actionStatus: { + statusCode: e.statusCode, + userFacingMessage: e.message + } + } + } + }); + }); + } + }); + + context('Is Not Dialog Event', function () { + for (const e of exceptions) { + it(`should handle ${e.name}`, async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + test.mocks.userStoryService.updateUserStory.throws(e); + event.isDialogEvent = false; + + const response = + await AppActionHandler.execute(event, test.mocks.chatApp); + + assert.deepStrictEqual(response, { + text: `⚠️ ${e.message}` + }); + }); + } + }); + + it('should re-throw unrecognized exception', async function () { + const test = getTestEnvironment(); + const AppActionHandler = test.AppActionHandler; + test.mocks.userStoryService.updateUserStory.throws(new Error('test')); + + await assert.rejects( + async () => AppActionHandler.execute(event, test.mocks.chatApp), Error); + }); + }); }); diff --git a/node/project-management-app/test/controllers/app.test.js b/node/project-management-app/test/controllers/app.test.js index eb8de09a..ca8a90ae 100644 --- a/node/project-management-app/test/controllers/app.test.js +++ b/node/project-management-app/test/controllers/app.test.js @@ -106,7 +106,7 @@ describe('App', function () { const response = await App.execute(event); assert.deepStrictEqual(response, { - text: 'Thank you for adding the Project Manager app.' + + text: 'Thank you for adding the Project Management app.' + ' Message the app for a list of available commands.' }); assert.ok(test.mocks.spaceService.createSpace.calledOnceWith( @@ -212,8 +212,6 @@ describe('App', function () { assert.ok( test.mocks.userStoryService.listUserStoriesByUser.calledOnceWith( SPACE_NAME, USER_NAME)); - assert.ok( - test.mocks.userService.getUsers.calledOnceWith(SPACE_NAME, [USER_ID])); }); }); @@ -394,8 +392,6 @@ describe('App', function () { assert.ok( test.mocks.userStoryService.listUserStoriesByUser.calledOnceWith( SPACE_NAME, USER_NAME)); - assert.ok( - test.mocks.userService.getUsers.calledOnceWith(SPACE_NAME, [USER_ID])); }); }); } diff --git a/node/project-management-app/views/edit-user-story-card.js b/node/project-management-app/views/edit-user-story-card.js index a8b50f0c..b1cb91e8 100644 --- a/node/project-management-app/views/edit-user-story-card.js +++ b/node/project-management-app/views/edit-user-story-card.js @@ -13,7 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_edit_user_story_card] + +/** + * @fileoverview Module that exports a + * [card](https://developers.google.com/chat/api/guides/v1/messages/create#create) + * object that the Chat app then sends back to Chat in a + * [dialog](https://developers.google.com/chat/how-tos/dialogs) so the user can + * edit a user story. + */ const { UserStory, Status, Priority, Size } = require('../model/user-story'); const { User } = require('../model/user'); @@ -222,5 +229,3 @@ exports.EditUserStoryCard = class { } } - -// [END chat_project_management_edit_user_story_card] diff --git a/node/project-management-app/views/help-card.js b/node/project-management-app/views/help-card.js index fcff583c..ff80474f 100644 --- a/node/project-management-app/views/help-card.js +++ b/node/project-management-app/views/help-card.js @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_help_card] /** - * A Card with a description of the available commands. + * A + * [card](https://developers.google.com/chat/api/guides/v1/messages/create#create) + * with a description of the available commands. */ exports.HelpCard = class { @@ -115,5 +116,3 @@ exports.HelpCard = class { } } - -// [END chat_project_management_help_card] diff --git a/node/project-management-app/views/user-story-card.js b/node/project-management-app/views/user-story-card.js index 74351aed..9c41f24c 100644 --- a/node/project-management-app/views/user-story-card.js +++ b/node/project-management-app/views/user-story-card.js @@ -13,7 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_user_story_card] + +/** + * @fileoverview Module that exports a + * [card](https://developers.google.com/chat/api/guides/v1/messages/create#create) + * object that the Chat app then sends back to Chat in a message or + * [dialog](https://developers.google.com/chat/how-tos/dialogs) with a standard + * view of a user story. + */ const { UserStory, StatusIcon } = require('../model/user-story'); const { User } = require('../model/user'); @@ -91,5 +98,3 @@ exports.UserStoryCard = class { } } - -// [END chat_project_management_user_story_card] diff --git a/node/project-management-app/views/user-story-list-card.js b/node/project-management-app/views/user-story-list-card.js index c9c2fb2b..721b73c9 100644 --- a/node/project-management-app/views/user-story-list-card.js +++ b/node/project-management-app/views/user-story-list-card.js @@ -13,7 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_user_story_list_card] + +/** + * @fileoverview Module that exports a + * [card](https://developers.google.com/chat/api/guides/v1/messages/create#create) + * object that the Chat app then sends back to Chat in a message or + * [dialog](https://developers.google.com/chat/how-tos/dialogs) with a list of + * user stories. + */ const { UserStory } = require('../model/user-story'); const { User } = require('../model/user'); @@ -109,5 +116,3 @@ exports.UserStoryListCard = class { } } - -// [END chat_project_management_user_story_list_card] diff --git a/node/project-management-app/views/widgets/user-story-assignee-widget.js b/node/project-management-app/views/widgets/user-story-assignee-widget.js index f3a36632..5155a294 100644 --- a/node/project-management-app/views/widgets/user-story-assignee-widget.js +++ b/node/project-management-app/views/widgets/user-story-assignee-widget.js @@ -13,7 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_user_story_assginee_widget] + +/** + * @fileoverview Module that exports a + * [widget](https://developers.google.com/chat/api/reference/rest/v1/cards#Widget) + * object that the Chat app can add to a card to display the assignee of a user + * story. + */ const { User } = require('../../model/user'); @@ -48,5 +54,3 @@ exports.UserStoryAssigneeWidget = class { } } - -// [END chat_project_management_user_story_assginee_widget] diff --git a/node/project-management-app/views/widgets/user-story-buttons-widget.js b/node/project-management-app/views/widgets/user-story-buttons-widget.js index 22db5c6d..3811b348 100644 --- a/node/project-management-app/views/widgets/user-story-buttons-widget.js +++ b/node/project-management-app/views/widgets/user-story-buttons-widget.js @@ -13,7 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_user_story_buttons_widget] + +/** + * @fileoverview Module that exports a + * [widget](https://developers.google.com/chat/api/reference/rest/v1/cards#Widget) + * object that the Chat app can add to a card to display the actions buttons for + * a user story. + */ const { UserStory, Status } = require('../../model/user-story'); const { UserStoryCardType } = require('./user-story-card-type'); @@ -178,5 +184,3 @@ exports.UserStoryButtonsWidget = class { } } - -// [END chat_project_management_user_story_buttons_widget] diff --git a/node/project-management-app/views/widgets/user-story-card-type.js b/node/project-management-app/views/widgets/user-story-card-type.js index c6333b22..b40aec1f 100644 --- a/node/project-management-app/views/widgets/user-story-card-type.js +++ b/node/project-management-app/views/widgets/user-story-card-type.js @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_user_story_card_type] /** * Types of user interface where user story cards can appear. @@ -29,5 +28,3 @@ exports.UserStoryCardType = { /** Dialog with a list of user stories. */ LIST_DIALOG: 'list_dialog', }; - -// [END chat_project_management_user_story_card_type] diff --git a/node/project-management-app/views/widgets/user-story-columns-widget.js b/node/project-management-app/views/widgets/user-story-columns-widget.js index 1515c837..1d7561dc 100644 --- a/node/project-management-app/views/widgets/user-story-columns-widget.js +++ b/node/project-management-app/views/widgets/user-story-columns-widget.js @@ -13,7 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_user_story_columns_widget] + +/** + * @fileoverview Module that exports a + * [widget](https://developers.google.com/chat/api/reference/rest/v1/cards#Widget) + * object that the Chat app can add to a card to display information in two + * columns. + */ /** * Returns an Icon widget with the provided URL, or null if no URL @@ -74,5 +80,3 @@ exports.UserStoryColumnsWidget = class { } } - -// [END chat_project_management_user_story_columns_widget] diff --git a/node/project-management-app/views/widgets/user-story-row-widget.js b/node/project-management-app/views/widgets/user-story-row-widget.js index 9d1218d3..47020fbd 100644 --- a/node/project-management-app/views/widgets/user-story-row-widget.js +++ b/node/project-management-app/views/widgets/user-story-row-widget.js @@ -13,7 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// [START chat_project_management_user_story_row_widget] + +/** + * @fileoverview Module that exports a + * [widget](https://developers.google.com/chat/api/reference/rest/v1/cards#Widget) + * object that the Chat app can add to a card to display a row in a list with + * data for a single user story. + */ const { UserStory, StatusIcon } = require('../../model/user-story'); @@ -63,5 +69,3 @@ exports.UserStoryRowWidget = class { } } - -// [END chat_project_management_user_story_row_widget] From ce517500131770d18595e26fba1b52cd9e137fcb Mon Sep 17 00:00:00 2001 From: Gustavo Tondello Date: Wed, 13 Dec 2023 15:10:59 +0000 Subject: [PATCH 12/12] feat: Project Management Node sample PR review --- .../controllers/app-action-handler.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/node/project-management-app/controllers/app-action-handler.js b/node/project-management-app/controllers/app-action-handler.js index b5453826..c5e547a5 100644 --- a/node/project-management-app/controllers/app-action-handler.js +++ b/node/project-management-app/controllers/app-action-handler.js @@ -47,9 +47,12 @@ const AIAction = { /** * Handles exceptions thrown by the UserStoryService. * @param {!Error} e An exception thrown by the UserStoryService. - * @return {Object} A - * [dialog](https://developers.google.com/chat/how-tos/dialogs) status message - * with a user facing error message. + * @param {!boolean} isDialogEvent Whether the event that led to the exception + * was a [dialog](https://developers.google.com/chat/how-tos/dialogs) submission. + * @return {Object} If the event that led to the exception was from a dialog, a + * dialog action response with a user facing error message. Otherwise, a + * [text message](https://developers.google.com/chat/api/guides/v1/messages/create#respond-user-interaction) + * with the error message. * @throws {Error} If the exception is not a recognized type from the app. */ function handleException(e, isDialogEvent) {