From 0aa1ad0d09bb147ef5fa34174669d0b49d567f53 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Thu, 3 Aug 2023 09:20:54 -0700 Subject: [PATCH] feat: require JSDoc in new api (#50429) Co-authored-by: Oliver Eyton-Williams --- .eslintrc.json | 23 ++++++++ api/package.json | 3 +- api/src/app.ts | 7 +++ api/src/middleware/index.ts | 7 +++ api/src/routes/auth.ts | 16 ++++++ api/src/routes/challenge.ts | 7 +++ api/src/routes/deprecated-endpoints.ts | 12 +++++ api/src/routes/deprecated-unsubscribe.ts | 9 ++++ api/src/routes/donate.ts | 7 +++ api/src/routes/helpers/challenge-helpers.ts | 22 ++++++++ api/src/routes/settings.ts | 13 +++++ api/src/routes/status.ts | 8 +++ api/src/routes/user.ts | 8 +++ api/src/utils/common-challenge-functions.ts | 12 +++++ api/src/utils/error-formatting.ts | 7 ++- api/src/utils/get-challenges.ts | 4 ++ api/src/utils/index.ts | 6 +++ api/src/utils/normalize.ts | 24 +++++++++ api/src/utils/progress.ts | 12 +++++ api/src/utils/user-token.ts | 6 +++ api/src/utils/validation.ts | 6 +++ package.json | 1 + pnpm-lock.yaml | 58 ++++++++++++++++++++- 23 files changed, 275 insertions(+), 3 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index d4abab9718e..de649e55963 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -169,6 +169,29 @@ "rules": { "filenames-simple/naming-convention": "off" } + }, + { + "files": ["**/api/src/**/*.ts"], + "plugins": ["jsdoc"], + "extends": ["plugin:jsdoc/recommended-typescript-error"], + "rules": { + "jsdoc/require-jsdoc": [ + "error", + { + "require": { + "ArrowFunctionExpression": true, + "ClassDeclaration": true, + "ClassExpression": true, + "FunctionDeclaration": true, + "FunctionExpression": true, + "MethodDefinition": true + }, + "publicOnly": true + } + ], + "jsdoc/require-description-complete-sentence": "warn", + "jsdoc/tag-lines": "off" + } } ] } diff --git a/api/package.json b/api/package.json index 69f7ce7a0e4..43e16e3c3f4 100644 --- a/api/package.json +++ b/api/package.json @@ -69,7 +69,8 @@ "start": "FREECODECAMP_NODE_ENV=production node dist/server.js", "test": "jest --force-exit", "prisma": "dotenv -e ../.env prisma", - "postinstall": "prisma generate" + "postinstall": "prisma generate", + "lint": "cd .. && eslint api/src --max-warnings 0" }, "version": "0.0.1" } diff --git a/api/src/app.ts b/api/src/app.ts index cf26b761e4c..49132c8a1cc 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -77,6 +77,13 @@ ajv.addFormat('objectid', { validate: (str: string) => isObjectID(str) }); +/** + * Top-level wrapper to instantiate the API server. This is where all middleware and + * routes should be mounted. + * + * @param options The options to pass to the Fastify constructor. + * @returns The instantiated Fastify server, with TypeBox. + */ export const build = async ( options: FastifyHttpOptions = {} ): Promise => { diff --git a/api/src/middleware/index.ts b/api/src/middleware/index.ts index 96f85d751a5..bb874fd3fc8 100644 --- a/api/src/middleware/index.ts +++ b/api/src/middleware/index.ts @@ -3,6 +3,13 @@ import type { NextFunction, NextHandleFunction } from '@fastify/middie'; type MiddieRequest = Parameters[0]; type MiddieResponse = Parameters[1]; +/** + * Test middleware used to log request and response data? + * + * @param req The request payload. + * @param res The response to be sent back to the request. + * @param next Callback function to indicate that the middleware logic is complete. + */ export function testMiddleware( req: MiddieRequest, res: MiddieResponse, diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index 83bf641552f..e8b3106b99b 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -47,6 +47,15 @@ const findOrCreateUser = async (fastify: FastifyInstance, email: string) => { ); }; +/** + * Route handler for development login. This is only used in local + * development, and bypasses Auth0, authenticating as the development + * user. + * + * @param fastify The Fastify instance. + * @param _options Fastify options I guess? + * @param done Callback to signal that the logic has completed. + */ export const devLoginCallback: FastifyPluginCallback = ( fastify, _options, @@ -64,6 +73,13 @@ export const devLoginCallback: FastifyPluginCallback = ( done(); }; +/** + * Route handler for Auth0 authentication. + * + * @param fastify The Fastify instance. + * @param _options Fastify options I guess? + * @param done Callback to signal that the logic has completed. + */ export const auth0Routes: FastifyPluginCallback = (fastify, _options, done) => { fastify.addHook('onRequest', fastify.authenticate); diff --git a/api/src/routes/challenge.ts b/api/src/routes/challenge.ts index db43ad99675..7bac6121a3b 100644 --- a/api/src/routes/challenge.ts +++ b/api/src/routes/challenge.ts @@ -11,6 +11,13 @@ import { updateProject } from './helpers/challenge-helpers'; +/** + * Plugin for the challenge submission endpoints. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done The callback to signal that the plugin is ready. + */ export const challengeRoutes: FastifyPluginCallbackTypebox = ( fastify, _options, diff --git a/api/src/routes/deprecated-endpoints.ts b/api/src/routes/deprecated-endpoints.ts index 097623df799..2837d394441 100644 --- a/api/src/routes/deprecated-endpoints.ts +++ b/api/src/routes/deprecated-endpoints.ts @@ -10,6 +10,18 @@ export const endpoints: Endpoints = [ ['/account', 'GET'] ]; +/** + * Plugin for the deprecated endpoints. Instantiates a Fastify route for each + * endpoint, returning a 410 status code and a message indicating that the user + * should reload the app. + * + * These endpoints remain active until we can confirm that no requests are being + * made to them. + * + * @param fastify The Fastify instance. + * @param _options Fastify options I guess? + * @param done Callback to signal that the logic has completed. + */ export const deprecatedEndpoints: FastifyPluginCallbackTypebox = ( fastify, _options, diff --git a/api/src/routes/deprecated-unsubscribe.ts b/api/src/routes/deprecated-unsubscribe.ts index a8e798e8409..84abe3d2a2d 100644 --- a/api/src/routes/deprecated-unsubscribe.ts +++ b/api/src/routes/deprecated-unsubscribe.ts @@ -8,6 +8,15 @@ export const unsubscribeEndpoints: Endpoint[] = [ ['/unsubscribe/:email', 'GET'] ]; +/** + * Plugin for the deprecated unsubscribe endpoints. Each route returns a 302 + * redirect to the referer with a message explaining how to unsubscribe. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, + * options)`. + * @param done The callback to signal that the plugin is ready. + */ export const unsubscribeDeprecated: FastifyPluginCallbackTypebox = ( fastify, _options, diff --git a/api/src/routes/donate.ts b/api/src/routes/donate.ts index 607588a2b74..10d2f233af4 100644 --- a/api/src/routes/donate.ts +++ b/api/src/routes/donate.ts @@ -3,6 +3,13 @@ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; +/** + * Plugin for the donation endpoints. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done The callback to signal that the plugin is ready. + */ export const donateRoutes: FastifyPluginCallbackTypebox = ( fastify, _options, diff --git a/api/src/routes/helpers/challenge-helpers.ts b/api/src/routes/helpers/challenge-helpers.ts index 5661a5c8fe6..8895a15c707 100644 --- a/api/src/routes/helpers/challenge-helpers.ts +++ b/api/src/routes/helpers/challenge-helpers.ts @@ -1,6 +1,15 @@ import type { Prisma } from '@prisma/client'; import type { ProgressTimestamp } from '../../utils/progress'; +/** + * Confirm that a user can submit a CodeRoad project. + * + * @param id The id of the project. + * @param param The challenges the user has completed. + * @param param.partiallyCompletedChallenges The partially completed challenges. + * @param param.completedChallenges The completed challenges. + * @returns A boolean indicating if the user can submit the project. + */ export const canSubmitCodeRoadCertProject = ( id: string | undefined, { @@ -16,6 +25,12 @@ export const canSubmitCodeRoadCertProject = ( return false; }; +/** + * Create the Prisma query to update a project. + * @param id The id of the project. + * @param newChallenge The challenge corresponding to the project. + * @returns A Prisma query to update the project. + */ export const updateProject = ( id: string, newChallenge: Prisma.CompletedChallengeUpdateInput @@ -26,6 +41,13 @@ export const updateProject = ( partiallyCompletedChallenges: { deleteMany: { where: { id } } } }); +/** + * Create the Prisma query to create a project. + * @param id The id of the project. + * @param newChallenge The challenge corresponding to the project. + * @param progressTimestamps The user's current progress timestamps. + * @returns A Prisma query to update the project. + */ export const createProject = ( id: string, newChallenge: Prisma.CompletedChallengeCreateInput, diff --git a/api/src/routes/settings.ts b/api/src/routes/settings.ts index cdc570de6de..eb8de72c2de 100644 --- a/api/src/routes/settings.ts +++ b/api/src/routes/settings.ts @@ -5,6 +5,12 @@ import { isValidUsername } from '../../../utils/validate'; import { blocklistedUsernames } from '../../../config/constants.js'; import { schemas } from '../schemas'; +/** + * Validate an image url. + * + * @param picture The url to check. + * @returns Whether the url is a picture with a valid protocol. + */ export const isPictureWithProtocol = (picture?: string): boolean => { if (!picture) return false; try { @@ -15,6 +21,13 @@ export const isPictureWithProtocol = (picture?: string): boolean => { } }; +/** + * Plugin for all endpoints related to user settings. + * + * @param fastify The Fastify instance. + * @param _options Fastify options I guess? + * @param done Callback to signal that the logic has completed. + */ export const settingRoutes: FastifyPluginCallbackTypebox = ( fastify, _options, diff --git a/api/src/routes/status.ts b/api/src/routes/status.ts index 7637ba2d3a0..d42c42762f9 100644 --- a/api/src/routes/status.ts +++ b/api/src/routes/status.ts @@ -1,5 +1,13 @@ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; +/** + * Plugin for the health check endpoint. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, + * options)`. + * @param done The callback to signal that the plugin is ready. + */ export const statusRoute: FastifyPluginCallbackTypebox = ( fastify, _options, diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts index cb3421ddaa4..9c0a458a686 100644 --- a/api/src/routes/user.ts +++ b/api/src/routes/user.ts @@ -24,6 +24,14 @@ const nanoid = customAlphabet( 64 ); +/** + * Wrapper for endpoints related to user account management, + * such as account deletion. + * + * @param fastify The Fastify instance. + * @param _options Fastify options I guess? + * @param done Callback to signal that the logic has completed. + */ export const userRoutes: FastifyPluginCallbackTypebox = ( fastify, _options, diff --git a/api/src/utils/common-challenge-functions.ts b/api/src/utils/common-challenge-functions.ts index df85e8dd8a2..b1831b059b8 100644 --- a/api/src/utils/common-challenge-functions.ts +++ b/api/src/utils/common-challenge-functions.ts @@ -65,6 +65,18 @@ type CompletedChallenge = { files?: CompletedChallengeFile[]; }; +/** + * Helper function to update a user's challenge data. Used in challenge + * submission endpoints. + * + * @deprecated Create specific functions for each submission endpoint. + * @param fastify The Fastify instance. + * @param user The existing user record. + * @param challengeId The id of the submitted challenge. + * @param _completedChallenge The challenge submission. + * @param timezone The user's timezone. + * @returns Information about the update. + */ export async function updateUserChallengeData( fastify: FastifyInstance, user: user, diff --git a/api/src/utils/error-formatting.ts b/api/src/utils/error-formatting.ts index cd31097a88e..0a2c0afeebc 100644 --- a/api/src/utils/error-formatting.ts +++ b/api/src/utils/error-formatting.ts @@ -9,7 +9,12 @@ export type FormattedError = { | 'You have to complete the project before you can submit a URL.'; }; -// This only formats invalid challenge submission for now. +/** + * Format invalid challenge submission errors. + * + * @param errors An array of validation errors. + * @returns Formatted errors that can be used in the response. + */ export const formatValidationError = ( errors: ErrorObject[] ): FormattedError => { diff --git a/api/src/utils/get-challenges.ts b/api/src/utils/get-challenges.ts index 9bbb1c0d764..991726bbe4b 100644 --- a/api/src/utils/get-challenges.ts +++ b/api/src/utils/get-challenges.ts @@ -20,6 +20,10 @@ interface CurriculumProps { blocks: Record; } +/** + * Get all the challenges from the curriculum. + * @returns An array of challenges. + */ export function getChallenges() { const superBlockKeys = Object.values(SuperBlocks); const typedCurriculum: Curriculum = curriculum as Curriculum; diff --git a/api/src/utils/index.ts b/api/src/utils/index.ts index fbe8447a59c..18097405724 100644 --- a/api/src/utils/index.ts +++ b/api/src/utils/index.ts @@ -1,5 +1,11 @@ import { randomBytes, createHash } from 'crypto'; +/** + * Utility to encode a buffer to a base64 URI. + * + * @param buf The buffer to encode. + * @returns The encoded string. + */ export function base64URLEncode(buf: Buffer): string { return buf .toString('base64') diff --git a/api/src/utils/normalize.ts b/api/src/utils/normalize.ts index fabfba7940a..4f00c11f994 100644 --- a/api/src/utils/normalize.ts +++ b/api/src/utils/normalize.ts @@ -9,6 +9,12 @@ type NoNullProperties = { [P in keyof T]: NullToUndefined; }; +/** + * Converts a Twitter handle or URL to a URL. + * + * @param handleOrUrl Twitter handle or URL. + * @returns Twitter URL. + */ export const normalizeTwitter = ( handleOrUrl: string | null ): string | undefined => { @@ -23,6 +29,12 @@ export const normalizeTwitter = ( return url ?? handleOrUrl; }; +/** + * Ensure that the user's profile UI settings are valid. + * + * @param maybeProfileUI A null or the user's profile UI settings. + * @returns The input with nulls removed or a default value if there is no input. + */ export const normalizeProfileUI = ( maybeProfileUI: ProfileUI | null ): NoNullProperties => { @@ -42,6 +54,12 @@ export const normalizeProfileUI = ( }; }; +/** + * Remove all the null properties from an object. + * + * @param obj Any object. + * @returns The input with nulls removed. + */ export const removeNulls = >( obj: T ): NoNullProperties => @@ -65,6 +83,12 @@ type NormalizedChallenge = { solution?: string; }; +/** + * Remove all the null properties from a CompletedChallenge array. + * + * @param completedChallenges The CompletedChallenge array. + * @returns The input with nulls removed. + */ export const normalizeChallenges = ( completedChallenges: CompletedChallenge[] ): NormalizedChallenge[] => { diff --git a/api/src/utils/progress.ts b/api/src/utils/progress.ts index b10d586c75c..2c7919e146f 100644 --- a/api/src/utils/progress.ts +++ b/api/src/utils/progress.ts @@ -1,5 +1,11 @@ export type ProgressTimestamp = number | { timestamp: number } | null; +/** + * Converts a ProgressTimestamp array to a object with keys based on the timestamps. + * + * @param progressTimestamps The ProgressTimestamp array. + * @returns The object with keys based on the timestamps. + */ export const getCalendar = ( progressTimestamps: ProgressTimestamp[] | null ): Record => { @@ -17,6 +23,12 @@ export const getCalendar = ( return calendar; }; +/** + * Converts a ProgressTimestamp array to an integer number of points. + * + * @param progressTimestamps The ProgressTimestamp array. + * @returns The number of points. + */ export const getPoints = ( progressTimestamps: ProgressTimestamp[] | null ): number => { diff --git a/api/src/utils/user-token.ts b/api/src/utils/user-token.ts index 26ef52a43f4..d9bb1c03cba 100644 --- a/api/src/utils/user-token.ts +++ b/api/src/utils/user-token.ts @@ -2,6 +2,12 @@ import jwt from 'jsonwebtoken'; import { JWT_SECRET } from './env'; +/** + * Encode an id into a JWT (the naming suggests it's a user token, but it's the + * id of the UserToken document). + * @param userToken A token id to encode. + * @returns An encoded object with the userToken property. + */ export function encodeUserToken(userToken: string): string { return jwt.sign({ userToken }, JWT_SECRET); } diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index f4c9984bcc0..ed37622f563 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -2,5 +2,11 @@ import { ObjectId } from 'mongodb'; // This is trivial, but makes it simple to refactor if we swap monogodb for // bson, say. + +/** + * Checks if a string is a valid MongoDB ObjectID. + * @param id A string to check. + * @returns A boolean indicating if the string is a valid MongoDB ObjectID. + */ export const isObjectID = (id?: string): boolean => id ? ObjectId.isValid(id) : false; diff --git a/package.json b/package.json index 1545b769695..05478309fee 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "eslint-plugin-filenames-simple": "0.8.0", "eslint-plugin-import": "2.28.0", "eslint-plugin-jest-dom": "3.9.4", + "eslint-plugin-jsdoc": "44.2.4", "eslint-plugin-jsx-a11y": "6.7.1", "eslint-plugin-no-only-tests": "2.6.0", "eslint-plugin-react": "7.33.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 172ea27c24a..1e9661dddc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: eslint-plugin-jest-dom: specifier: 3.9.4 version: 3.9.4(eslint@8.46.0) + eslint-plugin-jsdoc: + specifier: 44.2.4 + version: 44.2.4(eslint@8.46.0) eslint-plugin-jsx-a11y: specifier: 6.7.1 version: 6.7.1(eslint@8.46.0) @@ -6859,6 +6862,15 @@ packages: transitivePeerDependencies: - typescript + /@es-joy/jsdoccomment@0.39.4: + resolution: {integrity: sha512-Jvw915fjqQct445+yron7Dufix9A+m9j1fCJYlCo1FWlRvTxa3pjJelxdSTdaLWcTwRU6vbL+NYjO4YuNIS5Qg==} + engines: {node: '>=16'} + dependencies: + comment-parser: 1.3.1 + esquery: 1.5.0 + jsdoc-type-pratt-parser: 4.0.0 + dev: true + /@eslint-community/eslint-utils@4.4.0(eslint@8.46.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -12358,6 +12370,11 @@ packages: resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} dev: false + /are-docs-informative@0.0.2: + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} + engines: {node: '>=14'} + dev: true + /are-we-there-yet@2.0.0: resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} engines: {node: '>=10'} @@ -15245,6 +15262,11 @@ packages: engines: {node: ^12.20.0 || >=14} dev: true + /comment-parser@1.3.1: + resolution: {integrity: sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==} + engines: {node: '>= 12.0.0'} + dev: true + /common-path-prefix@3.0.0: resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} @@ -17814,6 +17836,25 @@ packages: - typescript dev: false + /eslint-plugin-jsdoc@44.2.4(eslint@8.46.0): + resolution: {integrity: sha512-/EMMxCyRh1SywhCb66gAqoGX4Yv6Xzc4bsSkF1AiY2o2+bQmGMQ05QZ5+JjHbdFTPDZY9pfn+DsSNP0a5yQpIg==} + engines: {node: '>=16'} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + dependencies: + '@es-joy/jsdoccomment': 0.39.4 + are-docs-informative: 0.0.2 + comment-parser: 1.3.1 + debug: 4.3.4(supports-color@8.1.1) + escape-string-regexp: 4.0.0 + eslint: 8.46.0 + esquery: 1.5.0 + semver: 7.5.3 + spdx-expression-parse: 3.0.1 + transitivePeerDependencies: + - supports-color + dev: true + /eslint-plugin-jsx-a11y@6.7.1(eslint@7.32.0): resolution: {integrity: sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==} engines: {node: '>=4.0'} @@ -21072,13 +21113,23 @@ packages: dependencies: '@types/express': 4.17.17 '@types/http-proxy': 1.17.10 - http-proxy: 1.18.1(debug@3.2.7) + http-proxy: 1.18.1 is-glob: 4.0.3 is-plain-obj: 3.0.0 micromatch: 4.0.5 transitivePeerDependencies: - debug + /http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.2(debug@4.3.4) + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + /http-proxy@1.18.1(debug@3.2.7): resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} @@ -23342,6 +23393,11 @@ packages: /jsbn@0.1.1: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + /jsdoc-type-pratt-parser@4.0.0: + resolution: {integrity: sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==} + engines: {node: '>=12.0.0'} + dev: true + /jsdom@16.7.0: resolution: {integrity: sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==} engines: {node: '>=10'}