diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index b22b8b106a..c55a6d0d5b 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -51,6 +51,7 @@ export default { contentUri: '/static/content', defaultPluginsUri: '/static/plugins', pluginsAssetsUri: '/static/resources/plugins', + base: process.env.RI_BASE || '/', secretStoragePassword: process.env.SECRET_STORAGE_PASSWORD, tls: process.env.SERVER_TLS ? process.env.SERVER_TLS === 'true' : true, tlsCert: process.env.SERVER_TLS_CERT, diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-import.controller.ts b/redisinsight/api/src/modules/bulk-actions/bulk-import.controller.ts index 00110f1792..98bb34715b 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-import.controller.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-import.controller.ts @@ -14,6 +14,7 @@ import { UploadImportFileDto } from 'src/modules/bulk-actions/dto/upload-import- import { ClientMetadataParam } from 'src/common/decorators'; import { ClientMetadata } from 'src/common/models'; import { IBulkActionOverview } from 'src/modules/bulk-actions/interfaces/bulk-action-overview.interface'; +import { UploadImportFileByPathDto } from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto'; @UsePipes(new ValidationPipe({ transform: true })) @UseInterceptors(ClassSerializerInterceptor) @@ -40,4 +41,21 @@ export class BulkImportController { ): Promise { return this.service.import(clientMetadata, dto); } + + @Post('import/tutorial-data') + @HttpCode(200) + @ApiEndpoint({ + description: 'Import data from tutorial by path', + responses: [ + { + type: Object, + }, + ], + }) + async uploadFromTutorial( + @Body() dto: UploadImportFileByPathDto, + @ClientMetadataParam() clientMetadata: ClientMetadata, + ): Promise { + return this.service.uploadFromTutorial(clientMetadata, dto); + } } diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts index 3ace41a9f2..48b56afc8a 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts @@ -11,8 +11,13 @@ import { MemoryStoredFile } from 'nestjs-form-data'; import { BulkActionSummary } from 'src/modules/bulk-actions/models/bulk-action-summary'; import { IBulkActionOverview } from 'src/modules/bulk-actions/interfaces/bulk-action-overview.interface'; import { BulkActionStatus, BulkActionType } from 'src/modules/bulk-actions/constants'; -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkActionsAnalyticsService } from 'src/modules/bulk-actions/bulk-actions-analytics.service'; +import * as fs from 'fs-extra'; +import config from 'src/utils/config'; +import { join } from 'path'; + +const PATH_CONFIG = config.get('dir_path'); const generateNCommandsBuffer = (n: number) => Buffer.from( (new Array(n)).fill(1).map(() => ['set', ['foo', 'bar']]).join('\n'), @@ -55,12 +60,20 @@ const mockUploadImportFileDto = { } as unknown as MemoryStoredFile, }; +const mockUploadImportFileByPathDto = { + path: '/some/path', +}; + +jest.mock('fs-extra'); +const mockedFs = fs as jest.Mocked; + describe('BulkImportService', () => { let service: BulkImportService; let databaseConnectionService: MockType; let analytics: MockType; beforeEach(async () => { + jest.mock('fs-extra', () => mockedFs); jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ @@ -211,4 +224,83 @@ describe('BulkImportService', () => { } }); }); + + describe('uploadFromTutorial', () => { + let spy; + + beforeEach(() => { + spy = jest.spyOn(service as any, 'import'); + spy.mockResolvedValue(mockSummary); + mockedFs.readFile.mockResolvedValue(Buffer.from('set foo bar')); + }); + + it('should import file by path', async () => { + mockedFs.pathExists.mockImplementationOnce(async () => true); + + await service.uploadFromTutorial(mockClientMetadata, mockUploadImportFileByPathDto); + + expect(mockedFs.readFile).toHaveBeenCalledWith(join(PATH_CONFIG.homedir, mockUploadImportFileByPathDto.path)); + }); + + it('should import file by path with static', async () => { + mockedFs.pathExists.mockImplementationOnce(async () => true); + + await service.uploadFromTutorial(mockClientMetadata, { path: '/static/guides/_data.file' }); + + expect(mockedFs.readFile).toHaveBeenCalledWith(join(PATH_CONFIG.homedir, '/guides/_data.file')); + }); + + it('should normalize path before importing and not search for file outside home folder', async () => { + mockedFs.pathExists.mockImplementationOnce(async () => true); + + await service.uploadFromTutorial(mockClientMetadata, { + path: '/../../../danger', + }); + + expect(mockedFs.readFile).toHaveBeenCalledWith(join(PATH_CONFIG.homedir, 'danger')); + }); + + it('should normalize path before importing and throw an error when search for file outside home folder (relative)', async () => { + mockedFs.pathExists.mockImplementationOnce(async () => true); + + try { + await service.uploadFromTutorial(mockClientMetadata, { + path: '../../../danger', + }); + + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual('Data file was not found'); + } + }); + + it('should throw BadRequest when no file found', async () => { + mockedFs.pathExists.mockImplementationOnce(async () => false); + + try { + await service.uploadFromTutorial(mockClientMetadata, { + path: '../../../danger', + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual('Data file was not found'); + } + }); + + it('should throw BadRequest when file size is greater then 100MB', async () => { + mockedFs.pathExists.mockImplementationOnce(async () => true); + mockedFs.stat.mockImplementationOnce(async () => ({ size: 100 * 1024 * 1024 + 1 } as fs.Stats)); + + try { + await service.uploadFromTutorial(mockClientMetadata, mockUploadImportFileByPathDto); + + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual('Maximum file size is 100MB'); + } + }); + }); }); diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.ts b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.ts index c9ee4cbff2..039a6cbf59 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.ts @@ -1,4 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { join, resolve } from 'path'; +import * as fs from 'fs-extra'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { Readable } from 'stream'; import * as readline from 'readline'; import { wrapHttpError } from 'src/common/utils'; @@ -10,8 +12,13 @@ import { BulkActionSummary } from 'src/modules/bulk-actions/models/bulk-action-s import { IBulkActionOverview } from 'src/modules/bulk-actions/interfaces/bulk-action-overview.interface'; import { BulkActionStatus, BulkActionType } from 'src/modules/bulk-actions/constants'; import { BulkActionsAnalyticsService } from 'src/modules/bulk-actions/bulk-actions-analytics.service'; +import { UploadImportFileByPathDto } from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto'; +import config from 'src/utils/config'; +import { MemoryStoredFile } from 'nestjs-form-data'; const BATCH_LIMIT = 10_000; +const PATH_CONFIG = config.get('dir_path'); +const SERVER_CONFIG = config.get('server'); @Injectable() export class BulkImportService { @@ -137,4 +144,44 @@ export class BulkImportService { throw wrapHttpError(e); } } + + /** + * Upload file from tutorial by path + * @param clientMetadata + * @param dto + */ + public async uploadFromTutorial( + clientMetadata: ClientMetadata, + dto: UploadImportFileByPathDto, + ): Promise { + try { + const filePath = join(dto.path); + + const staticPath = join(SERVER_CONFIG.base, SERVER_CONFIG.staticUri); + + let trimmedPath = filePath; + if (filePath.indexOf(staticPath) === 0) { + trimmedPath = filePath.slice(staticPath.length); + } + + const path = join(PATH_CONFIG.homedir, trimmedPath); + + if (!path.startsWith(PATH_CONFIG.homedir) || !await fs.pathExists(path)) { + throw new BadRequestException('Data file was not found'); + } + + if ((await fs.stat(path))?.size > 100 * 1024 * 1024) { + throw new BadRequestException('Maximum file size is 100MB'); + } + + const buffer = await fs.readFile(path); + + return this.import(clientMetadata, { + file: { buffer } as MemoryStoredFile, + }); + } catch (e) { + this.logger.error('Unable to process an import file path from tutorial', e); + throw wrapHttpError(e); + } + } } diff --git a/redisinsight/api/src/modules/bulk-actions/dto/upload-import-file-by-path.dto.ts b/redisinsight/api/src/modules/bulk-actions/dto/upload-import-file-by-path.dto.ts new file mode 100644 index 0000000000..d9a73964fd --- /dev/null +++ b/redisinsight/api/src/modules/bulk-actions/dto/upload-import-file-by-path.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class UploadImportFileByPathDto { + @ApiProperty({ + type: 'string', + description: 'Internal path to data file', + }) + @IsString() + @IsNotEmpty() + path: string; +} diff --git a/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import-tutorial_data.test.ts b/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import-tutorial_data.test.ts new file mode 100644 index 0000000000..0c4534dce3 --- /dev/null +++ b/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import-tutorial_data.test.ts @@ -0,0 +1,122 @@ +import { + expect, + describe, + it, + deps, + requirements, + validateApiCall, +} from '../deps'; +import { AdmZip, path } from '../../helpers/test'; +const { rte, request, server, constants } = deps; + +const endpoint = ( + id = constants.TEST_INSTANCE_ID, +) => request(server).post(`/${constants.API.DATABASES}/${id}/bulk-actions/import/tutorial-data`); + +const creatCustomTutorialsEndpoint = () => request(server).post(`/custom-tutorials`); + +const getZipArchive = () => { + const zipArchive = new AdmZip(); + + zipArchive.addFile('info.md', Buffer.from('# info.md', 'utf8')); + zipArchive.addFile('_data/data.txt', Buffer.from( + `set ${constants.TEST_STRING_KEY_1} bulkimport`, + 'utf8', + )); + + return zipArchive; +} + +describe('POST /databases/:id/bulk-actions/import/tutorial-data', () => { + requirements('!rte.sharedData', '!rte.bigData', 'rte.serverType=local') + + beforeEach(async () => await rte.data.truncate()); + + describe('Common', function () { + let tutorialId; + it('should import data', async () => { + expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.not.eq('bulkimport'); + + // create tutorial + const zip = getZipArchive(); + await validateApiCall({ + endpoint: creatCustomTutorialsEndpoint, + attach: ['file', zip.toBuffer(), 'a.zip'], + statusCode: 201, + checkFn: ({ body }) => { + tutorialId = body.id; + }, + }); + + await validateApiCall({ + endpoint, + data: { + path: path.join('/custom-tutorials', tutorialId, '_data/data.txt'), + }, + responseBody: { + id: 'empty', + databaseId: constants.TEST_INSTANCE_ID, + type: 'import', + summary: { processed: 1, succeed: 1, failed: 0, errors: [] }, + progress: null, + filter: null, + status: 'completed', + }, + checkFn: async ({ body }) => { + expect(body.duration).to.gt(0); + + expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eq('bulkimport'); + }, + }); + }); + it('should import data with static path', async () => { + expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.not.eq('bulkimport'); + + // create tutorial + const zip = getZipArchive(); + await validateApiCall({ + endpoint: creatCustomTutorialsEndpoint, + attach: ['file', zip.toBuffer(), 'a.zip'], + statusCode: 201, + checkFn: ({ body }) => { + tutorialId = body.id; + }, + }); + + await validateApiCall({ + endpoint, + data: { + path: path.join('/static/custom-tutorials', tutorialId, '_data/data.txt'), + }, + responseBody: { + id: 'empty', + databaseId: constants.TEST_INSTANCE_ID, + type: 'import', + summary: { processed: 1, succeed: 1, failed: 0, errors: [] }, + progress: null, + filter: null, + status: 'completed', + }, + checkFn: async ({ body }) => { + expect(body.duration).to.gt(0); + + expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eq('bulkimport'); + }, + }); + }); + it('should return BadRequest when path does not exists', async () => { + await validateApiCall({ + endpoint, + data: { + path: path.join('/custom-tutorials', tutorialId, '../../../../../_data/data.txt'), + }, + statusCode: 400, + responseBody: { + statusCode: 400, + message: 'Data file was not found', + error: 'Bad Request', + }, + }); + }); + }); +}); diff --git a/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-upload.test.ts b/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import.test.ts similarity index 100% rename from redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-upload.test.ts rename to redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import.test.ts diff --git a/redisinsight/api/test/api/custom-tutorials/POST-custom-tutorials.test.ts b/redisinsight/api/test/api/custom-tutorials/POST-custom-tutorials.test.ts index 983bd5499a..6fec8089e7 100644 --- a/redisinsight/api/test/api/custom-tutorials/POST-custom-tutorials.test.ts +++ b/redisinsight/api/test/api/custom-tutorials/POST-custom-tutorials.test.ts @@ -12,7 +12,7 @@ import { _, } from '../deps'; import { getBaseURL } from '../../helpers/server'; -const { server, request } = deps; +const { server, request, localDb } = deps; // create endpoint const creatEndpoint = () => request(server).post(`/custom-tutorials`); @@ -110,11 +110,12 @@ const globalManifest = { describe('POST /custom-tutorials', () => { requirements('rte.serverType=local'); - describe('Common', () => { - before(async () => { - await fsExtra.remove(customTutorialsFolder); - }); + before(async () => { + await fsExtra.remove(customTutorialsFolder); + await (await localDb.getRepository(localDb.repositories.CUSTOM_TUTORIAL)).clear(); + }); + describe('Common', () => { it('should import tutorial from file and generate _manifest.json', async () => { const zip = getZipArchive(); zip.writeZip(path.join(staticsFolder, 'test_no_manifest.zip')); diff --git a/redisinsight/ui/src/assets/img/icons/data-upload-bulk.svg b/redisinsight/ui/src/assets/img/icons/data-upload-bulk.svg new file mode 100644 index 0000000000..129402a0bf --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/data-upload-bulk.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/components/notifications/Notifications.tsx b/redisinsight/ui/src/components/notifications/Notifications.tsx index e544637d19..c9cb8a2df8 100644 --- a/redisinsight/ui/src/components/notifications/Notifications.tsx +++ b/redisinsight/ui/src/components/notifications/Notifications.tsx @@ -64,7 +64,7 @@ const Notifications = () => { ) const getSuccessToasts = (data: IMessage[]) => - data.map(({ id = '', title = '', message = '', group }) => { + data.map(({ id = '', title = '', message = '', className, group }) => { const toast: Toast = { id, iconType: 'iInCircle', @@ -74,6 +74,7 @@ const Notifications = () => { ), color: 'success', + className } toast.text = getSuccessText(message, toast, group) toast.onClose = () => removeToast(toast) diff --git a/redisinsight/ui/src/components/notifications/styles.module.scss b/redisinsight/ui/src/components/notifications/styles.module.scss index c4432a06cd..49160a28d4 100644 --- a/redisinsight/ui/src/components/notifications/styles.module.scss +++ b/redisinsight/ui/src/components/notifications/styles.module.scss @@ -16,3 +16,21 @@ :global(.euiToast) { box-shadow: none !important; } + +.summary { + border-top: 1px solid var(--euiToastSuccessBtnColor); + padding-top: 8px; + + :global(.euiFlexItem:not(:last-child)) { + margin-right: 14px !important; + } + + .summaryValue { + font-size: 14px; + font-weight: 500; + } + + .summaryLabel { + color: var(--euiToastLightColor) !important; + } +} diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx index e5d2608e26..88edef0a9e 100644 --- a/redisinsight/ui/src/components/notifications/success-messages.tsx +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -1,7 +1,9 @@ import React from 'react' +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui' import { EXTERNAL_LINKS } from 'uiSrc/constants/links' -import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' -import { bufferToString, formatNameShort, Maybe } from 'uiSrc/utils' +import { IBulkActionOverview, RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { bufferToString, formatLongName, formatNameShort, Maybe, millisecondsFormat } from 'uiSrc/utils' +import { numberWithSpaces } from 'uiSrc/utils/numbers' import styles from './styles.module.scss' // TODO: use i18n file for texts @@ -153,5 +155,44 @@ export default { }), TEST_CONNECTION: () => ({ title: 'Connection is successful', - }) + }), + UPLOAD_DATA_BULK: (data: IBulkActionOverview, fileName: string) => { + const { processed = 0, succeed = 0, failed = 0, } = data?.summary ?? {} + return ({ + title: ( + <> + Action completed +
+ Data uploaded with file: {formatLongName(fileName, 24, 5)} + + ), + message: ( + + + {numberWithSpaces(processed)} + Commands Processed + + + {numberWithSpaces(succeed)} + Success + + + {numberWithSpaces(failed)} + Errors + + + {millisecondsFormat(data?.duration || 0, 'H:mm:ss.SSS')} + Time Taken + + + ), + className: 'dynamic' + }) + } } diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 916bacdfb9..20ca803f3c 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -5,6 +5,7 @@ enum ApiEndpoints { DATABASES_EXPORT = 'databases/export', BULK_ACTIONS_IMPORT = 'bulk-actions/import', + BULK_ACTIONS_IMPORT_TUTORIAL_DATA = 'bulk-actions/import/tutorial-data', CA_CERTIFICATES = 'certificates/ca', CLIENT_CERTIFICATES = 'certificates/client', diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/InternalPage/InternalPage.tsx b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/InternalPage/InternalPage.tsx index 471b023561..6ea62df990 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/InternalPage/InternalPage.tsx +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/InternalPage/InternalPage.tsx @@ -16,6 +16,7 @@ import { Image, Code, EmptyPrompt, + RedisUploadButton, Pagination } from 'uiSrc/pages/workbench/components/enablement-area/EnablementArea/components' import { getTutorialSection } from 'uiSrc/pages/workbench/components/enablement-area/EnablementArea/utils' @@ -58,7 +59,7 @@ const InternalPage = (props: Props) => { manifestPath, sourcePath } = props - const components: any = { LazyCodeButton, Image, Code } + const components: any = { LazyCodeButton, Image, Code, RedisUploadButton } const containerRef = useRef(null) const { instanceId = '' } = useParams<{ instanceId: string }>() const handleScroll = debounce(() => { diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/RedisUploadButton.spec.tsx b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/RedisUploadButton.spec.tsx new file mode 100644 index 0000000000..4de5dc3c31 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/RedisUploadButton.spec.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' +import { customTutorialsBulkUploadSelector, uploadDataBulk } from 'uiSrc/slices/workbench/wb-custom-tutorials' + +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import RedisUploadButton, { Props } from './RedisUploadButton' + +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + connectedInstanceSelector: jest.fn().mockReturnValue({ + id: 'databaseId', + }), +})) + +jest.mock('uiSrc/slices/workbench/wb-custom-tutorials', () => ({ + ...jest.requireActual('uiSrc/slices/workbench/wb-custom-tutorials'), + customTutorialsBulkUploadSelector: jest.fn().mockReturnValue({ + pathsInProgress: [], + }), +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +const props: Props = { + label: 'Label', + path: '/text' +} + +describe('RedisUploadButton', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should be disabled with loading state', () => { + (customTutorialsBulkUploadSelector as jest.Mock).mockReturnValueOnce({ + pathsInProgress: [props.path], + }) + + render() + + expect(screen.getByTestId('upload-data-bulk-btn')).toBeDisabled() + }) + + it('should open warning popover and call proper actions after submit', () => { + render() + + fireEvent.click(screen.getByTestId('upload-data-bulk-btn')) + + expect(screen.getByTestId('upload-data-bulk-tooltip')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('upload-data-bulk-apply-btn')) + + const expectedActions = [uploadDataBulk(props.path)] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call proper telemetry events', () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + render() + + fireEvent.click(screen.getByTestId('upload-data-bulk-btn')) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.WORKBENCH_ENABLEMENT_AREA_DATA_UPLOAD_CLICKED, + eventData: { + databaseId: 'databaseId' + } + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + + fireEvent.click(screen.getByTestId('upload-data-bulk-apply-btn')) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.WORKBENCH_ENABLEMENT_AREA_DATA_UPLOAD_SUBMITTED, + eventData: { + databaseId: 'databaseId' + } + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + }) +}) diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/RedisUploadButton.tsx b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/RedisUploadButton.tsx new file mode 100644 index 0000000000..b74e5d07a3 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/RedisUploadButton.tsx @@ -0,0 +1,110 @@ +import { EuiButton, EuiIcon, EuiPopover, EuiSpacer, EuiText } from '@elastic/eui' +import { useDispatch, useSelector } from 'react-redux' +import React, { useEffect, useState } from 'react' +import cx from 'classnames' +import { truncateText } from 'uiSrc/utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { customTutorialsBulkUploadSelector, uploadDataBulkAction } from 'uiSrc/slices/workbench/wb-custom-tutorials' + +import { ReactComponent as BulkDataUploadIcon } from 'uiSrc/assets/img/icons/data-upload-bulk.svg' + +import styles from './styles.module.scss' + +export interface Props { + label: string + path: string +} + +const RedisUploadButton = ({ label, path }: Props) => { + const { id: instanceId } = useSelector(connectedInstanceSelector) + const { pathsInProgress } = useSelector(customTutorialsBulkUploadSelector) + + const [isLoading, setIsLoading] = useState(false) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + + const dispatch = useDispatch() + + useEffect(() => { + setIsLoading(pathsInProgress.includes(path)) + }, [pathsInProgress]) + + const openPopover = () => { + if (!isPopoverOpen) { + sendEventTelemetry({ + event: TelemetryEvent.WORKBENCH_ENABLEMENT_AREA_DATA_UPLOAD_CLICKED, + eventData: { + databaseId: instanceId + } + }) + } + + setIsPopoverOpen((v) => !v) + } + + const uploadData = async () => { + setIsPopoverOpen(false) + dispatch(uploadDataBulkAction(instanceId, path)) + sendEventTelemetry({ + event: TelemetryEvent.WORKBENCH_ENABLEMENT_AREA_DATA_UPLOAD_SUBMITTED, + eventData: { + databaseId: instanceId + } + }) + } + + return ( +
+ setIsPopoverOpen(false)} + panelClassName={styles.panelPopover} + anchorClassName={styles.popoverAnchor} + panelPaddingSize="none" + button={( + + {truncateText(label, 86)} + + )} + > + + +
+ Execute commands in bulk +
+ +
+ All commands from the file in your tutorial will be automatically executed against your database. + Avoid executing them in production databases. +
+ + Execute + +
+
+
+ ) +} + +export default RedisUploadButton diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/index.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/index.ts new file mode 100644 index 0000000000..fc79773633 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/index.ts @@ -0,0 +1,3 @@ +import RedisUploadButton from './RedisUploadButton' + +export default RedisUploadButton diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/styles.module.scss b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/styles.module.scss new file mode 100644 index 0000000000..56840b0e49 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/RedisUploadButton/styles.module.scss @@ -0,0 +1,67 @@ +.wrapper { + width: 100%; + + :global(.euiPopover) { + width: 100%; + } + + .button { + &[class*='euiButton--secondary']:not([class*='isDisabled']) { + &:hover { + background-color: var(--euiColorSecondary) !important; + border-color: var(--euiColorSecondary) !important; + color: var(--euiColorPrimaryText) !important; + } + } + &:global(.euiButton.euiButton-isDisabled) { + color: var(--buttonSecondaryDisabledTextColor) !important; + } + } +} + +.containerPopover { + padding: 18px; + width: 360px; + height: 188px; +} + +.panelPopover { + border-color: var(--euiColorPrimary) !important; + :global(.euiPopover__panelArrow--bottom:before) { + border-bottom-color: var(--euiColorPrimary) !important; + } + + :global(.euiPopover__panelArrow--top:before) { + border-top-color: var(--euiColorPrimary) !important; + } +} + +.popoverAnchor { + display: block; + width: 100%; +} + +.popoverIcon { + position: absolute; + color: var(--euiColorWarningLight) !important; + width: 24px !important; + height: 24px !important; +} + +.popoverItem { + font-size: 13px !important; + line-height: 18px !important; + padding-left: 34px; +} + +.popoverItemTitle { + color: var(--htmlColor) !important; + font-size: 14px !important; + line-height: 24px !important; +} + +.uploadApproveBtn { + position: absolute; + right: 18px; + bottom: 18px; +} diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/index.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/index.ts index 5f7f693945..52c5608c3e 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/index.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/index.ts @@ -11,6 +11,7 @@ import PlainText from './PlainText' import Pagination from './Pagination' import UploadTutorialForm from './UploadTutorialForm' import WelcomeMyTutorials from './WelcomeMyTutorials' +import RedisUploadButton from './RedisUploadButton' export { Code, @@ -26,4 +27,5 @@ export { Pagination, UploadTutorialForm, WelcomeMyTutorials, + RedisUploadButton, } diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/formatter/MarkdownToJsxString.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/formatter/MarkdownToJsxString.ts index a2b0e2c393..889b48b444 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/formatter/MarkdownToJsxString.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/formatter/MarkdownToJsxString.ts @@ -7,6 +7,7 @@ import { visit } from 'unist-util-visit' import { IFormatter, IFormatterConfig } from './formatter.interfaces' import { rehypeLinks } from '../transform/rehypeLinks' +import { remarkRedisUpload } from '../transform/remarkRedisUpload' import { remarkRedisCode } from '../transform/remarkRedisCode' import { remarkImage } from '../transform/remarkImage' @@ -17,6 +18,7 @@ class MarkdownToJsxString implements IFormatter { unified() .use(remarkParse) .use(remarkGfm) // support GitHub Flavored Markdown + .use(remarkRedisUpload, path) // Add custom component for redis-upload code block .use(remarkRedisCode) // Add custom component for Redis code block .use(remarkImage, path) // Add custom component for Redis code block .use(remarkRehype, { allowDangerousHtml: true }) // Pass raw HTML strings through. diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkImage.spec.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkImage.spec.ts index 50474b6c75..0e2747d380 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkImage.spec.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkImage.spec.ts @@ -11,9 +11,9 @@ const testCases = [ result: `${RESOURCES_BASE_URL}${TUTORIAL_PATH}/_images/relative.png`, }, { - url: '/../../../_images/relative.png', // NOTE: will not work in real. There is no sense to even support absolute paths + url: '/_images/relative.png', path: `${TUTORIAL_PATH}/lvl1/lvl2/lvl3/intro.md`, - result: `${RESOURCES_BASE_URL}_images/relative.png`, + result: `${RESOURCES_BASE_URL}${TUTORIAL_PATH}/_images/relative.png`, }, { url: 'https://somesite.test/image.png', diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkRedisUpload.spec.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkRedisUpload.spec.ts new file mode 100644 index 0000000000..ea5b358589 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkRedisUpload.spec.ts @@ -0,0 +1,54 @@ +import { visit } from 'unist-util-visit' +import { remarkRedisUpload } from '../transform/remarkRedisUpload' + +jest.mock('unist-util-visit') + +const getValue = (label: string, path: string) => + `` + +const TUTORIAL_PATH = 'static/custom-tutorials/tutorial-id' + +const testCases = [ + { + lang: 'redis-upload:[../../../_data/strings.txt]', + path: `${TUTORIAL_PATH}/lvl1/lvl2/lvl3/intro.md`, + meta: 'Upload data', + resultPath: `/${TUTORIAL_PATH}/_data/strings.txt` + }, + { + lang: 'redis-upload:[/_data/s t rings.txt]', + path: `${TUTORIAL_PATH}/lvl1/lvl2/lvl3/intro.md`, + meta: 'Upload data', + resultPath: `/${TUTORIAL_PATH}/_data/s t rings.txt` + }, + { + lang: 'redis-upload:[https://somesite.test/image.png]', + path: `${TUTORIAL_PATH}/lvl1/lvl2/lvl3/intro.md`, + meta: 'Upload data', + resultPath: '/image.png', + }, +] + +describe('remarkRedisUpload', () => { + testCases.forEach((tc) => { + it(`should return ${tc.resultPath} + ${tc.meta} for ${tc.lang} ${tc.meta}`, () => { + const node = { + type: 'code', + lang: tc.lang, + meta: tc.meta + }; + + // mock implementation + (visit as jest.Mock) + .mockImplementation((_tree: any, _name: string, callback: (node: any) => void) => { callback(node) }) + + const remark = remarkRedisUpload(tc.path) + remark({} as Node) + expect(node).toEqual({ + ...node, + type: 'html', + value: getValue(tc.meta, tc.resultPath), + }) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/remarkImage.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/remarkImage.ts index ba5aacfdda..1c86d431ab 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/remarkImage.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/remarkImage.ts @@ -1,11 +1,9 @@ import { visit } from 'unist-util-visit' -import { RESOURCES_BASE_URL } from 'uiSrc/services/resourcesService' +import { getFileUrlFromMd } from 'uiSrc/utils/pathUtil' export const remarkImage = (path: string): (tree: Node) => void => (tree: any) => { // Find img node in syntax tree visit(tree, 'image', (node) => { - const pathURL = new URL(path, RESOURCES_BASE_URL) - const url = new URL(node.url, pathURL) - node.url = url.toString() + node.url = getFileUrlFromMd(node.url, path) }) } diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/remarkRedisUpload.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/remarkRedisUpload.ts new file mode 100644 index 0000000000..056f89ed35 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/remarkRedisUpload.ts @@ -0,0 +1,25 @@ +import { visit } from 'unist-util-visit' +import { getFileUrlFromMd } from 'uiSrc/utils/pathUtil' + +export const remarkRedisUpload = (path: string): (tree: Node) => void => (tree: any) => { + // Find code node in syntax tree + visit(tree, 'code', (node) => { + try { + const { lang, meta } = node + + const value: string = `${lang} ${meta}` + const [, filePath, label] = value.match(/^redis-upload:\[(.*)] (.*)/i) || [] + + const { pathname } = new URL(getFileUrlFromMd(filePath, path)) + const decodedPath = decodeURI(pathname) + + if (path && label) { + node.type = 'html' + // Replace it with our custom component + node.value = `` + } + } catch (e) { + // ignore errors + } + }) +} diff --git a/redisinsight/ui/src/services/resourcesService.ts b/redisinsight/ui/src/services/resourcesService.ts index b174b2b3ac..eafc06d145 100644 --- a/redisinsight/ui/src/services/resourcesService.ts +++ b/redisinsight/ui/src/services/resourcesService.ts @@ -13,6 +13,10 @@ const resourcesService = axios.create({ baseURL: RESOURCES_BASE_URL, }) +export const getOriginUrl = () => (IS_ABSOLUTE_PATH.test(RESOURCES_BASE_URL) + ? RESOURCES_BASE_URL + : (window?.location?.origin || RESOURCES_BASE_URL)) + export const getPathToResource = (url: string = ''): string => (IS_ABSOLUTE_PATH.test(url) ? url : new URL(url, resourcesService.defaults.baseURL).toString()) diff --git a/redisinsight/ui/src/slices/interfaces/app.ts b/redisinsight/ui/src/slices/interfaces/app.ts index 397d5b9e62..9a690ef1c2 100644 --- a/redisinsight/ui/src/slices/interfaces/app.ts +++ b/redisinsight/ui/src/slices/interfaces/app.ts @@ -16,6 +16,7 @@ export interface IMessage { title: string message: string group?: string + className?: string } export interface StateAppInfo { diff --git a/redisinsight/ui/src/slices/interfaces/workbench.ts b/redisinsight/ui/src/slices/interfaces/workbench.ts index 94c7220d67..a304df6540 100644 --- a/redisinsight/ui/src/slices/interfaces/workbench.ts +++ b/redisinsight/ui/src/slices/interfaces/workbench.ts @@ -39,6 +39,11 @@ export interface StateWorkbenchEnablementArea { error: string items: IEnablementAreaItem[] } +export interface StateWorkbenchCustomTutorials extends StateWorkbenchEnablementArea { + bulkUpload: { + pathsInProgress: string[] + } +} export interface CommandExecutionUI extends Partial { id?: string diff --git a/redisinsight/ui/src/slices/tests/workbench/wb-custom-tutorials.spec.ts b/redisinsight/ui/src/slices/tests/workbench/wb-custom-tutorials.spec.ts index 13c66a2ba1..8ffeca8649 100644 --- a/redisinsight/ui/src/slices/tests/workbench/wb-custom-tutorials.spec.ts +++ b/redisinsight/ui/src/slices/tests/workbench/wb-custom-tutorials.spec.ts @@ -1,11 +1,13 @@ import { cloneDeep } from 'lodash' import { AxiosError } from 'axios' import { cleanup, initialStateDefault, mockedStore, } from 'uiSrc/utils/test-utils' -import { IEnablementAreaItem } from 'uiSrc/slices/interfaces' +import { IBulkActionOverview, IEnablementAreaItem } from 'uiSrc/slices/interfaces' import { MOCK_CUSTOM_TUTORIALS, MOCK_TUTORIALS_ITEMS } from 'uiSrc/constants' import { apiService } from 'uiSrc/services' -import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import { addErrorNotification, addMessageNotification } from 'uiSrc/slices/app/notifications' +import successMessages from 'uiSrc/components/notifications/success-messages' +import { getFileNameFromPath } from 'uiSrc/utils/pathUtil' import reducer, { initialState, getWBCustomTutorials, @@ -21,6 +23,10 @@ import reducer, { fetchCustomTutorials, deleteCustomTutorial, workbenchCustomTutorialsSelector, + uploadDataBulk, + uploadDataBulkSuccess, + uploadDataBulkFailed, + uploadDataBulkAction, defaultItems, } from '../../workbench/wb-custom-tutorials' @@ -276,6 +282,97 @@ describe('slices', () => { }) }) + describe('uploadDataBulk', () => { + it('should properly set loading for paths', () => { + // Arrange + const state = { + ...initialState, + bulkUpload: { + ...initialState.bulkUpload, + pathsInProgress: ['data/data'] + } + } + + // Act + const nextState = reducer(initialState, uploadDataBulk('data/data')) + + // Assert + const rootState = Object.assign(initialStateDefault, { + workbench: { + customTutorials: nextState, + }, + }) + + expect(workbenchCustomTutorialsSelector(rootState)).toEqual(state) + }) + }) + + describe('uploadDataBulk', () => { + it('should properly remove path from loading', () => { + // Arrange + const currentState = { + ...initialState, + bulkUpload: { + ...initialState.bulkUpload, + pathsInProgress: ['data/data', 'data/another'] + } + } + + const state = { + ...initialState, + bulkUpload: { + ...initialState.bulkUpload, + pathsInProgress: ['data/another'] + } + } + + // Act + const nextState = reducer(currentState, uploadDataBulkSuccess('data/data')) + + // Assert + const rootState = Object.assign(initialStateDefault, { + workbench: { + customTutorials: nextState, + }, + }) + + expect(workbenchCustomTutorialsSelector(rootState)).toEqual(state) + }) + }) + + describe('uploadDataBulkFailed', () => { + it('should properly remove path from loading', () => { + // Arrange + const currentState = { + ...initialState, + bulkUpload: { + ...initialState.bulkUpload, + pathsInProgress: ['data/data'] + } + } + + const state = { + ...initialState, + bulkUpload: { + ...initialState.bulkUpload, + pathsInProgress: [] + } + } + + // Act + const nextState = reducer(currentState, uploadDataBulkFailed('data/data')) + + // Assert + const rootState = Object.assign(initialStateDefault, { + workbench: { + customTutorials: nextState, + }, + }) + + expect(workbenchCustomTutorialsSelector(rootState)).toEqual(state) + }) + }) + // thunks describe('fetchCustomTutorials', () => { @@ -415,4 +512,56 @@ describe('slices', () => { expect(mockedStore.getActions()).toEqual(expectedActions) }) }) + + describe('uploadDataBulkAction', () => { + it('succeed to upload data', async () => { + // Arrange + const instanceId = '1' + const path = 'data/data' + const data = {} + const responsePayload = { status: 200, data } + + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(uploadDataBulkAction(instanceId, path)) + + // Assert + const expectedActions = [ + uploadDataBulk(path), + uploadDataBulkSuccess(path), + addMessageNotification( + successMessages.UPLOAD_DATA_BULK(data as IBulkActionOverview, getFileNameFromPath(path)) + ) + ] + + expect(mockedStore.getActions()).toEqual(expectedActions) + }) + + it('failed to delete tutorial', async () => { + // Arrange + const instanceId = '1' + const path = 'data/data' + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(uploadDataBulkAction(instanceId, path)) + + // Assert + const expectedActions = [ + uploadDataBulk(path), + uploadDataBulkFailed(path), + addErrorNotification(responsePayload as AxiosError), + ] + + expect(mockedStore.getActions()).toEqual(expectedActions) + }) + }) }) diff --git a/redisinsight/ui/src/slices/workbench/wb-custom-tutorials.ts b/redisinsight/ui/src/slices/workbench/wb-custom-tutorials.ts index 6bf5db67b5..8992515a05 100644 --- a/redisinsight/ui/src/slices/workbench/wb-custom-tutorials.ts +++ b/redisinsight/ui/src/slices/workbench/wb-custom-tutorials.ts @@ -1,15 +1,19 @@ import { createSlice } from '@reduxjs/toolkit' import { remove } from 'lodash' +import { AxiosError } from 'axios' import { ApiEndpoints } from 'uiSrc/constants' -import { getApiErrorMessage, isStatusSuccessful, } from 'uiSrc/utils' +import { getApiErrorMessage, getUrl, isStatusSuccessful, } from 'uiSrc/utils' import { apiService } from 'uiSrc/services' import { EnablementAreaComponent, + IBulkActionOverview, IEnablementAreaItem, - StateWorkbenchEnablementArea, + StateWorkbenchCustomTutorials, } from 'uiSrc/slices/interfaces' -import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import { addErrorNotification, addMessageNotification } from 'uiSrc/slices/app/notifications' +import successMessages from 'uiSrc/components/notifications/success-messages' +import { getFileNameFromPath } from 'uiSrc/utils/pathUtil' import { AppDispatch, RootState } from '../store' export const defaultItems: IEnablementAreaItem[] = [ @@ -21,11 +25,14 @@ export const defaultItems: IEnablementAreaItem[] = [ } ] -export const initialState: StateWorkbenchEnablementArea = { +export const initialState: StateWorkbenchCustomTutorials = { loading: false, deleting: false, error: '', items: defaultItems, + bulkUpload: { + pathsInProgress: [] + } } // A slice for recipes @@ -71,11 +78,21 @@ const workbenchCustomTutorialsSlice = createSlice({ state.deleting = false state.error = payload }, + uploadDataBulk: (state, { payload }) => { + state.bulkUpload.pathsInProgress.push(payload) + }, + uploadDataBulkSuccess: (state, { payload }) => { + remove(state.bulkUpload.pathsInProgress, (p) => p === payload) + }, + uploadDataBulkFailed: (state, { payload }) => { + remove(state.bulkUpload.pathsInProgress, (p) => p === payload) + } } }) // A selector export const workbenchCustomTutorialsSelector = (state: RootState) => state.workbench.customTutorials +export const customTutorialsBulkUploadSelector = (state: RootState) => state.workbench.customTutorials.bulkUpload // Actions generated from the slice export const { @@ -87,7 +104,10 @@ export const { uploadWBCustomTutorialFailure, deleteWbCustomTutorial, deleteWBCustomTutorialSuccess, - deleteWBCustomTutorialFailure + deleteWBCustomTutorialFailure, + uploadDataBulk, + uploadDataBulkSuccess, + uploadDataBulkFailed, } = workbenchCustomTutorialsSlice.actions // The reducer @@ -105,7 +125,7 @@ export function fetchCustomTutorials(onSuccessAction?: () => void, onFailAction? onSuccessAction?.() } } catch (error) { - const errorMessage = getApiErrorMessage(error) + const errorMessage = getApiErrorMessage(error as AxiosError) dispatch(getWBCustomTutorialsFailure(errorMessage)) onFailAction?.() } @@ -134,7 +154,8 @@ export function uploadCustomTutorial( dispatch(uploadWBCustomTutorialSuccess(data)) onSuccessAction?.() } - } catch (error) { + } catch (_error) { + const error = _error as AxiosError const errorMessage = getApiErrorMessage(error) dispatch(uploadWBCustomTutorialFailure(errorMessage)) dispatch(addErrorNotification(error)) @@ -153,9 +174,43 @@ export function deleteCustomTutorial(id: string, onSuccessAction?: () => void, o onSuccessAction?.() } } catch (error) { - const errorMessage = getApiErrorMessage(error) + const errorMessage = getApiErrorMessage(error as AxiosError) dispatch(deleteWBCustomTutorialFailure(errorMessage)) - dispatch(addErrorNotification(error)) + dispatch(addErrorNotification(error as AxiosError)) + onFailAction?.() + } + } +} + +export function uploadDataBulkAction( + instanceId: string, + path: string, + onSuccessAction?: () => void, + onFailAction?: () => void +) { + return async (dispatch: AppDispatch) => { + dispatch(uploadDataBulk(path)) + try { + const { status, data } = await apiService.post( + getUrl( + instanceId, + ApiEndpoints.BULK_ACTIONS_IMPORT_TUTORIAL_DATA + ), + { path }, + ) + + if (isStatusSuccessful(status)) { + dispatch(uploadDataBulkSuccess(path)) + dispatch( + addMessageNotification( + successMessages.UPLOAD_DATA_BULK(data as IBulkActionOverview, getFileNameFromPath(path)) + ) + ) + onSuccessAction?.() + } + } catch (error) { + dispatch(uploadDataBulkFailed(path)) + dispatch(addErrorNotification(error as AxiosError)) onFailAction?.() } } diff --git a/redisinsight/ui/src/styles/components/_toasts.scss b/redisinsight/ui/src/styles/components/_toasts.scss index 142023df1f..f59bb97844 100644 --- a/redisinsight/ui/src/styles/components/_toasts.scss +++ b/redisinsight/ui/src/styles/components/_toasts.scss @@ -54,7 +54,21 @@ } } +.euiGlobalToastList { + width: auto !important; + + .euiToast { + width: 340px !important; + + &.dynamic { + width: auto !important; + max-width: 480px !important; + } + } +} + .euiGlobalToastList--right:not(:empty) { + align-items: flex-end !important; right: 14px !important; } diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index 1f1ffb713c..8d5bb9be45 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -73,6 +73,7 @@ --euiToastSuccessBorderColor: #{$euiToastSuccessBorderColor}; --euiToastDangerBtnColor: #{$euiToastDangerBtnColor}; --euiToastDangerBorderColor: #{$euiToastDangerBorderColor}; + --euiToastLightColor: #{$euiColorDarkShade}; // Custom --htmlColor: #{$htmlColor}; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index 5fdef0de41..c6239f5017 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -74,6 +74,7 @@ --euiToastSuccessBorderColor: #{$euiToastSuccessBorderColor}; --euiToastDangerBtnColor: #{$euiToastDangerBtnColor}; --euiToastDangerBorderColor: #{$euiToastDangerBorderColor}; + --euiToastLightColor: #{$euiColorLightestShade}; // Custom --htmlColor: #{$htmlColor}; diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index f7a2112e15..fba2513221 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -118,6 +118,8 @@ export enum TelemetryEvent { WORKBENCH_ENABLEMENT_AREA_TUTORIAL_LINK_CHANGED = 'WORKBENCH_ENABLEMENT_AREA_TUTORIAL_LINK_CHANGED', WORKBENCH_ENABLEMENT_AREA_TUTORIAL_DELETED = 'WORKBENCH_ENABLEMENT_AREA_TUTORIAL_DELETED', WORKBENCH_ENABLEMENT_AREA_TUTORIAL_INFO_CLICKED = 'WORKBENCH_ENABLEMENT_AREA_TUTORIAL_INFO_CLICKED', + WORKBENCH_ENABLEMENT_AREA_DATA_UPLOAD_CLICKED = 'WORKBENCH_ENABLEMENT_AREA_DATA_UPLOAD_CLICKED', + WORKBENCH_ENABLEMENT_AREA_DATA_UPLOAD_SUBMITTED = 'WORKBENCH_ENABLEMENT_AREA_DATA_UPLOAD_SUBMITTED', PROFILER_OPENED = 'PROFILER_OPENED', PROFILER_STARTED = 'PROFILER_STARTED', diff --git a/redisinsight/ui/src/utils/pathUtil.ts b/redisinsight/ui/src/utils/pathUtil.ts new file mode 100644 index 0000000000..d9305955df --- /dev/null +++ b/redisinsight/ui/src/utils/pathUtil.ts @@ -0,0 +1,43 @@ +import { getOriginUrl } from 'uiSrc/services/resourcesService' +import { IS_ABSOLUTE_PATH } from 'uiSrc/constants/regex' + +enum TutorialsPaths { + CustomTutorials = 'custom-tutorials', + Guide = 'guides', + Tutorials = 'tutorials', +} + +export const getRootStaticPath = (mdPath: string) => { + const paths = mdPath?.split('/') || [] + const tutorialFolder = paths[1] + + if (tutorialFolder === TutorialsPaths.CustomTutorials) return paths.slice(0, 3).join('/') + if (tutorialFolder === TutorialsPaths.Guide || tutorialFolder === TutorialsPaths.Tutorials) { + return paths.slice(0, 2).join('/') + } + + return mdPath +} + +const processAbsolutePath = (nodeUrl: string, mdPath: string) => { + // todo: quick fix. find the root cause why path has both '/' and '\' + const normalizedMdPath = mdPath.replaceAll('\\', '/') + const tutorialRootPath = getRootStaticPath(normalizedMdPath) + + return new URL(tutorialRootPath + nodeUrl, getOriginUrl()).toString() +} + +export const getFileUrlFromMd = (nodeUrl: string, mdPath: string): string => { + // process external link + if (IS_ABSOLUTE_PATH.test(nodeUrl)) return nodeUrl + + if (nodeUrl.startsWith('/') || nodeUrl.startsWith('\\')) { + return processAbsolutePath(nodeUrl, mdPath) + } + + // process relative path + const pathUrl = new URL(mdPath, getOriginUrl()) + return new URL(nodeUrl, pathUrl).toString() +} + +export const getFileNameFromPath = (path: string): string => path.split('/').pop() || '' diff --git a/redisinsight/ui/src/utils/tests/pathUtil.spec.ts b/redisinsight/ui/src/utils/tests/pathUtil.spec.ts new file mode 100644 index 0000000000..66619a44c4 --- /dev/null +++ b/redisinsight/ui/src/utils/tests/pathUtil.spec.ts @@ -0,0 +1,41 @@ +import { RESOURCES_BASE_URL } from 'uiSrc/services/resourcesService' +import { getFileUrlFromMd } from '../pathUtil' + +jest.mock('unist-util-visit') +const TUTORIAL_PATH = 'static/custom-tutorials/tutorial-id' +const GUIDES_PATH = 'static/guides' +const testCases = [ + { + url: '../../../_images/relative.png', + path: `${TUTORIAL_PATH}/lvl1/lvl2/lvl3/intro.md`, + result: `${RESOURCES_BASE_URL}${TUTORIAL_PATH}/_images/relative.png`, + }, + { + url: '/_images/relative.png', + path: `${TUTORIAL_PATH}/lvl1/lvl2/lvl3/intro.md`, + result: `${RESOURCES_BASE_URL}${TUTORIAL_PATH}/_images/relative.png`, + }, + { + url: '/_images/relative.png', + path: `${GUIDES_PATH}/lvl1/lvl2/lvl3/intro.md`, + result: `${RESOURCES_BASE_URL}${GUIDES_PATH}/_images/relative.png`, + }, + { + url: '/_images/relative.png', + path: '/unknown-path/lvl1/lvl2/lvl3/intro.md', + result: `${RESOURCES_BASE_URL}unknown-path/lvl1/lvl2/lvl3/intro.md/_images/relative.png`, + }, + { + url: 'https://somesite.test/image.png', + path: `${TUTORIAL_PATH}/lvl1/lvl2/lvl3/intro.md`, + result: 'https://somesite.test/image.png', + } +] +describe('getFileUrlFromMd', () => { + testCases.forEach((tc) => { + it(`should return ${tc.result} for url:${tc.url}, path: ${tc.path} `, () => { + const url = getFileUrlFromMd(tc.url, tc.path) + expect(url).toEqual(tc.result) + }) + }) +}) diff --git a/tests/e2e/helpers/common.ts b/tests/e2e/helpers/common.ts index 2d992c8f10..4a3bc52228 100644 --- a/tests/e2e/helpers/common.ts +++ b/tests/e2e/helpers/common.ts @@ -1,3 +1,6 @@ +import * as path from 'path'; +import * as archiver from 'archiver'; +import * as fs from 'fs'; import { ClientFunction, RequestMock, t } from 'testcafe'; import { Chance } from 'chance'; import { apiUrl, commonUrl } from './conf'; @@ -188,4 +191,30 @@ export class Common { static async getPageUrl(): Promise { return (await ClientFunction(() => window.location.href))(); } + + /** + * Create Zip archive from folder + * @param folderPath Path to folder to archive + * @param zipName Zip archive name + */ + static async createZipFromFolder(folderPath: string, zipName: string): Promise { + const sourceDir = path.join(__dirname, folderPath); + const zipFilePath = path.join(__dirname, zipName); + const output = fs.createWriteStream(zipFilePath); + const archive = archiver('zip', { zlib: { level: 9 } }); + + // Add the contents of the directory to the zip archive + archive.directory(sourceDir, false); + // Finalize the archive and write it to disk + await archive.finalize(); + archive.pipe(output); + } + + /** + * Delete file from folder + * @param folderPath Path to file + */ + static async deleteFileFromFolder(filePath: string): Promise { + fs.unlinkSync(path.join(__dirname, filePath)); + } } diff --git a/tests/e2e/helpers/database.ts b/tests/e2e/helpers/database.ts index 7509915304..9104633b79 100644 --- a/tests/e2e/helpers/database.ts +++ b/tests/e2e/helpers/database.ts @@ -28,7 +28,7 @@ export async function addNewStandaloneDatabase(databaseParameters: AddNewDatabas // Wait for database to be exist .expect(myRedisDatabasePage.dbNameList.withExactText(databaseParameters.databaseName ?? '').exists).ok('The database not displayed', { timeout: 10000 }) // Close message - .click(myRedisDatabasePage.toastCloseButton); + .click(myRedisDatabasePage.Toast.toastCloseButton); } /** @@ -77,7 +77,7 @@ export async function addOSSClusterDatabase(databaseParameters: OSSClusterParame await t .click(myRedisDatabasePage.AddRedisDatabase.addRedisDatabaseButton) // Check for info message that DB was added - .expect(myRedisDatabasePage.databaseInfoMessage.exists).ok('Info message not exists', { timeout: 10000 }) + .expect(myRedisDatabasePage.Toast.toastHeader.exists).ok('Info message not exists', { timeout: 10000 }) // Wait for database to be exist .expect(myRedisDatabasePage.dbNameList.withExactText(databaseParameters.ossClusterDatabaseName).exists).ok('The database not displayed', { timeout: 10000 }); } diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 74f97f0618..7d2eab9230 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -30,11 +30,13 @@ "@types/node": "18.11.9" }, "devDependencies": { + "@types/archiver": "^5.3.2", "@types/chance": "1.1.3", "@types/edit-json-file": "1.7.0", "@types/supertest": "^2.0.8", "@typescript-eslint/eslint-plugin": "4.28.2", "@typescript-eslint/parser": "4.28.2", + "archiver": "^5.3.1", "chance": "1.1.8", "cross-env": "^7.0.3", "dotenv-cli": "^5.0.0", diff --git a/tests/e2e/pageObjects/base-page.ts b/tests/e2e/pageObjects/base-page.ts index c51587b724..e32049177b 100644 --- a/tests/e2e/pageObjects/base-page.ts +++ b/tests/e2e/pageObjects/base-page.ts @@ -1,10 +1,12 @@ import { t } from 'testcafe'; import { NavigationPanel } from './components/navigation-panel'; +import { Toast } from './components/toast'; import { ShortcutsPanel } from './components/shortcuts-panel'; export class BasePage { NavigationPanel = new NavigationPanel(); ShortcutsPanel = new ShortcutsPanel(); + Toast = new Toast(); /** * Reload page diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index de94dd9bf0..fd61c45994 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -61,7 +61,6 @@ export class BrowserPage extends InstancePage { addJsonObjectButton = Selector('[data-testid=add-object-btn]'); addJsonFieldButton = Selector('[data-testid=add-field-btn]'); expandJsonObject = Selector('[data-testid=expand-object]'); - toastCloseButton = Selector('[data-test-subj=toastCloseButton]'); scoreButton = Selector('[data-testid=score-button]'); sortingButton = Selector('[data-testid=header-sorting-button]'); editJsonObjectButton = Selector('[data-testid=edit-object-btn]'); @@ -103,7 +102,6 @@ export class BrowserPage extends InstancePage { editZsetButton = Selector('[data-testid^=zset-edit-button-]'); editListButton = Selector('[data-testid^=edit-list-button-]'); cancelStreamGroupBtn = Selector('[data-testid=cancel-stream-groups-btn]'); - submitTooltipBtn = Selector('[data-testid=submit-tooltip-btn]'); patternModeBtn = Selector('[data-testid=search-mode-pattern-btn]'); redisearchModeBtn = Selector('[data-testid=search-mode-redisearch-btn]'); showFilterHistoryBtn = Selector('[data-testid=show-suggestions-btn]'); @@ -198,7 +196,6 @@ export class BrowserPage extends InstancePage { //TEXT ELEMENTS keySizeDetails = Selector('[data-testid=key-size-text]'); keyLengthDetails = Selector('[data-testid=key-length-text]'); - notificationMessage = Selector('[data-test-subj=euiToastHeader]'); keyNameInTheList = Selector(this.cssSelectorKey); databaseNames = Selector('[data-testid^=db_name_]'); hashFieldsList = Selector('[data-testid^=hash-field-] span'); @@ -265,7 +262,6 @@ export class BrowserPage extends InstancePage { streamConsumerName = Selector('[data-testid^=stream-consumer-]'); consumerGroup = Selector('[data-testid^=stream-group-]'); entryIdInfoIcon = Selector('[data-testid=entry-id-info-icon]'); - errorMessage = Selector('[data-test-subj=toast-error]'); entryIdError = Selector('[data-testid=id-error]'); pendingCount = Selector('[data-testid=pending-count]'); lastRefreshMessage = Selector('[data-testid=refresh-message]'); @@ -442,7 +438,7 @@ export class BrowserPage extends InstancePage { await t.typeText(this.streamValue, value, { replace: true, paste: true }); await t.expect(this.addKeyButton.withAttribute('disabled').exists).notOk('Add Key button not clickable'); await t.click(this.addKeyButton); - await t.click(this.toastCloseButton); + await t.click(this.Toast.toastCloseButton); } /** @@ -545,15 +541,10 @@ export class BrowserPage extends InstancePage { return keyNameInTheList.exists; } - //Getting the text of the Notification message - async getMessageText(): Promise { - return this.notificationMessage.textContent; - } - //Delete key from details async deleteKey(): Promise { - if (await this.toastCloseButton.exists) { - await t.click(this.toastCloseButton); + if (await this.Toast.toastCloseButton.exists) { + await t.click(this.Toast.toastCloseButton); } await t.click(this.keyNameInTheList); await t.click(this.deleteKeyButton); @@ -640,8 +631,8 @@ export class BrowserPage extends InstancePage { * @param keyValue The hash value */ async addFieldToHash(keyFieldValue: string, keyValue: string): Promise { - if (await this.toastCloseButton.exists) { - await t.click(this.toastCloseButton); + if (await this.Toast.toastCloseButton.exists) { + await t.click(this.Toast.toastCloseButton); } await t.click(this.addKeyValueItemsButton); await t.typeText(this.hashFieldInput, keyFieldValue, { replace: true, paste: true }); @@ -734,8 +725,8 @@ export class BrowserPage extends InstancePage { * @param keyMember The value of the set member */ async addMemberToSet(keyMember: string): Promise { - if (await this.toastCloseButton.exists) { - await t.click(this.toastCloseButton); + if (await this.Toast.toastCloseButton.exists) { + await t.click(this.Toast.toastCloseButton); } await t .click(this.addKeyValueItemsButton) @@ -749,8 +740,8 @@ export class BrowserPage extends InstancePage { * @param score The value of the score */ async addMemberToZSet(keyMember: string, score: string): Promise { - if (await this.toastCloseButton.exists) { - await t.click(this.toastCloseButton); + if (await this.Toast.toastCloseButton.exists) { + await t.click(this.Toast.toastCloseButton); } await t .click(this.addKeyValueItemsButton) @@ -784,8 +775,8 @@ export class BrowserPage extends InstancePage { * @param element The value of the list element */ async addElementToList(element: string): Promise { - if (await this.toastCloseButton.exists) { - await t.click(this.toastCloseButton); + if (await this.Toast.toastCloseButton.exists) { + await t.click(this.Toast.toastCloseButton); } await t .click(this.addKeyValueItemsButton) diff --git a/tests/e2e/pageObjects/components/myRedisDatabase/add-redis-database.ts b/tests/e2e/pageObjects/components/myRedisDatabase/add-redis-database.ts index bccbab4391..75539b2f7d 100644 --- a/tests/e2e/pageObjects/components/myRedisDatabase/add-redis-database.ts +++ b/tests/e2e/pageObjects/components/myRedisDatabase/add-redis-database.ts @@ -40,7 +40,6 @@ export class AddRedisDatabase { secretKeyInput = Selector('[data-testid=secret-key]'); welcomePageTitle = Selector('[data-testid=welcome-page-title]'); databaseIndexInput = Selector('[data-testid=db]'); - errorMessage = Selector('[data-test-subj=toast-error]'); databaseIndexMessage = Selector('[data-testid=db-index-message]'); primaryGroupNameInput = Selector('[data-testid=primary-group]'); masterGroupPassword = Selector('[data-testid=sentinel-master-password]'); diff --git a/tests/e2e/pageObjects/components/toast.ts b/tests/e2e/pageObjects/components/toast.ts new file mode 100644 index 0000000000..a3afa68aa7 --- /dev/null +++ b/tests/e2e/pageObjects/components/toast.ts @@ -0,0 +1,11 @@ +import { Selector } from 'testcafe'; + +export class Toast { + toastHeader = Selector('[data-test-subj=euiToastHeader]'); + toastBody = Selector('[class*=euiToastBody]'); + toastSuccess = Selector('[class*=euiToast--success]'); + toastError = Selector('[class*=euiToast--danger]'); + toastCloseButton = Selector('[data-test-subj=toastCloseButton]'); + toastSubmitBtn = Selector('[data-testid=submit-tooltip-btn]'); + toastCancelBtn = Selector('[data-testid=toast-cancel-btn]'); +} diff --git a/tests/e2e/pageObjects/my-redis-databases-page.ts b/tests/e2e/pageObjects/my-redis-databases-page.ts index e648cbd3d3..0ca1642ee3 100644 --- a/tests/e2e/pageObjects/my-redis-databases-page.ts +++ b/tests/e2e/pageObjects/my-redis-databases-page.ts @@ -18,7 +18,7 @@ export class MyRedisDatabasePage extends BasePage { //BUTTONS deleteDatabaseButton = Selector('[data-testid^=delete-instance-]'); confirmDeleteButton = Selector('[data-testid^=delete-instance-]').withExactText('Remove'); - toastCloseButton = Selector('[data-test-subj=toastCloseButton]'); + deleteButtonInPopover = Selector('#deletePopover button'); confirmDeleteAllDbButton = Selector('[data-testid=delete-selected-dbs]'); editDatabaseButton = Selector('[data-testid^=edit-instance]'); @@ -60,7 +60,6 @@ export class MyRedisDatabasePage extends BasePage { moduleQuantifier = Selector('[data-testid=_module]'); dbNameList = Selector('[data-testid^=instance-name]', { timeout: 3000 }); tableRowContent = Selector('[data-test-subj=database-alias-column]'); - databaseInfoMessage = Selector('[data-test-subj=euiToastHeader]'); hostPort = Selector('[data-testid=host-port]'); noResultsFoundMessage = Selector('div').withExactText('No results found'); noResultsFoundText = Selector('div').withExactText('No databases matched your search. Try reducing the criteria.'); @@ -79,8 +78,8 @@ export class MyRedisDatabasePage extends BasePage { * @param dbName The name of the database to be opened */ async clickOnDBByName(dbName: string): Promise { - if (await this.toastCloseButton.exists) { - await t.click(this.toastCloseButton); + if (await this.Toast.toastCloseButton.exists) { + await t.click(this.Toast.toastCloseButton); } const db = this.dbNameList.withExactText(dbName.trim()); await t.expect(db.exists).ok(`"${dbName}" database doesn't exist`, {timeout: 10000}); @@ -103,8 +102,8 @@ export class MyRedisDatabasePage extends BasePage { .click(this.deleteDatabaseButton) .click(this.confirmDeleteButton); } - if (await this.toastCloseButton.exists) { - await t.click(this.toastCloseButton); + if (await this.Toast.toastCloseButton.exists) { + await t.click(this.Toast.toastCloseButton); } } diff --git a/tests/e2e/pageObjects/workbench-page.ts b/tests/e2e/pageObjects/workbench-page.ts index f4cb0f5b8e..74f8b90899 100644 --- a/tests/e2e/pageObjects/workbench-page.ts +++ b/tests/e2e/pageObjects/workbench-page.ts @@ -25,6 +25,7 @@ export class WorkbenchPage extends InstancePage { cssCustomPluginTableResult = '[data-testid^=query-table-result-client]'; cssCommandExecutionDateTime = '[data-testid=command-execution-date-time]'; cssRowInVirtualizedTable = '[data-testid^=row-]'; + cssTutorialDeleteIcon = '[data-testid^=delete-tutorial-icon-]'; //------------------------------------------------------------------------------------------- //DECLARATION OF SELECTORS //*Declare all elements/components of the relevant page. @@ -37,7 +38,7 @@ export class WorkbenchPage extends InstancePage { tutorialOpenUploadButton = Selector('[data-testid=open-upload-tutorial-btn]'); tutorialLinkField = Selector('[data-testid=tutorial-link-field]'); tutorialLatestDeleteIcon = Selector('[data-testid^=delete-tutorial-icon-]').nth(0); - tutorialDeleteButton = Selector('[data-testid^=delete-tutorial-]').withText('Delete'); + tutorialDeleteButton = Selector('button[data-testid^=delete-tutorial-]'); tutorialNameField = Selector('[data-testid=tutorial-name-field]'); tutorialSubmitButton = Selector('[data-testid=submit-upload-tutorial-btn]'); tutorialImport = Selector('[data-testid=import-tutorial]'); @@ -80,6 +81,8 @@ export class WorkbenchPage extends InstancePage { copyCommand = Selector('[data-testid=copy-command]'); redisStackTimeSeriesLoadMorePoints = Selector('[data-testid=preselect-Load more data points]'); documentHashCreateButton = Selector('[data-testid=preselect-auto-Create]'); + uploadDataBulkBtn = Selector('[data-testid=upload-data-bulk-btn]'); + uploadDataBulkApplyBtn = Selector('[data-testid=upload-data-bulk-apply-btn]'); //ICONS noCommandHistoryIcon = Selector('[data-testid=wb_no-results__icon]'); parametersAnchor = Selector('[data-testid=parameters-anchor]'); @@ -248,7 +251,7 @@ export class WorkbenchPage extends InstancePage { * Get selector with tutorial name * @param tutorialName name of the uploaded tutorial */ - async getAccordionButtonWithName(tutorialName: string): Promise { + getAccordionButtonWithName(tutorialName: string): Selector { return Selector(`[data-testid=accordion-button-${tutorialName}]`); } @@ -256,7 +259,7 @@ export class WorkbenchPage extends InstancePage { * Get internal tutorial link with .md name * @param internalLink name of the .md file */ - async getInternalLinkWithManifest(internalLink: string): Promise { + getInternalLinkWithManifest(internalLink: string): Selector { return Selector(`[data-testid="internal-link-${internalLink}.md"]`); } @@ -264,7 +267,7 @@ export class WorkbenchPage extends InstancePage { * Get internal tutorial link without .md name * @param internalLink name of the label */ - async getInternalLinkWithoutManifest(internalLink: string): Promise { + getInternalLinkWithoutManifest(internalLink: string): Selector { return Selector(`[data-testid="internal-link-${internalLink}"]`); } @@ -272,15 +275,28 @@ export class WorkbenchPage extends InstancePage { * Find tutorial selector by name * @param name A tutorial name */ - async getTutorialByName(name: string): Promise { + getTutorialByName(name: string): Selector { return Selector('div').withText(name); } + /** + * Delete tutorial by name + * @param name A tutorial name + */ + async deleteTutorialByName(name: string): Promise { + const deleteTutorialBtn = this.tutorialAccordionButton.withText(name).find(this.cssTutorialDeleteIcon); + if (await this.closeEnablementPage.exists) { + await t.click(this.closeEnablementPage); + } + await t.click(deleteTutorialBtn); + await t.click(this.tutorialDeleteButton); + } + /** * Find image in tutorial by alt text * @param alt Image alt text */ - async getTutorialImageByAlt(alt: string): Promise { + getTutorialImageByAlt(alt: string): Selector { return Selector('img').withAttribute('alt', alt); } diff --git a/tests/e2e/test-data/upload-tutorials/customTutorials.zip b/tests/e2e/test-data/upload-tutorials/customTutorials.zip deleted file mode 100644 index c71a78c437..0000000000 Binary files a/tests/e2e/test-data/upload-tutorials/customTutorials.zip and /dev/null differ diff --git a/tests/e2e/test-data/upload-tutorials/customTutorials/_images/image.png b/tests/e2e/test-data/upload-tutorials/customTutorials/_images/image.png new file mode 100644 index 0000000000..ac4148695b Binary files /dev/null and b/tests/e2e/test-data/upload-tutorials/customTutorials/_images/image.png differ diff --git a/tests/e2e/test-data/upload-tutorials/customTutorials/_upload/bulkUplAllKeyTypes.txt b/tests/e2e/test-data/upload-tutorials/customTutorials/_upload/bulkUplAllKeyTypes.txt new file mode 100644 index 0000000000..3b71461d24 --- /dev/null +++ b/tests/e2e/test-data/upload-tutorials/customTutorials/_upload/bulkUplAllKeyTypes.txt @@ -0,0 +1,9 @@ +HSET hashkey1 'field' 'value' +LPUSH listkey1 'element' +SADD setkey1 'member' +ZADD zsetkey1 1 'member' +SET stringkey1 'value' +JSON.SET jsonkey1 . '1' +XADD streamkey1 * 'field' 'value' +GRAPH.QUERY graphkey1 "CREATE ()" +TS.CREATE tskey1 \ No newline at end of file diff --git a/tests/e2e/test-data/upload-tutorials/customTutorials/_upload/bulkUplString.txt b/tests/e2e/test-data/upload-tutorials/customTutorials/_upload/bulkUplString.txt new file mode 100644 index 0000000000..1c94a82bcf --- /dev/null +++ b/tests/e2e/test-data/upload-tutorials/customTutorials/_upload/bulkUplString.txt @@ -0,0 +1 @@ +SET stringkey1test 'value' \ No newline at end of file diff --git a/tests/e2e/test-data/upload-tutorials/customTutorials/folder-1/probably-1.md b/tests/e2e/test-data/upload-tutorials/customTutorials/folder-1/probably-1.md new file mode 100644 index 0000000000..d392e2d292 --- /dev/null +++ b/tests/e2e/test-data/upload-tutorials/customTutorials/folder-1/probably-1.md @@ -0,0 +1,56 @@ +In very broad terms probabilistic data structures (PDS) allow us to get to a "close enough" result in a much shorter time and by using significantly less memory. + +Relative path button: + +```redis-upload:[../_upload/bulkUplAllKeyTypes.txt] Upload relative +``` + +Relative path long name button: + +```redis-upload:[../../_upload/bulkUplAllKeyTypes.txt] Longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname longname +``` + +Absolute path button: + +```redis-upload:[/_upload/bulkUplString.txt] Upload absolute +``` + +External: + +![RedisInsight screen external](https://github.com/RedisInsight/RedisInsight/blob/main/.github/redisinsight_browser.png?raw=true) + +Invalid absolute path button: + +```redis-upload:[/_upload/bulkUplAllKeyTypes] Invalid absolute +``` + +Invalid relative path button: + +```redis-upload:[../../_upload/bulkUplAllKeyTypes.txt] Invalid relative +``` + +Redis Stack supports 4 of the most famous PDS: +- Bloom filters +- Cuckoo filters +- Count-Min Sketch +- Top-K + +In the rest of this tutorial we'll introduce how you can use a Bloom filter to save many heavy calls to the relational database, or a lot of memory, compared to using sets or hashes. +A Bloom filter is a probabilistic data structure that enables you to check if an element is present in a set using a very small memory space of a fixed size. **It can guarantee the absence of an element from a set, but it can only give an estimation about its presence**. So when it responds that an element is not present in a set (a negative answer), you can be sure that indeed is the case. However, one out of every N positive answers will be wrong. +Even though it looks unusual at a first glance, this kind of uncertainty still has its place in computer science. There are many cases out there where a negative answer will prevent very costly operations; + +How can a Bloom filter be useful to our bike shop? For starters, we could keep a Bloom filter that stores all usernames of people who've already registered with our service. That way, when someone is creating a new account we can very quickly check if that username is free. If the answer is yes, we'd still have to go and check the main database for the precise result, but if the answer is no, we can skip that call and continue with the registration. + +Another, perhaps more interesting example is for showing better and more relevant ads to users. We could keep a bloom filter per user with all the products they've bought from the shop, and when we get a list of products from our suggestion engine we could check it against this filter. + + +```redis Add all bought product ids in the Bloom filter +BF.MADD user:778:bought_products 4545667 9026875 3178945 4848754 1242449 +``` + +Just before we try to show an ad to a user, we can first check if that product id is already in their "bought products" Bloom filter. If the answer is yes - we might choose to check the main database, or we might skip to the next recommendation from our list. But if the answer is no, then we know for sure that our user hasn't bought that product: + +```redis Has a user bought this product? +BF.EXISTS user:778:bought_products 1234567 // No, the user has not bought this product +BF.EXISTS user:778:bought_products 3178945 // The user might have bought this product +``` diff --git a/tests/e2e/test-data/upload-tutorials/customTutorials/folder-2/vector-2.md b/tests/e2e/test-data/upload-tutorials/customTutorials/folder-2/vector-2.md new file mode 100644 index 0000000000..eb64bd0b91 --- /dev/null +++ b/tests/e2e/test-data/upload-tutorials/customTutorials/folder-2/vector-2.md @@ -0,0 +1 @@ +In very broad terms probabilistic data structures (PDS) allow us to get to a "close enough" result in a much shorter time and by using significantly less memory. \ No newline at end of file diff --git a/tests/e2e/tests/critical-path/browser/bulk-upload.e2e.ts b/tests/e2e/tests/critical-path/browser/bulk-upload.e2e.ts index ec0da5b1dd..22c2726be2 100644 --- a/tests/e2e/tests/critical-path/browser/bulk-upload.e2e.ts +++ b/tests/e2e/tests/critical-path/browser/bulk-upload.e2e.ts @@ -20,6 +20,7 @@ const verifyCompletedResultText = async(resultsText: string[]): Promise => for (const result of resultsText) { await t.expect(browserPage.BulkActions.bulkUploadCompletedSummary.textContent).contains(result, 'Bulk upload completed summary not correct'); } + await t.expect(browserPage.BulkActions.bulkUploadCompletedSummary.textContent).notContains('0:00:00.00', 'Bulk upload Time taken not correct'); }; fixture `Bulk Upload` @@ -49,7 +50,6 @@ test('Verify bulk upload of different text docs formats', async t => { // Verify that keys of all types can be uploaded await browserPage.BulkActions.uploadFileInBulk(filePathes.allKeysFile); await verifyCompletedResultText(allKeysResults); - await t.expect(browserPage.BulkActions.bulkUploadCompletedSummary.textContent).notContains('0:00:00.00', 'Bulk upload completed summary not correct'); await browserPage.searchByKeyName('*key1'); await verifyKeysDisplayedInTheList(keyNames); diff --git a/tests/e2e/tests/critical-path/browser/json-key.e2e.ts b/tests/e2e/tests/critical-path/browser/json-key.e2e.ts index 6707c5455d..1a86f5b0d1 100644 --- a/tests/e2e/tests/critical-path/browser/json-key.e2e.ts +++ b/tests/e2e/tests/critical-path/browser/json-key.e2e.ts @@ -27,9 +27,9 @@ test('Verify that user can not add invalid JSON structure inside of created JSON // Add Json key with json object await browserPage.addJsonKey(keyName, value, keyTTL); // Check the notification message - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Key has been added', 'The notification'); - await t.click(browserPage.toastCloseButton); + await t.click(browserPage.Toast.toastCloseButton); // Add key with value on the same level await browserPage.addJsonKeyOnTheSameLevel('"key1"', '{}'); // Add invalid JSON structure diff --git a/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts b/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts index 145f6bfd6a..bb8ea4c45c 100644 --- a/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts @@ -35,7 +35,7 @@ test // Verify that user can test Standalone connection on edit and see the success message await t.click(myRedisDatabasePage.AddRedisDatabase.testConnectionBtn); - await t.expect(myRedisDatabasePage.databaseInfoMessage.textContent).contains('Connection is successful', 'Standalone connection is not successful'); + await t.expect(myRedisDatabasePage.Toast.toastHeader.textContent).contains('Connection is successful', 'Standalone connection is not successful'); // Verify that user can cancel the Clone by clicking the “Cancel” or the “x” button await t.click(myRedisDatabasePage.AddRedisDatabase.cloneDatabaseButton); @@ -75,7 +75,7 @@ test // Verify that user can test OSS Cluster connection on edit and see the success message await t.click(myRedisDatabasePage.AddRedisDatabase.testConnectionBtn); - await t.expect(myRedisDatabasePage.databaseInfoMessage.textContent).contains('Connection is successful', 'OSS Cluster connection is not successful'); + await t.expect(myRedisDatabasePage.Toast.toastHeader.textContent).contains('Connection is successful', 'OSS Cluster connection is not successful'); await t.click(myRedisDatabasePage.AddRedisDatabase.cloneDatabaseButton); await t @@ -109,7 +109,7 @@ test // Verify that user can test Sentinel connection on edit and see the success message await t.click(myRedisDatabasePage.AddRedisDatabase.testConnectionBtn); - await t.expect(myRedisDatabasePage.databaseInfoMessage.textContent).contains('Connection is successful', 'Sentinel connection is not successful'); + await t.expect(myRedisDatabasePage.Toast.toastHeader.textContent).contains('Connection is successful', 'Sentinel connection is not successful'); // Verify that for Sentinel Host and Port fields are replaced with editable Primary Group Name field await t diff --git a/tests/e2e/tests/critical-path/database/connecting-to-the-db.e2e.ts b/tests/e2e/tests/critical-path/database/connecting-to-the-db.e2e.ts index f64649c5b4..9c65a48f75 100644 --- a/tests/e2e/tests/critical-path/database/connecting-to-the-db.e2e.ts +++ b/tests/e2e/tests/critical-path/database/connecting-to-the-db.e2e.ts @@ -45,13 +45,13 @@ test // Verify that when user request to test database connection is not successfull, can see standart connection error await t.click(myRedisDatabasePage.AddRedisDatabase.testConnectionBtn); - await t.expect(myRedisDatabasePage.databaseInfoMessage.textContent).contains('Error', 'Invalid connection has no error on test'); + await t.expect(myRedisDatabasePage.Toast.toastHeader.textContent).contains('Error', 'Invalid connection has no error on test'); // Click for saving await t.click(myRedisDatabasePage.AddRedisDatabase.addRedisDatabaseButton); // Verify that the database is not in the list - await t.expect(myRedisDatabasePage.AddRedisDatabase.errorMessage.textContent).contains('Error', 'Error message not displayed', { timeout: 10000 }); - await t.expect(myRedisDatabasePage.AddRedisDatabase.errorMessage.textContent).contains(errorMessage, 'Error message not displayed', { timeout: 10000 }); + await t.expect(myRedisDatabasePage.Toast.toastError.textContent).contains('Error', 'Error message not displayed', { timeout: 10000 }); + await t.expect(myRedisDatabasePage.Toast.toastError.textContent).contains(errorMessage, 'Error message not displayed', { timeout: 10000 }); }); test .meta({ rte: rte.none })('Fields to add database prepopulation', async t => { diff --git a/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts b/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts index 40e4b99e8d..6bb364fd10 100644 --- a/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts @@ -22,7 +22,7 @@ test('Verify that user can add DB with logical index via host and port from Add // Verify that user can test database connection and see success message await t.click(myRedisDatabasePage.AddRedisDatabase.testConnectionBtn); - await t.expect(myRedisDatabasePage.databaseInfoMessage.textContent).contains('Connection is successful', 'Standalone connection is not successful'); + await t.expect(myRedisDatabasePage.Toast.toastHeader.textContent).contains('Connection is successful', 'Standalone connection is not successful'); // Enter logical index await t.click(myRedisDatabasePage.AddRedisDatabase.databaseIndexCheckbox); diff --git a/tests/e2e/tests/critical-path/memory-efficiency/memory-efficiency.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/memory-efficiency.e2e.ts index 91524abf05..5179407fa3 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/memory-efficiency.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/memory-efficiency.e2e.ts @@ -57,8 +57,8 @@ test await browserPage.addHashKey(hashKeyName, keysTTL[2], hashValue); await browserPage.addStreamKey(streamKeyName, 'field', 'value', keysTTL[2]); await browserPage.addStreamKey(streamKeyNameDelimiter, 'field', 'value', keysTTL[2]); - if (await browserPage.submitTooltipBtn.exists) { - await t.click(browserPage.submitTooltipBtn); + if (await browserPage.Toast.toastSubmitBtn.exists) { + await t.click(browserPage.Toast.toastSubmitBtn); } await browserPage.Cli.addKeysFromCliWithDelimiter('MSET', 15); await t.click(browserPage.treeViewButton); diff --git a/tests/e2e/tests/regression/browser/add-keys.e2e.ts b/tests/e2e/tests/regression/browser/add-keys.e2e.ts index 109cbd416d..c042d11157 100644 --- a/tests/e2e/tests/regression/browser/add-keys.e2e.ts +++ b/tests/e2e/tests/regression/browser/add-keys.e2e.ts @@ -33,8 +33,8 @@ test('Verify that user can create different types(string, number, null, array, b for (let i = 0; i < jsonKeys.length; i++) { const keySelector = await browserPage.getKeySelectorByName(jsonKeys[i][0]); await browserPage.addJsonKey(jsonKeys[i][0], jsonKeys[i][1]); - await t.hover(browserPage.toastCloseButton); - await t.click(browserPage.toastCloseButton); + await t.hover(browserPage.Toast.toastCloseButton); + await t.click(browserPage.Toast.toastCloseButton); await t.click(browserPage.refreshKeysButton); await t.expect(keySelector.exists).ok(`${jsonKeys[i][0]} key not displayed`); // Add additional check for array elements diff --git a/tests/e2e/tests/regression/browser/consumer-group.e2e.ts b/tests/e2e/tests/regression/browser/consumer-group.e2e.ts index f067d17865..b3a0b6edff 100644 --- a/tests/e2e/tests/regression/browser/consumer-group.e2e.ts +++ b/tests/e2e/tests/regression/browser/consumer-group.e2e.ts @@ -61,7 +61,7 @@ test('Verify that when user enter invalid Group Name the error message appears', // Verify the error message await t.click(browserPage.streamTabGroups); await browserPage.createConsumerGroup(consumerGroupName); - await t.expect(browserPage.errorMessage.textContent).contains(error, 'The error message that the Group name already exists not displayed'); + await t.expect(browserPage.Toast.toastError.textContent).contains(error, 'The error message that the Group name already exists not displayed'); }); test('Verify that user can sort Consumer Group column: A>Z / Z>A(A>Z is default table sorting)', async t => { keyName = Common.generateWord(20); diff --git a/tests/e2e/tests/regression/browser/keys-all-databases.e2e.ts b/tests/e2e/tests/regression/browser/keys-all-databases.e2e.ts index 5d73e726c2..642721dc1f 100644 --- a/tests/e2e/tests/regression/browser/keys-all-databases.e2e.ts +++ b/tests/e2e/tests/regression/browser/keys-all-databases.e2e.ts @@ -35,7 +35,7 @@ const verifyKeysAdded = async(): Promise => { // Add Hash key await browserPage.addHashKey(keyName); // Check the notification message - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Key has been added', 'The notification not correct'); // Check that new key is displayed in the list await browserPage.searchByKeyName(keyName); diff --git a/tests/e2e/tests/regression/browser/stream-pending-messages.e2e.ts b/tests/e2e/tests/regression/browser/stream-pending-messages.e2e.ts index cef02b9d1d..0e6320a3ba 100644 --- a/tests/e2e/tests/regression/browser/stream-pending-messages.e2e.ts +++ b/tests/e2e/tests/regression/browser/stream-pending-messages.e2e.ts @@ -75,7 +75,7 @@ test('Verify that the message is claimed only if its idle time is greater than t await t.click(browserPage.claimPendingMessageButton); await t.typeText(browserPage.streamMinIdleTimeInput, '100000000', { replace: true, paste: true }); await t.click(browserPage.submitButton); - await t.expect(browserPage.notificationMessage.textContent).contains('No messages claimed', 'The message is not claimed notification'); + await t.expect(browserPage.Toast.toastHeader.textContent).contains('No messages claimed', 'The message is not claimed notification'); await t.expect(browserPage.streamMessage.count).eql(streamMessageBefore, 'The number of pendings in the table not correct'); }); test('Verify that when user toggle optional parameters on, he can see optional fields', async t => { diff --git a/tests/e2e/tests/regression/browser/upload-json-key.e2e.ts b/tests/e2e/tests/regression/browser/upload-json-key.e2e.ts index f5f615ba30..1f6d4438e7 100644 --- a/tests/e2e/tests/regression/browser/upload-json-key.e2e.ts +++ b/tests/e2e/tests/regression/browser/upload-json-key.e2e.ts @@ -34,7 +34,7 @@ test('Verify that user can insert a JSON from .json file on the form to add a JS await t.typeText(browserPage.addKeyNameInput, keyName, { replace: true, paste: true }); await t.setFilesToUpload(browserPage.jsonUploadInput, [filePath]); await t.click(browserPage.addKeyButton); - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Key has been added', 'The key added notification not found'); // Verify that user can see the JSON value populated from the file when the insert is successful. for (const el of jsonValues) { diff --git a/tests/e2e/tests/regression/workbench/import-tutorials.e2e.ts b/tests/e2e/tests/regression/workbench/import-tutorials.e2e.ts index b24343b184..f2122892ef 100644 --- a/tests/e2e/tests/regression/workbench/import-tutorials.e2e.ts +++ b/tests/e2e/tests/regression/workbench/import-tutorials.e2e.ts @@ -1,22 +1,33 @@ import * as path from 'path'; +import { t } from 'testcafe'; import { rte } from '../../../helpers/constants'; import { acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../pageObjects'; -import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../pageObjects'; +import { commonUrl, ossStandaloneConfig, ossStandaloneRedisearch } from '../../../helpers/conf'; import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; +import { Common } from '../../../helpers/common'; +import { deleteAllKeysFromDB, verifyKeysDisplayedInTheList } from '../../../helpers/keys'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); -const filePath = path.join('..', '..', '..', 'test-data', 'upload-tutorials', 'customTutorials.zip'); -const tutorialName = 'customTutorials'; -const tutorialName2 = 'tutorialTestByLink'; -const link = 'https://drive.google.com/uc?id=1puRUoT8HmyZCekkeWNxBzXe_48TzXcJc&export=download'; -let folder1 = 'folder-1'; -let folder2 = 'folder-2'; +const browserPage = new BrowserPage(); +const zipFolderName = 'customTutorials'; +const folderPath = path.join('..', 'test-data', 'upload-tutorials', zipFolderName); +const folder1 = 'folder-1'; +const folder2 = 'folder-2'; +const internalLinkName2 = 'vector-2'; +let tutorialName = `${zipFolderName}${Common.generateWord(5)}`; +let zipFilePath = path.join('..', 'test-data', 'upload-tutorials', `${tutorialName}.zip`); let internalLinkName1 = 'probably-1'; -let internalLinkName2 = 'vector-2'; +const verifyCompletedResultText = async(resultsText: string[]): Promise => { + for (const result of resultsText) { + await t.expect(workbenchPage.Toast.toastBody.textContent).contains(result, 'Bulk upload completed summary not correct'); + } + await t.expect(workbenchPage.Toast.toastBody.textContent).notContains('0:00:00.000', 'Bulk upload Time taken not correct'); + await t.click(workbenchPage.Toast.toastSubmitBtn); +}; fixture `Upload custom tutorials` .meta({ type: 'regression', rte: rte.standalone }) @@ -25,84 +36,100 @@ fixture `Upload custom tutorials` await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); }) - .afterEach(async () => { + .afterEach(async() => { await deleteStandaloneDatabaseApi(ossStandaloneConfig); }); /* https://redislabs.atlassian.net/browse/RI-4186, https://redislabs.atlassian.net/browse/RI-4198, https://redislabs.atlassian.net/browse/RI-4302, https://redislabs.atlassian.net/browse/RI-4318 */ -test('Verify that user can upload tutorial with local zip file without manifest.json', async t => { - // Verify that user can upload custom tutorials on docker version - folder1 = 'folder-1'; - folder2 = 'folder-2'; - internalLinkName1 = 'probably-1'; - internalLinkName2 = 'vector-2'; +test + .before(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + tutorialName = `${zipFolderName}${Common.generateWord(5)}`; + zipFilePath = path.join('..', 'test-data', 'upload-tutorials', `${tutorialName}.zip`); + // Create zip file for uploading + await Common.createZipFromFolder(folderPath, zipFilePath); + }) + .after(async() => { + // Delete zip file + await Common.deleteFileFromFolder(zipFilePath); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Verify that user can upload tutorial with local zip file without manifest.json', async t => { + // Verify that user can upload custom tutorials on docker version + internalLinkName1 = 'probably-1'; + const imageExternalPath = 'RedisInsight screen external'; + // const imageRelativePath = 'RedisInsight screen relative'; - // Verify that user can see the “MY TUTORIALS” section in the Enablement area. - await t.expect(workbenchPage.customTutorials.visible).ok('custom tutorials sections is not visible'); - await t.click(workbenchPage.tutorialOpenUploadButton); - await t.expect(workbenchPage.tutorialSubmitButton.hasAttribute('disabled')).ok('submit button is not disabled'); + // Verify that user can see the “MY TUTORIALS” section in the Enablement area. + await t.expect(workbenchPage.customTutorials.visible).ok('custom tutorials sections is not visible'); + await t.click(workbenchPage.tutorialOpenUploadButton); + await t.expect(workbenchPage.tutorialSubmitButton.hasAttribute('disabled')).ok('submit button is not disabled'); - // Verify that User can request to add a new custom Tutorial by uploading a .zip archive from a local folder - await t.setFilesToUpload(workbenchPage.tutorialImport, [filePath]); - await t.click(workbenchPage.tutorialSubmitButton); - await t.expect(workbenchPage.tutorialAccordionButton.withText(tutorialName).visible).ok(`${tutorialName} tutorial is not uploaded`); + // Verify that User can request to add a new custom Tutorial by uploading a .zip archive from a local folder + await t.setFilesToUpload(workbenchPage.tutorialImport, [zipFilePath]); + await t.click(workbenchPage.tutorialSubmitButton); + await t.expect(workbenchPage.tutorialAccordionButton.withText(tutorialName).visible).ok(`${tutorialName} tutorial is not uploaded`); - // Verify that when user upload a .zip archive without a .json manifest, all markdown files are inserted at the same hierarchy level - await t.click(workbenchPage.tutorialAccordionButton.withText(tutorialName)); - await t.expect((await workbenchPage.getAccordionButtonWithName(folder1)).visible).ok(`${folder1} is not visible`); - await t.expect((await workbenchPage.getAccordionButtonWithName(folder2)).visible).ok(`${folder2} is not visible`); - await t.click(await workbenchPage.getAccordionButtonWithName(folder1)); - await t.expect((await workbenchPage.getInternalLinkWithManifest(internalLinkName1)).visible) - .ok(`${internalLinkName1} is not visible`); - await t.click(await workbenchPage.getAccordionButtonWithName(folder2)); - await t.expect((await workbenchPage.getInternalLinkWithManifest(internalLinkName2)).visible) - .ok(`${internalLinkName2} is not visible`); - await t.expect(workbenchPage.scrolledEnablementArea.exists).notOk('enablement area is visible before clicked'); - await t.click((await workbenchPage.getInternalLinkWithManifest(internalLinkName1))); - await t.expect(workbenchPage.scrolledEnablementArea.visible).ok('enablement area is not visible after clicked'); + // Verify that when user upload a .zip archive without a .json manifest, all markdown files are inserted at the same hierarchy level + await t.click(workbenchPage.tutorialAccordionButton.withText(tutorialName)); + await t.expect(workbenchPage.getAccordionButtonWithName(folder1).visible).ok(`${folder1} is not visible`); + await t.expect(workbenchPage.getAccordionButtonWithName(folder2).visible).ok(`${folder2} is not visible`); + await t.click(workbenchPage.getAccordionButtonWithName(folder1)); + await t.expect(workbenchPage.getInternalLinkWithManifest(internalLinkName1).visible) + .ok(`${internalLinkName1} is not visible`); + await t.click(workbenchPage.getAccordionButtonWithName(folder2)); + await t.expect(workbenchPage.getInternalLinkWithManifest(internalLinkName2).visible) + .ok(`${internalLinkName2} is not visible`); + await t.expect(workbenchPage.scrolledEnablementArea.exists).notOk('enablement area is visible before clicked'); + await t.click(workbenchPage.getInternalLinkWithManifest(internalLinkName1)); + await t.expect(workbenchPage.scrolledEnablementArea.visible).ok('enablement area is not visible after clicked'); - // Error when github upload .zip with relative path in .md - // const imageExternalPath = 'RedisInsight screen external'; - // const imageRelativePath = 'RedisInsight screen relative'; - // Verify that user can see image in custom tutorials by providing absolute external path in md file - // const imageExternal = await workbenchPage.getTutorialImageByAlt(imageExternalPath); - // await workbenchPage.waitUntilImageRendered(imageExternal); - // const imageExternalHeight = await imageExternal.getStyleProperty('height'); - // await t.expect(parseInt(imageExternalHeight.replace(/[^\d]/g, ''))).gte(150); - // Verify that user can see image in custom tutorials by providing relative path in md file - // const imageRelative = await workbenchPage.getTutorialImageByAlt(imageRelativePath); - // await workbenchPage.waitUntilImageRendered(imageRelative); - // const imageRelativeHeight = await imageRelative.getStyleProperty('height'); - // await t.expect(parseInt(imageRelativeHeight.replace(/[^\d]/g, ''))).gte(150); + // Verify that user can see image in custom tutorials by providing absolute external path in md file + const imageExternal = workbenchPage.getTutorialImageByAlt(imageExternalPath); + await workbenchPage.waitUntilImageRendered(imageExternal); + const imageExternalHeight = await imageExternal.getStyleProperty('height'); + await t.expect(parseInt(imageExternalHeight.replace(/[^\d]/g, ''))).gte(150); - // Verify that when User delete the tutorial, then User can see this tutorial and relevant markdown files are deleted from: the Enablement area in Workbench - await t.click(workbenchPage.closeEnablementPage); - await t.click(workbenchPage.tutorialLatestDeleteIcon); - await t.expect(workbenchPage.tutorialDeleteButton.visible).ok('Delete popup is not visible'); - await t.click(workbenchPage.tutorialDeleteButton); - await t.expect(workbenchPage.tutorialDeleteButton.exists).notOk('Delete popup is still visible'); - await t.expect((workbenchPage.tutorialAccordionButton.withText(tutorialName).exists)) - .notOk(`${tutorialName} tutorial is not uploaded`); -}); + /* Uncomment after fix https://redislabs.atlassian.net/browse/RI-4486 + also need to add in probably-1.md file: + Relative: + ![RedisInsight screen relative](../_images/image.png) */ + // Verify that user can see image in custom tutorials by providing relative path in md file + // const imageRelative = await workbenchPage.getTutorialImageByAlt(imageRelativePath); + // await workbenchPage.waitUntilImageRendered(imageRelative); + // const imageRelativeHeight = await imageRelative.getStyleProperty('height'); + // await t.expect(parseInt(imageRelativeHeight.replace(/[^\d]/g, ''))).gte(150); + + // Verify that when User delete the tutorial, then User can see this tutorial and relevant markdown files are deleted from: the Enablement area in Workbench + await t.click(workbenchPage.closeEnablementPage); + await t.click(workbenchPage.tutorialLatestDeleteIcon); + await t.expect(workbenchPage.tutorialDeleteButton.visible).ok('Delete popup is not visible'); + await t.click(workbenchPage.tutorialDeleteButton); + await t.expect(workbenchPage.tutorialDeleteButton.exists).notOk('Delete popup is still visible'); + await t.expect(workbenchPage.tutorialAccordionButton.withText(tutorialName).exists) + .notOk(`${tutorialName} tutorial is not uploaded`); + }); // https://redislabs.atlassian.net/browse/RI-4186, https://redislabs.atlassian.net/browse/RI-4213, https://redislabs.atlassian.net/browse/RI-4302 test('Verify that user can upload tutorial with URL with manifest.json', async t => { const labelFromManifest = 'LabelFromManifest'; + const link = 'https://drive.google.com/uc?id=1puRUoT8HmyZCekkeWNxBzXe_48TzXcJc&export=download'; internalLinkName1 = 'manifest-id'; + tutorialName = 'tutorialTestByLink'; await t.click(workbenchPage.tutorialOpenUploadButton); // Verify that user can upload tutorials using a URL await t.typeText(workbenchPage.tutorialLinkField, link); await t.click(workbenchPage.tutorialSubmitButton); - await t.expect(workbenchPage.tutorialAccordionButton.withText(tutorialName2).with({ timeout: 20000 }).visible) - .ok(`${tutorialName2} tutorial is not uploaded`); - await t.click(workbenchPage.tutorialAccordionButton.withText(tutorialName2)); + await t.expect(workbenchPage.tutorialAccordionButton.withText(tutorialName).with({ timeout: 20000 }).visible) + .ok(`${tutorialName} tutorial is not uploaded`); + await t.click(workbenchPage.tutorialAccordionButton.withText(tutorialName)); // Verify that User can see the same structure in the tutorial uploaded as described in the .json manifest - await t.expect((await workbenchPage.getInternalLinkWithoutManifest(internalLinkName1)).visible) + await t.expect(workbenchPage.getInternalLinkWithoutManifest(internalLinkName1).visible) .ok(`${internalLinkName1} folder specified in manifest is not visible`); - await t.expect(await (await workbenchPage.getInternalLinkWithoutManifest(internalLinkName1)).textContent) + await t.expect(workbenchPage.getInternalLinkWithoutManifest(internalLinkName1).textContent) .eql(labelFromManifest, `${labelFromManifest} tutorial specified in manifest is not visible`); - await t.click((await workbenchPage.getInternalLinkWithoutManifest(internalLinkName1))); + await t.click(workbenchPage.getInternalLinkWithoutManifest(internalLinkName1)); await t.expect(workbenchPage.scrolledEnablementArea.visible).ok('enablement area is not visible after clicked'); await t.click(workbenchPage.closeEnablementPage); await t.click(workbenchPage.tutorialLatestDeleteIcon); @@ -110,6 +137,72 @@ test('Verify that user can upload tutorial with URL with manifest.json', async t await t.click(workbenchPage.tutorialDeleteButton); await t.expect(workbenchPage.tutorialDeleteButton.exists).notOk('Delete popup is still visible'); // Verify that when User delete the tutorial, then User can see this tutorial and relevant markdown files are deleted from: the Enablement area in Workbench - await t.expect((workbenchPage.tutorialAccordionButton.withText(tutorialName2).exists)) - .notOk(`${tutorialName2} tutorial is not uploaded`); + await t.expect(workbenchPage.tutorialAccordionButton.withText(tutorialName).exists) + .notOk(`${tutorialName} tutorial is not uploaded`); }); +// https://redislabs.atlassian.net/browse/RI-4352 +test + .before(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneRedisearch, ossStandaloneRedisearch.databaseName); + await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + tutorialName = `${zipFolderName}${Common.generateWord(5)}`; + zipFilePath = path.join('..', 'test-data', 'upload-tutorials', `${tutorialName}.zip`); + // Create zip file for uploading + await Common.createZipFromFolder(folderPath, zipFilePath); + }) + .after(async() => { + await Common.deleteFileFromFolder(zipFilePath); + await deleteAllKeysFromDB(ossStandaloneRedisearch.host, ossStandaloneRedisearch.port); + // Clear and delete database + await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await workbenchPage.deleteTutorialByName(tutorialName); + await t.expect(workbenchPage.tutorialAccordionButton.withText(tutorialName).exists) + .notOk(`${tutorialName} tutorial is not deleted`); + await deleteStandaloneDatabaseApi(ossStandaloneRedisearch); + })('Verify that user can bulk upload data from custom tutorial', async t => { + const allKeysResults = ['9Commands Processed', '9Success', '0Errors']; + const absolutePathResults = ['1Commands Processed', '1Success', '0Errors']; + const invalidPathes = ['Invalid relative', 'Invalid absolute']; + const keyNames = ['hashkey1', 'listkey1', 'setkey1', 'zsetkey1', 'stringkey1', 'jsonkey1', 'streamkey1', 'graphkey1', 'tskey1', 'stringkey1test']; + internalLinkName1 = 'probably-1'; + + // Upload custom tutorial + await t + .click(workbenchPage.tutorialOpenUploadButton) + .setFilesToUpload(workbenchPage.tutorialImport, [zipFilePath]) + .click(workbenchPage.tutorialSubmitButton); + await t.expect(workbenchPage.tutorialAccordionButton.withText(tutorialName).visible).ok(`${tutorialName} tutorial is not uploaded`); + // Open tutorial + await t + .click(workbenchPage.tutorialAccordionButton.withText(tutorialName)) + .click(workbenchPage.getAccordionButtonWithName(folder1)) + .click(workbenchPage.getInternalLinkWithManifest(internalLinkName1)); + await t.expect(workbenchPage.scrolledEnablementArea.visible).ok('Enablement area is not visible after clicked'); + + // Verify that user can bulk upload data by relative path + await t.click(workbenchPage.uploadDataBulkBtn.withText('Upload relative')); + await t.click(workbenchPage.uploadDataBulkApplyBtn); + // Verify that user can see the summary when the command execution is completed + await verifyCompletedResultText(allKeysResults); + + // Verify that user can bulk upload data by absolute path + await t.click(workbenchPage.uploadDataBulkBtn.withText('Upload absolute')); + await t.click(workbenchPage.uploadDataBulkApplyBtn); + await verifyCompletedResultText(absolutePathResults); + + // Verify that user can't upload file by invalid relative path + // Verify that user can't upload file by invalid absolute path + for (const path of invalidPathes) { + await t.click(workbenchPage.uploadDataBulkBtn.withText(path)); + await t.click(workbenchPage.uploadDataBulkApplyBtn); + // Verify that user can see standard error messages when any error occurs while finding the file or parsing it + await t.expect(workbenchPage.Toast.toastError.textContent).contains('Data file was not found', 'Bulk upload not failed'); + await t.click(workbenchPage.Toast.toastCancelBtn); + } + + // Open Browser page + await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + // Verify that keys of all types can be uploaded + await browserPage.searchByKeyName('*key1*'); + await verifyKeysDisplayedInTheList(keyNames); + }); diff --git a/tests/e2e/tests/smoke/browser/add-keys.e2e.ts b/tests/e2e/tests/smoke/browser/add-keys.e2e.ts index f84c9de1cf..ac141073d2 100644 --- a/tests/e2e/tests/smoke/browser/add-keys.e2e.ts +++ b/tests/e2e/tests/smoke/browser/add-keys.e2e.ts @@ -24,7 +24,7 @@ test('Verify that user can add Hash Key', async t => { // Add Hash key await browserPage.addHashKey(keyName); // Check the notification message - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Key has been added', 'The notification not displayed'); // Check that new key is displayed in the list await browserPage.searchByKeyName(keyName); @@ -36,7 +36,7 @@ test('Verify that user can add Set Key', async t => { // Add Set key await browserPage.addSetKey(keyName); // Check the notification message - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Key has been added', 'The notification not displayed'); // Check that new key is displayed in the list await browserPage.searchByKeyName(keyName); @@ -48,7 +48,7 @@ test('Verify that user can add List Key', async t => { // Add List key await browserPage.addListKey(keyName); // Check the notification message - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Key has been added', 'The notification not displayed'); // Check that new key is displayed in the list await browserPage.searchByKeyName(keyName); @@ -60,7 +60,7 @@ test('Verify that user can add String Key', async t => { // Add String key await browserPage.addStringKey(keyName); // Check the notification message - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Key has been added', 'The notification not displayed'); // Check that new key is displayed in the list await browserPage.searchByKeyName(keyName); @@ -72,7 +72,7 @@ test('Verify that user can add ZSet Key', async t => { // Add ZSet key await browserPage.addZSetKey(keyName, '111'); // Check the notification message - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Key has been added', 'The notification not displayed'); // Check that new key is displayed in the list await browserPage.searchByKeyName(keyName); @@ -87,7 +87,7 @@ test('Verify that user can add JSON Key', async t => { // Add JSON key await browserPage.addJsonKey(keyName, value, keyTTL); // Check the notification message - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Key has been added', 'The notification not displayed'); // Check that new key is displayed in the list await browserPage.searchByKeyName(keyName); diff --git a/tests/e2e/tests/smoke/browser/hash-field.e2e.ts b/tests/e2e/tests/smoke/browser/hash-field.e2e.ts index 1aafd36758..f8b68abbb8 100644 --- a/tests/e2e/tests/smoke/browser/hash-field.e2e.ts +++ b/tests/e2e/tests/smoke/browser/hash-field.e2e.ts @@ -37,6 +37,6 @@ test('Verify that user can add field to Hash', async t => { await t.click(browserPage.removeHashFieldButton); await t.click(browserPage.confirmRemoveHashFieldButton); // Check the notification message - const notofication = await browserPage.getMessageText(); - await t.expect(notofication).contains('Field has been removed', 'The notification is not displayed'); + const notification = browserPage.Toast.toastHeader.textContent; + await t.expect(notification).contains('Field has been removed', 'The notification is not displayed'); }); diff --git a/tests/e2e/tests/smoke/browser/json-key.e2e.ts b/tests/e2e/tests/smoke/browser/json-key.e2e.ts index efd8203ac8..30979b5c10 100644 --- a/tests/e2e/tests/smoke/browser/json-key.e2e.ts +++ b/tests/e2e/tests/smoke/browser/json-key.e2e.ts @@ -27,7 +27,7 @@ test('Verify that user can add key with value to any level of JSON structure', a // Add Json key with json object await browserPage.addJsonKey(keyName, value, keyTTL); // Check the notification message - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Key has been added', 'The notification not found'); // Verify that user can create JSON object await t.expect(browserPage.addJsonObjectButton.exists).ok('The add Json object button not found', { timeout: 10000 }); diff --git a/tests/e2e/tests/smoke/browser/list-key.e2e.ts b/tests/e2e/tests/smoke/browser/list-key.e2e.ts index 0330594cfd..8073676724 100644 --- a/tests/e2e/tests/smoke/browser/list-key.e2e.ts +++ b/tests/e2e/tests/smoke/browser/list-key.e2e.ts @@ -36,7 +36,7 @@ test('Verify that user can select remove List element position: from tail', asyn // Remove element from the key await browserPage.removeListElementFromTail('1'); // Check the notification message - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Elements have been removed', 'The notification not found'); // Check the removed element is not in the list await t.expect(browserPage.listElementsList.withExactText(element3).exists).notOk('The list element not removed', { timeout: 10000 }); @@ -50,7 +50,7 @@ test('Verify that user can select remove List element position: from head', asyn // Remove element from the key await browserPage.removeListElementFromHead('1'); // Check the notification message - const notofication = await browserPage.getMessageText(); + const notofication = browserPage.Toast.toastHeader.textContent; await t.expect(notofication).contains('Elements have been removed', 'The notification not found'); // Check the removed element is not in the list await t.expect(browserPage.listElementsList.withExactText(element).exists).notOk('The list element not removed', { timeout: 10000 }); diff --git a/tests/e2e/tests/smoke/browser/list-of-keys-verifications.e2e.ts b/tests/e2e/tests/smoke/browser/list-of-keys-verifications.e2e.ts index 56b0ed04ac..2a3073368c 100644 --- a/tests/e2e/tests/smoke/browser/list-of-keys-verifications.e2e.ts +++ b/tests/e2e/tests/smoke/browser/list-of-keys-verifications.e2e.ts @@ -54,8 +54,8 @@ test('Verify that user can refresh Keys', async t => { // Add hash key await browserPage.addHashKey(keyName, keyTTL); - const notofication = await browserPage.getMessageText(); - await t.expect(notofication).contains('Key has been added', 'The notification is not displayed'); + const notification = browserPage.Toast.toastHeader.textContent; + await t.expect(notification).contains('Key has been added', 'The notification is not displayed'); await t.click(browserPage.closeKeyButton); // Search for the added key await browserPage.searchByKeyName(keyName); @@ -76,8 +76,8 @@ test('Verify that user can open key details', async t => { // Add String key await browserPage.addStringKey(keyName, keyTTL, keyValue); - const notofication = await browserPage.getMessageText(); - await t.expect(notofication).contains('Key has been added', 'The notification is not displayed'); + const notification = browserPage.Toast.toastHeader.textContent; + await t.expect(notification).contains('Key has been added', 'The notification is not displayed'); await t.click(browserPage.closeKeyButton); // Search for the added key await browserPage.searchByKeyName(keyName); diff --git a/tests/e2e/tests/smoke/browser/set-key.e2e.ts b/tests/e2e/tests/smoke/browser/set-key.e2e.ts index 27da238596..373177e588 100644 --- a/tests/e2e/tests/smoke/browser/set-key.e2e.ts +++ b/tests/e2e/tests/smoke/browser/set-key.e2e.ts @@ -36,6 +36,6 @@ test('Verify that user can remove member from Set', async t => { await t.click(browserPage.removeSetMemberButton); await t.click(browserPage.confirmRemoveSetMemberButton); // Check the notification message - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Member has been removed', 'The notification not found'); }); diff --git a/tests/e2e/tests/smoke/browser/verify-keys-refresh.e2e.ts b/tests/e2e/tests/smoke/browser/verify-keys-refresh.e2e.ts index b49f79d44e..daef813d50 100644 --- a/tests/e2e/tests/smoke/browser/verify-keys-refresh.e2e.ts +++ b/tests/e2e/tests/smoke/browser/verify-keys-refresh.e2e.ts @@ -27,7 +27,7 @@ test('Verify that user can refresh Keys', async t => { // Add hash key await browserPage.addHashKey(keyName, keyTTL); - const notification = await browserPage.getMessageText(); + const notification = browserPage.Toast.toastHeader.textContent; await t.expect(notification).contains('Key has been added', 'The notification not found'); await t.click(browserPage.closeKeyButton); // Search for the added key diff --git a/tests/e2e/tests/smoke/browser/zset-key.e2e.ts b/tests/e2e/tests/smoke/browser/zset-key.e2e.ts index 4d8b2fca79..fa94585a99 100644 --- a/tests/e2e/tests/smoke/browser/zset-key.e2e.ts +++ b/tests/e2e/tests/smoke/browser/zset-key.e2e.ts @@ -35,6 +35,6 @@ test('Verify that user can remove member from ZSet', async t => { await t.click(browserPage.removeZserMemberButton); await t.click(browserPage.confirmRemovZSetMemberButton); // Check the notification message - const notofication = await browserPage.getMessageText(); - await t.expect(notofication).contains('Member has been removed', 'The notification not found'); + const notification = browserPage.Toast.toastHeader.textContent; + await t.expect(notification).contains('Member has been removed', 'The notification not found'); }); diff --git a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts index 77d1911597..209d2b6d9f 100644 --- a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts +++ b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts @@ -64,7 +64,7 @@ test // Wait for database to be exist .expect(myRedisDatabasePage.dbNameList.withExactText(ossStandaloneConfig.databaseName).exists).ok('The database not displayed', { timeout: 10000 }) // Close message - .click(myRedisDatabasePage.toastCloseButton); + .click(myRedisDatabasePage.Toast.toastCloseButton); // Verify that user can see an indicator of databases that are added manually and not opened yet await myRedisDatabasePage.verifyDatabaseStatusIsVisible(ossStandaloneConfig.databaseName); diff --git a/tests/e2e/yarn.lock b/tests/e2e/yarn.lock index f593e48323..6940e03d29 100644 --- a/tests/e2e/yarn.lock +++ b/tests/e2e/yarn.lock @@ -1228,6 +1228,13 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== +"@types/archiver@^5.3.2": + version "5.3.2" + resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-5.3.2.tgz#a9f0bcb0f0b991400e7766d35f6e19d163bdadcc" + integrity sha512-IctHreBuWE5dvBDz/0WeKtyVKVRs4h75IblxOACL92wU66v+HGAfEYAOyXkOFphvRJMhuXdI9huDXpX0FC6lCw== + dependencies: + "@types/readdir-glob" "*" + "@types/chance@1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@types/chance/-/chance-1.1.3.tgz#d19fe9391288d60fdccd87632bfc9ab2b4523fea" @@ -1284,6 +1291,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4" integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg== +"@types/readdir-glob@*": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/readdir-glob/-/readdir-glob-1.1.1.tgz#27ac2db283e6aa3d110b14ff9da44fcd1a5c38b1" + integrity sha512-ImM6TmoF8bgOwvehGviEj3tRdRBbQujr1N+0ypaln/GWjaerOB26jb93vsRHmdMtvVQZQebOlqt2HROark87mQ== + dependencies: + "@types/node" "*" + "@types/set-value@*": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/set-value/-/set-value-4.0.1.tgz#7caf185556a67c2d9051080931853047423c93bd" @@ -1497,6 +1511,35 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== +archiver-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2" + integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw== + dependencies: + glob "^7.1.4" + graceful-fs "^4.2.0" + lazystream "^1.0.0" + lodash.defaults "^4.2.0" + lodash.difference "^4.5.0" + lodash.flatten "^4.4.0" + lodash.isplainobject "^4.0.6" + lodash.union "^4.6.0" + normalize-path "^3.0.0" + readable-stream "^2.0.0" + +archiver@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/archiver/-/archiver-5.3.1.tgz#21e92811d6f09ecfce649fbefefe8c79e57cbbb6" + integrity sha512-8KyabkmbYrH+9ibcTScQ1xCJC/CGcugdVIwB+53f5sZziXgwUh3iXlAlANMxcZyDEfTHMe6+Z5FofV8nopXP7w== + dependencies: + archiver-utils "^2.1.0" + async "^3.2.3" + buffer-crc32 "^0.2.1" + readable-stream "^3.6.0" + readdir-glob "^1.0.0" + tar-stream "^2.2.0" + zip-stream "^4.1.0" + are-we-there-yet@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" @@ -1641,6 +1684,11 @@ async@0.2.6: resolved "https://registry.yarnpkg.com/async/-/async-0.2.6.tgz#ad3f373d9249ae324881565582bc90e152abbd68" integrity sha512-LTdAJ0KBRK5o4BlBlUoGvfGNOMON+NLbONgDZk80SX0G8LQZyjN+74nNADIpQ/+rxun6+fYm7z4vIzAB51UKUA== +async@^3.2.3: + version "3.2.4" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" + integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -1709,7 +1757,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.1.2: +base64-js@^1.1.2, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -1732,6 +1780,15 @@ bin-v8-flags-filter@^1.1.2: resolved "https://registry.yarnpkg.com/bin-v8-flags-filter/-/bin-v8-flags-filter-1.2.0.tgz#023fc4ee22999b2b1f6dd1b7253621366841537e" integrity sha512-g8aeYkY7GhyyKRvQMBsJQZjhm2iCX3dKYvfrMpwVR8IxmUGrkpCBFoKbB9Rh0o3sTLCjU/1tFpZ4C7j3f+D+3g== +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + bluebird@^3.5.0: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -1755,6 +1812,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + braces@^2.3.1: version "2.3.2" resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" @@ -1795,11 +1859,24 @@ browserslist@^4.21.3, browserslist@^4.21.5: node-releases "^2.0.8" update-browserslist-db "^1.0.10" +buffer-crc32@^0.2.1, buffer-crc32@^0.2.13: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + cacache@^15.2.0: version "15.3.0" resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.3.0.tgz#dc85380fb2f556fe3dda4c719bfa0ec875a7f1eb" @@ -2042,6 +2119,16 @@ component-emitter@^1.2.0, component-emitter@^1.2.1: resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== +compress-commons@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-4.1.1.tgz#df2a09a7ed17447642bad10a85cc9a19e5c42a7d" + integrity sha512-QLdDLCKNV2dtoTorqgxngQCMA+gWXkM/Nwu7FpeBhk/RdkzimqC3jueb/FDmaZeXh+uby1jkBqE3xArsLBE5wQ== + dependencies: + buffer-crc32 "^0.2.13" + crc32-stream "^4.0.2" + normalize-path "^3.0.0" + readable-stream "^3.6.0" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -2084,6 +2171,19 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +crc-32@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + +crc32-stream@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-4.0.2.tgz#c922ad22b38395abe9d3870f02fa8134ed709007" + integrity sha512-DxFZ/Hk473b/muq1VJ///PMNLj0ZMnzye9thBpmjpJKCc5eMgB95aK8zCGrGfQ90cWo561Te6HK9D+j4KPdM6w== + dependencies: + crc-32 "^1.2.0" + readable-stream "^3.4.0" + create-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" @@ -2382,7 +2482,7 @@ encoding@^0.1.12: dependencies: iconv-lite "^0.6.2" -end-of-stream@^1.1.0: +end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== @@ -2918,6 +3018,11 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs-minipass@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" @@ -3142,6 +3247,11 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.2.2, graceful-fs@^4.2.6: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== +graceful-fs@^4.2.0: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + graphlib@^2.1.5: version "2.1.8" resolved "https://registry.yarnpkg.com/graphlib/-/graphlib-2.1.8.tgz#5761d414737870084c92ec7b5dbcb0592c9d35da" @@ -3313,6 +3423,11 @@ iconv-lite@^0.6.2: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ignore@^4.0.3, ignore@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" @@ -3368,7 +3483,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3: +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3836,6 +3951,13 @@ kind-of@^6.0.0, kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +lazystream@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.1.tgz#494c831062f1f9408251ec44db1cba29242a2638" + integrity sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw== + dependencies: + readable-stream "^2.0.5" + levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -3882,6 +4004,26 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== + +lodash.difference@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c" + integrity sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA== + +lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" @@ -3892,6 +4034,11 @@ lodash.truncate@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== +lodash.union@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" + integrity sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw== + "lodash@4.6.1 || ^4.16.1", lodash@^4.14.0, lodash@^4.17.13, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.4: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" @@ -4084,6 +4231,13 @@ minimatch@^3.0.4, minimatch@^3.1.1: dependencies: brace-expansion "^1.1.7" +minimatch@^5.1.0: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.1.0, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" @@ -4309,6 +4463,11 @@ normalize-package-data@^2.3.2: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + npm-run-path@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -4766,6 +4925,19 @@ read-pkg@^3.0.0: normalize-package-data "^2.3.2" path-type "^3.0.0" +readable-stream@^2.0.0, readable-stream@^2.0.5: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readable-stream@^2.0.1, readable-stream@^2.3.5: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" @@ -4779,6 +4951,15 @@ readable-stream@^2.0.1, readable-stream@^2.3.5: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^3.1.1, readable-stream@^3.4.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readable-stream@^3.6.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" @@ -4788,6 +4969,13 @@ readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readdir-glob@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/readdir-glob/-/readdir-glob-1.1.3.tgz#c3d831f51f5e7bfa62fa2ffbe4b508c640f09584" + integrity sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA== + dependencies: + minimatch "^5.1.0" + redis-commands@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89" @@ -5417,6 +5605,17 @@ table@^6.0.9: string-width "^4.2.3" strip-ansi "^6.0.1" +tar-stream@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + tar@^6.0.2, tar@^6.1.11, tar@^6.1.2: version "6.1.13" resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.13.tgz#46e22529000f612180601a6fe0680e7da508847b" @@ -6130,3 +6329,12 @@ yn@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + +zip-stream@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-4.1.0.tgz#51dd326571544e36aa3f756430b313576dc8fc79" + integrity sha512-zshzwQW7gG7hjpBlgeQP9RuyPGNxvJdzR8SUM3QhxCnLjWN2E7j3dOvpeDcQoETfHx0urRS7EtmVToql7YpU4A== + dependencies: + archiver-utils "^2.1.0" + compress-commons "^4.1.0" + readable-stream "^3.6.0"