From 0d6c43c53b3ec6d0c6ca36f9b9061ce013f1da1e Mon Sep 17 00:00:00 2001 From: rs-amp <56435292+rs-amp@users.noreply.github.com> Date: Thu, 17 Jun 2021 12:06:48 +0100 Subject: [PATCH 01/12] feat(hub): clone hub command (#76) --- package-lock.json | 41 +- package.json | 2 +- src/commands/content-item/__mocks__/copy.ts | 5 +- src/commands/content-item/archive.ts | 10 +- src/commands/content-item/copy.spec.ts | 41 +- src/commands/content-item/copy.ts | 21 +- src/commands/content-item/export.spec.ts | 9 +- src/commands/content-item/export.ts | 15 +- .../content-item/import-revert.spec.ts | 38 +- src/commands/content-item/import-revert.ts | 12 +- src/commands/content-item/import.spec.ts | 17 +- src/commands/content-item/import.ts | 33 +- src/commands/content-item/move.spec.ts | 22 +- src/commands/content-item/move.ts | 26 +- src/commands/content-item/unarchive.ts | 8 +- .../__snapshots__/export.spec.ts.snap | 219 +++--- src/commands/content-type-schema/archive.ts | 8 +- .../content-type-schema/export.spec.ts | 101 +-- src/commands/content-type-schema/export.ts | 52 +- .../content-type-schema/import.spec.ts | 106 ++- src/commands/content-type-schema/import.ts | 61 +- src/commands/content-type-schema/unarchive.ts | 4 +- src/commands/content-type/archive.ts | 8 +- src/commands/content-type/export.spec.ts | 196 +++--- src/commands/content-type/export.ts | 55 +- src/commands/content-type/import.spec.ts | 123 ++-- src/commands/content-type/import.ts | 89 ++- src/commands/content-type/unarchive.ts | 4 +- src/commands/event/archive.spec.ts | 18 + src/commands/event/archive.ts | 32 +- src/commands/hub/clean.spec.ts | 2 +- src/commands/hub/clone.spec.ts | 635 ++++++++++++++++++ src/commands/hub/clone.ts | 246 +++++++ src/commands/hub/model/clone-hub-state.ts | 14 + src/commands/hub/model/clone-hub-step.ts | 15 + .../hub/steps/content-clone-step.spec.ts | 158 +++++ src/commands/hub/steps/content-clone-step.ts | 37 + .../hub/steps/schema-clone-step.spec.ts | 218 ++++++ src/commands/hub/steps/schema-clone-step.ts | 87 +++ .../hub/steps/settings-clone-step.spec.ts | 226 +++++++ src/commands/hub/steps/settings-clone-step.ts | 96 +++ .../hub/steps/type-clone-step.spec.ts | 246 +++++++ src/commands/hub/steps/type-clone-step.ts | 98 +++ src/commands/settings/export.spec.ts | 11 +- src/commands/settings/export.ts | 33 +- src/commands/settings/import.spec.ts | 14 +- src/commands/settings/import.ts | 16 +- src/common/archive/archive-helpers.ts | 31 +- src/common/archive/archive-log.ts | 3 +- src/common/archive/archive-options.ts | 2 +- src/common/content-item/copy-config.spec.ts | 4 +- .../dc-management-sdk-js/mock-content.ts | 98 ++- src/common/dc-management-sdk-js/paginator.ts | 10 +- .../dc-management-sdk-js/resource-status.ts | 9 + src/common/file-log.ts | 6 +- src/common/log-helpers.ts | 17 + src/common/question-helpers.ts | 24 + src/interfaces/clone-hub-builder-options.ts | 28 + .../copy-item-builder-options.interface.ts | 2 +- .../export-builder-options.interface.ts | 4 + .../export-item-builder-options.interface.ts | 2 +- .../import-builder-options.interface.ts | 3 + .../import-item-builder-options.interface.ts | 4 +- ...port-settings-builder-options.interface.ts | 4 +- src/services/export.service.spec.ts | 14 +- src/services/export.service.ts | 53 +- src/view/data-presenter.ts | 11 +- 67 files changed, 3262 insertions(+), 595 deletions(-) create mode 100644 src/commands/hub/clone.spec.ts create mode 100644 src/commands/hub/clone.ts create mode 100644 src/commands/hub/model/clone-hub-state.ts create mode 100644 src/commands/hub/model/clone-hub-step.ts create mode 100644 src/commands/hub/steps/content-clone-step.spec.ts create mode 100644 src/commands/hub/steps/content-clone-step.ts create mode 100644 src/commands/hub/steps/schema-clone-step.spec.ts create mode 100644 src/commands/hub/steps/schema-clone-step.ts create mode 100644 src/commands/hub/steps/settings-clone-step.spec.ts create mode 100644 src/commands/hub/steps/settings-clone-step.ts create mode 100644 src/commands/hub/steps/type-clone-step.spec.ts create mode 100644 src/commands/hub/steps/type-clone-step.ts create mode 100644 src/common/dc-management-sdk-js/resource-status.ts create mode 100644 src/common/question-helpers.ts create mode 100644 src/interfaces/clone-hub-builder-options.ts diff --git a/package-lock.json b/package-lock.json index 5b792482..c0d43fec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2656,12 +2656,27 @@ "dev": true }, "dc-management-sdk-js": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/dc-management-sdk-js/-/dc-management-sdk-js-1.9.0.tgz", - "integrity": "sha512-ChljW30c/BJbDlCiEfXz4bBwlCFPidJWiI3CLXgxm2GPnMq/rQxX+P9K5YQ7iIigPeEZX6K2n5+Z/ai+qSBp0w==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/dc-management-sdk-js/-/dc-management-sdk-js-1.13.0.tgz", + "integrity": "sha512-E97UYNvDqLQ80SvxV1T73/1k6Qb43+kV043QJIiB5QgYIiyRIleBOIX5NCzZzzb65Ti0D7WOvSqYHoVM8lQ4Ag==", "requires": { - "axios": "^0.18.0", + "axios": "^0.21.1", "url-template": "^2.0.8" + }, + "dependencies": { + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, + "follow-redirects": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", + "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==" + } } }, "debug": { @@ -8815,9 +8830,9 @@ "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==" }, "yargs": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.0.0.tgz", - "integrity": "sha512-ssa5JuRjMeZEUjg7bEL99AwpitxU/zWGAGpdj0di41pOEmJti8NR6kyUIJBkR78DTYNPZOU08luUo0GTHuB+ow==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", + "integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==", "requires": { "cliui": "^5.0.0", "decamelize": "^1.2.0", @@ -8829,7 +8844,7 @@ "string-width": "^3.0.0", "which-module": "^2.0.0", "y18n": "^4.0.0", - "yargs-parser": "^13.1.1" + "yargs-parser": "^15.0.1" }, "dependencies": { "string-width": { @@ -8841,6 +8856,15 @@ "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^5.1.0" } + }, + "yargs-parser": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.1.tgz", + "integrity": "sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } } } }, @@ -8848,6 +8872,7 @@ "version": "13.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz", "integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==", + "dev": true, "requires": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" diff --git a/package.json b/package.json index 56e45def..457f38ba 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "ajv": "^6.12.3", "axios": "^0.18.1", "chalk": "^2.4.2", - "dc-management-sdk-js": "^1.9.0", + "dc-management-sdk-js": "^1.13.0", "lodash": "^4.17.15", "node-fetch": "^2.6.0", "promise-retry": "^2.0.1", diff --git a/src/commands/content-item/__mocks__/copy.ts b/src/commands/content-item/__mocks__/copy.ts index 281e7870..5f38dea6 100644 --- a/src/commands/content-item/__mocks__/copy.ts +++ b/src/commands/content-item/__mocks__/copy.ts @@ -17,7 +17,10 @@ export const setForceFail = (fail: boolean): void => { export const handler = async (argv: Arguments): Promise => { calls.push(argv); const idOut = argv.exportedIds as string[]; - idOut.push(...outputIds); + + if (idOut) { + idOut.push(...outputIds); + } return !forceFail; }; diff --git a/src/commands/content-item/archive.ts b/src/commands/content-item/archive.ts index 20432e8c..2d2d0799 100644 --- a/src/commands/content-item/archive.ts +++ b/src/commands/content-item/archive.ts @@ -5,7 +5,7 @@ import { ArchiveLog } from '../../common/archive/archive-log'; import paginator from '../../common/dc-management-sdk-js/paginator'; import { confirmArchive } from '../../common/archive/archive-helpers'; import ArchiveOptions from '../../common/archive/archive-options'; -import { ContentItem, DynamicContent } from 'dc-management-sdk-js'; +import { ContentItem, DynamicContent, Status } from 'dc-management-sdk-js'; import { equalsOrRegex } from '../../common/filter/filter'; import { getDefaultLogPath, createLog } from '../../common/log-helpers'; import { FileLog } from '../../common/file-log'; @@ -153,7 +153,7 @@ export const getContentItems = async ({ contentType }: { client: DynamicContent; - id?: string; + id?: string | string[]; hubId: string; repoId?: string | string[]; folderId?: string | string[]; @@ -165,7 +165,9 @@ export const getContentItems = async ({ const contentItems: ContentItem[] = []; if (id != null) { - contentItems.push(await client.contentItems.get(id)); + const itemIds = Array.isArray(id) ? id : [id]; + const items = await Promise.all(itemIds.map(id => client.contentItems.get(id))); + contentItems.push(...items); return { contentItems, @@ -192,7 +194,7 @@ export const getContentItems = async ({ ) : await Promise.all( contentRepositories.map(async source => { - const items = await paginator(source.related.contentItems.list, { status: 'ACTIVE' }); + const items = await paginator(source.related.contentItems.list, { status: Status.ACTIVE }); contentItems.push(...items); }) ); diff --git a/src/commands/content-item/copy.spec.ts b/src/commands/content-item/copy.spec.ts index 85b85b0e..96bada43 100644 --- a/src/commands/content-item/copy.spec.ts +++ b/src/commands/content-item/copy.spec.ts @@ -13,7 +13,7 @@ import { Arguments } from 'yargs'; import { ExportItemBuilderOptions } from '../../interfaces/export-item-builder-options.interface'; import { ConfigurationParameters } from '../configure'; import { ImportItemBuilderOptions } from '../../interfaces/import-item-builder-options.interface'; -import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath, openRevertLog } from '../../common/log-helpers'; import * as copyConfig from '../../common/content-item/copy-config'; import { FileLog } from '../../common/file-log'; @@ -22,7 +22,10 @@ jest.mock('../../services/dynamic-content-client-factory'); jest.mock('./export'); jest.mock('./import'); jest.mock('./import-revert'); -jest.mock('../../common/log-helpers'); +jest.mock('../../common/log-helpers', () => ({ + ...jest.requireActual('../../common/log-helpers'), + getDefaultLogPath: jest.fn() +})); function rimraf(dir: string): Promise { return new Promise((resolve): void => { @@ -49,7 +52,8 @@ describe('content-item copy command', () => { expect(spyOption).toHaveBeenCalledWith('revertLog', { type: 'string', describe: - 'Path to a log file to revert a copy for. This will archive the most recently copied resources, and revert updated ones.' + 'Path to a log file to revert a copy for. This will archive the most recently copied resources, and revert updated ones.', + coerce: openRevertLog }); expect(spyOption).toHaveBeenCalledWith('srcRepo', { @@ -173,7 +177,9 @@ describe('content-item copy command', () => { clientId: 'client-id', clientSecret: 'client-id', hubId: 'hub-id', - logFile: new FileLog() + + logFile: new FileLog(), + revertLog: Promise.resolve(undefined) }; beforeAll(async () => { @@ -285,7 +291,7 @@ describe('content-item copy command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - revertLog: 'revertTest.txt' + revertLog: Promise.resolve(new FileLog()) }; await handler(argv); @@ -299,6 +305,31 @@ describe('content-item copy command', () => { expect(revertCalls[0].revertLog).toEqual(argv.revertLog); }); + it('should exit early when revertLog is not present.', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const exportCalls: Arguments[] = (exporter as any).calls; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const importCalls: Arguments[] = (importer as any).calls; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const revertCalls: Arguments[] = (reverter as any).calls; + + const argv = { + ...yargArgs, + ...config, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + + revertLog: openRevertLog('temp/copy/revertMissing.txt') + }; + await handler(argv); + + expect(exportCalls.length).toEqual(0); + expect(importCalls.length).toEqual(0); + expect(revertCalls.length).toEqual(0); + }); + it('should return false and remove temp folder when import fails or throws.', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const exportCalls: Arguments[] = (exporter as any).calls; diff --git a/src/commands/content-item/copy.ts b/src/commands/content-item/copy.ts index 5b967546..9530fc04 100644 --- a/src/commands/content-item/copy.ts +++ b/src/commands/content-item/copy.ts @@ -1,4 +1,4 @@ -import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath, openRevertLog } from '../../common/log-helpers'; import { Argv, Arguments } from 'yargs'; import { join } from 'path'; import { CopyItemBuilderOptions } from '../../interfaces/copy-item-builder-options.interface'; @@ -10,6 +10,8 @@ import { handler as importer } from './import'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { revert } from './import-revert'; import { loadCopyConfig } from '../../common/content-item/copy-config'; +import { FileLog } from '../../common/file-log'; +import { LogErrorLevel } from '../../common/archive/archive-log'; export function getTempFolder(name: string, platform: string = process.platform): string { return join(process.env[platform == 'win32' ? 'USERPROFILE' : 'HOME'] || __dirname, '.amplience', `copy-${name}/`); @@ -27,7 +29,8 @@ export const builder = (yargs: Argv): void => { .option('revertLog', { type: 'string', describe: - 'Path to a log file to revert a copy for. This will archive the most recently copied resources, and revert updated ones.' + 'Path to a log file to revert a copy for. This will archive the most recently copied resources, and revert updated ones.', + coerce: openRevertLog }) .option('srcRepo', { @@ -167,7 +170,15 @@ export const handler = async (argv: Arguments { expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }); }); @@ -116,7 +118,8 @@ describe('content-item export command', () => { const config = { clientId: 'client-id', clientSecret: 'client-id', - hubId: 'hub-id' + hubId: 'hub-id', + logFile: new FileLog() }; beforeAll(async () => { diff --git a/src/commands/content-item/export.ts b/src/commands/content-item/export.ts index 1b9b81b3..c98ebc62 100644 --- a/src/commands/content-item/export.ts +++ b/src/commands/content-item/export.ts @@ -9,12 +9,12 @@ import { uniqueFilenamePath, writeJsonToFile } from '../../services/export.servi import { ExportItemBuilderOptions } from '../../interfaces/export-item-builder-options.interface'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { ContentItem, Folder, DynamicContent, Hub, ContentRepository } from 'dc-management-sdk-js'; +import { ContentItem, Folder, DynamicContent, Hub, ContentRepository, Status } from 'dc-management-sdk-js'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { ContentDependancyTree, RepositoryContentItem } from '../../common/content-item/content-dependancy-tree'; import { ContentMapping } from '../../common/content-item/content-mapping'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { AmplienceSchemaValidator, defaultSchemaLookup } from '../../common/content-item/amplience-schema-validator'; interface PublishedContentItem { @@ -64,7 +64,8 @@ export const builder = (yargs: Argv): void => { .option('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }; @@ -136,7 +137,7 @@ const getContentItems = async ( // Add content items in repo base folder. Cache the other items so we don't have to request them again. let newItems: ContentItem[]; try { - const allItems = await paginator(repository.related.contentItems.list, { status: 'ACTIVE' }); + const allItems = await paginator(repository.related.contentItems.list, { status: Status.ACTIVE }); Array.prototype.push.apply(repoItems, allItems); newItems = allItems.filter(item => item.folderId == null); @@ -221,7 +222,7 @@ export const handler = async (argv: Arguments = new Map(); const client = dynamicContentClientFactory(argv); - const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; + const log = logFile.open(); const hub = await client.hubs.get(argv.hubId); log.appendLine('Retrieving content items, please wait.'); @@ -345,7 +346,5 @@ export const handler = async (argv: Arguments { @@ -75,7 +78,7 @@ describe('revert tests', function() { const argv = { ...yargArgs, ...config, - revertLog: 'temp/revert/createOnly.txt', + revertLog: openRevertLog('temp/revert/createOnly.txt'), dir: '.' }; await revert(argv); @@ -108,7 +111,7 @@ describe('revert tests', function() { const argv = { ...yargArgs, ...config, - revertLog: 'temp/revert/createImport.txt', + revertLog: openRevertLog('temp/revert/createImport.txt'), dir: '.' }; await revert(argv); @@ -171,7 +174,7 @@ describe('revert tests', function() { const argv = { ...yargArgs, ...config, - revertLog: 'temp/revert/createWarn.txt', + revertLog: openRevertLog('temp/revert/createWarn.txt'), dir: '.' }; await revert(argv); @@ -212,7 +215,7 @@ describe('revert tests', function() { const argv = { ...yargArgs, ...config, - revertLog: 'temp/revert/revertAbort.txt', + revertLog: openRevertLog('temp/revert/revertAbort.txt'), dir: '.' }; const result = await revert(argv); @@ -255,7 +258,7 @@ describe('revert tests', function() { const argv = { ...yargArgs, ...config, - revertLog: 'temp/revert/revertSkip.txt', + revertLog: openRevertLog('temp/revert/revertSkip.txt'), dir: '.' }; const result = await revert(argv); @@ -292,7 +295,7 @@ describe('revert tests', function() { const argv = { ...yargArgs, ...config, - revertLog: 'temp/revert/revertEmpty.txt', + revertLog: openRevertLog('temp/revert/revertEmpty.txt'), dir: '.' }; await revert(argv); @@ -330,7 +333,7 @@ describe('revert tests', function() { const argv = { ...yargArgs, ...config, - revertLog: 'temp/revert/revertSkip.txt', + revertLog: openRevertLog('temp/revert/revertSkip.txt'), dir: '.' }; const result = await revert(argv); @@ -366,7 +369,7 @@ describe('revert tests', function() { const argv = { ...yargArgs, ...config, - revertLog: 'temp/revert/revertSkip2.txt', + revertLog: openRevertLog('temp/revert/revertSkip2.txt'), dir: '.' }; const result = await revert(argv); @@ -401,16 +404,25 @@ describe('revert tests', function() { const argv = { ...yargArgs, ...config, - revertLog: 'temp/revert/revertMissing.txt', - dir: '.' + dir: '.', + revertLog: Promise.resolve(undefined) }; const result = await revert(argv); expect(result).toBeFalsy(); - // check items were archived appropriately + const argv2 = { + ...yargArgs, + ...config, + dir: '.', + revertLog: openRevertLog('temp/revert/missing.txt') + }; + const result2 = await revert(argv2); + + expect(result2).toBeFalsy(); + + // check items were not updated or archived expect(mockContent.metrics.itemsUpdated).toEqual(0); - // check items were archived appropriately expect(mockContent.metrics.itemsArchived).toEqual(0); }); }); diff --git a/src/commands/content-item/import-revert.ts b/src/commands/content-item/import-revert.ts index 81143e9d..31bbd141 100644 --- a/src/commands/content-item/import-revert.ts +++ b/src/commands/content-item/import-revert.ts @@ -1,17 +1,15 @@ import { ImportItemBuilderOptions } from '../../interfaces/import-item-builder-options.interface'; import { ConfigurationParameters } from '../configure'; import { Arguments } from 'yargs'; -import { FileLog } from '../../common/file-log'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ContentItem } from 'dc-management-sdk-js'; -import { asyncQuestion } from '../../common/archive/archive-helpers'; +import { asyncQuestion } from '../../common/question-helpers'; +import { LogErrorLevel } from '../../common/archive/archive-log'; export const revert = async (argv: Arguments): Promise => { - const log = new FileLog(); - try { - await log.loadFromFile(argv.revertLog as string); - } catch (e) { - console.log('Could not open the import log! Aborting.'); + const log = await argv.revertLog; + if (!log || log.errorLevel === LogErrorLevel.INVALID) { + console.log('No valid log file provided. Aborting.'); return false; } diff --git a/src/commands/content-item/import.spec.ts b/src/commands/content-item/import.spec.ts index 56b3b4f4..1ef4b1e8 100644 --- a/src/commands/content-item/import.spec.ts +++ b/src/commands/content-item/import.spec.ts @@ -2,7 +2,7 @@ import { builder, command, handler, LOG_FILENAME, getDefaultMappingPath } from ' import { dependsOn, dependantType } from './__mocks__/dependant-content-helper'; import * as reverter from './import-revert'; import * as publish from '../../common/import/publish-queue'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { Folder, ContentType } from 'dc-management-sdk-js'; import Yargs from 'yargs/yargs'; @@ -22,7 +22,10 @@ jest.mock('./import-revert'); jest.mock('../../services/dynamic-content-client-factory'); jest.mock('../../common/import/publish-queue'); jest.mock('../../common/media/media-rewriter'); -jest.mock('../../common/log-helpers'); +jest.mock('../../common/log-helpers', () => ({ + ...jest.requireActual('../../common/log-helpers'), + getDefaultLogPath: jest.fn() +})); function rimraf(dir: string): Promise { return new Promise((resolve): void => { @@ -130,7 +133,8 @@ describe('content-item import command', () => { expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }); }); @@ -144,7 +148,10 @@ describe('content-item import command', () => { const config = { clientId: 'client-id', clientSecret: 'client-id', - hubId: 'hub-id' + hubId: 'hub-id', + + logFile: new FileLog(), + revertLog: Promise.resolve(undefined) }; beforeEach(async () => { @@ -1080,7 +1087,7 @@ describe('content-item import command', () => { ...yargArgs, ...config, dir: 'temp/import/unused/', - revertLog: 'log.txt' + revertLog: Promise.resolve(new FileLog()) }; expect(await handler(argv)).toBeTruthy(); diff --git a/src/commands/content-item/import.ts b/src/commands/content-item/import.ts index 3a9f5799..ba8dbdda 100644 --- a/src/commands/content-item/import.ts +++ b/src/commands/content-item/import.ts @@ -27,9 +27,9 @@ import { ContentDependancyInfo } from '../../common/content-item/content-dependancy-tree'; -import { asyncQuestion } from '../../common/archive/archive-helpers'; import { AmplienceSchemaValidator, defaultSchemaLookup } from '../../common/content-item/amplience-schema-validator'; -import { getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { asyncQuestion } from '../../common/question-helpers'; import { PublishQueue } from '../../common/import/publish-queue'; import { MediaRewriter } from '../../common/media/media-rewriter'; @@ -125,7 +125,8 @@ export const builder = (yargs: Argv): void => { .option('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); }; @@ -393,7 +394,8 @@ const prepareContentForImport = async ( const updateExisting = force || (await asyncQuestion( - `${alreadyExists.length} of the items being imported already exist in the mapping. Would you like to update these content items instead of skipping them? (y/n) ` + `${alreadyExists.length} of the items being imported already exist in the mapping. Would you like to update these content items instead of skipping them? (y/n) `, + log )); if (!updateExisting) { @@ -435,7 +437,8 @@ const prepareContentForImport = async ( const create = force || (await asyncQuestion( - 'Content types can be automatically created for these schemas, but it is not recommended as they will have a default name and lack any configuration. Are you sure you wish to continue? (y/n) ' + 'Content types can be automatically created for these schemas, but it is not recommended as they will have a default name and lack any configuration. Are you sure you wish to continue? (y/n) ', + log )); if (!create) { return null; @@ -499,7 +502,8 @@ const prepareContentForImport = async ( const createAssignments = force || (await asyncQuestion( - 'These assignments will be created automatically. Are you sure you still wish to continue? (y/n) ' + 'These assignments will be created automatically. Are you sure you still wish to continue? (y/n) ', + log )); if (!createAssignments) { return null; @@ -576,7 +580,8 @@ const prepareContentForImport = async ( const ignore = force || (await asyncQuestion( - `${affectedContentItems.length} out of ${beforeRemove} content items will be skipped. Are you sure you still wish to continue? (y/n) ` + `${affectedContentItems.length} out of ${beforeRemove} content items will be skipped. Are you sure you still wish to continue? (y/n) `, + log )); if (!ignore) { return null; @@ -652,7 +657,8 @@ const prepareContentForImport = async ( const ignore = force || (await asyncQuestion( - `${invalidContentItems.length} out of ${contentItems.length} content items will be affected. Are you sure you still wish to continue? (y/n) ` + `${invalidContentItems.length} out of ${contentItems.length} content items will be affected. Are you sure you still wish to continue? (y/n) `, + log )); if (!ignore) { return null; @@ -848,7 +854,7 @@ const importTree = async ( export const handler = async ( argv: Arguments ): Promise => { - if (argv.revertLog != null) { + if (await argv.revertLog) { return revert(argv); } @@ -858,12 +864,10 @@ export const handler = async ( argv.publish = argv.publish || argv.republish; const client = dynamicContentClientFactory(argv); - const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; + const log = logFile.open(); const closeLog = async (): Promise => { - if (typeof logFile !== 'object') { - await log.close(); - } + await log.close(); }; let hub: Hub; @@ -959,7 +963,8 @@ export const handler = async ( const ignore = force || (await asyncQuestion( - 'These repositories will be skipped during the import, as they need to be added to the hub manually. Do you want to continue? (y/n) ' + 'These repositories will be skipped during the import, as they need to be added to the hub manually. Do you want to continue? (y/n) ', + log )); if (!ignore) { closeLog(); diff --git a/src/commands/content-item/move.spec.ts b/src/commands/content-item/move.spec.ts index 5bc9beb8..b3493e0f 100644 --- a/src/commands/content-item/move.spec.ts +++ b/src/commands/content-item/move.spec.ts @@ -18,7 +18,7 @@ import { CopyItemBuilderOptions } from '../../interfaces/copy-item-builder-optio import { ItemTemplate, MockContent } from '../../common/dc-management-sdk-js/mock-content'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; -import { getDefaultLogPath, createLog as createFileLog, createLog } from '../../common/log-helpers'; +import { getDefaultLogPath, createLog as createFileLog, createLog, openRevertLog } from '../../common/log-helpers'; import * as copyConfig from '../../common/content-item/copy-config'; import { ImportItemBuilderOptions } from '../../interfaces/import-item-builder-options.interface'; import { FileLog } from '../../common/file-log'; @@ -59,7 +59,8 @@ describe('content-item move command', () => { expect(spyOption).toHaveBeenCalledWith('revertLog', { type: 'string', describe: - 'Path to a log file to revert a move for. This will archive the most recently moved resources from the destination, unarchive from the source, and revert updated ones.' + 'Path to a log file to revert a move for. This will archive the most recently moved resources from the destination, unarchive from the source, and revert updated ones.', + coerce: openRevertLog }); expect(spyOption).toHaveBeenCalledWith('srcRepo', { @@ -158,7 +159,9 @@ describe('content-item move command', () => { clientId: 'client-id', clientSecret: 'client-id', hubId: 'hub-id', - logFile: new FileLog() + + logFile: new FileLog(), + revertLog: Promise.resolve(undefined) }; beforeAll(async () => { @@ -300,7 +303,7 @@ describe('content-item move command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - revertLog: 'temp/move/moveRevert.txt' + revertLog: openRevertLog('temp/move/moveRevert.txt') }; await handler(argv); @@ -316,7 +319,8 @@ describe('content-item move command', () => { clientSecret: 'acc2-secret', dir: '', hubId: 'hub2-id', - revertLog: 'temp/move/moveRevert.txt' + revertLog: expect.any(Promise), + logFile: expect.any(FileLog) }); rimraf('temp/move/moveRevert.txt'); @@ -355,7 +359,7 @@ describe('content-item move command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - revertLog: 'temp/move/moveRevertFetch.txt' + revertLog: openRevertLog('temp/move/moveRevertFetch.txt') }; await handler(argv); @@ -367,8 +371,6 @@ describe('content-item move command', () => { // should revert uninterrupted when unarchiving an item fails - // should abort early when passing a missing revert log - it('should abort early when passing a missing revert log', async () => { const copyCalls: Arguments[] = copierAny.calls; @@ -390,7 +392,7 @@ describe('content-item move command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - revertLog: 'temp/move/moveRevertMissing.txt' + revertLog: openRevertLog('temp/move/moveRevertMissing.txt') }; await handler(argv); @@ -513,7 +515,7 @@ describe('content-item move command', () => { dstClientId: 'acc2-id', dstSecret: 'acc2-secret', - revertLog: 'temp/move/abort.txt' + revertLog: Promise.resolve(new FileLog()) }; await handler(argv); diff --git a/src/commands/content-item/move.ts b/src/commands/content-item/move.ts index 8616ba26..050d4567 100644 --- a/src/commands/content-item/move.ts +++ b/src/commands/content-item/move.ts @@ -1,4 +1,4 @@ -import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { createLog, getDefaultLogPath, openRevertLog } from '../../common/log-helpers'; import { Argv, Arguments } from 'yargs'; import { CopyItemBuilderOptions } from '../../interfaces/copy-item-builder-options.interface'; import { ConfigurationParameters } from '../configure'; @@ -10,6 +10,7 @@ import dynamicContentClientFactory from '../../services/dynamic-content-client-f import { ContentItem, Status } from 'dc-management-sdk-js'; import { loadCopyConfig } from '../../common/content-item/copy-config'; import { revert } from './import-revert'; +import { LogErrorLevel } from '../../common/archive/archive-log'; export const command = 'move'; @@ -23,7 +24,8 @@ export const builder = (yargs: Argv): void => { .option('revertLog', { type: 'string', describe: - 'Path to a log file to revert a move for. This will archive the most recently moved resources from the destination, unarchive from the source, and revert updated ones.' + 'Path to a log file to revert a move for. This will archive the most recently moved resources from the destination, unarchive from the source, and revert updated ones.', + coerce: openRevertLog }) .option('srcRepo', { @@ -140,7 +142,14 @@ export const builder = (yargs: Argv): void => { export const handler = async (argv: Arguments): Promise => { argv.exportedIds = []; - if (argv.revertLog != null) { + const revertLog = await argv.revertLog; + + if (revertLog) { + if (revertLog.errorLevel === LogErrorLevel.INVALID) { + console.error('Could not read the revert log.'); + return; + } + const copyConfig = await loadCopyConfig(argv, new FileLog()); if (copyConfig == null) { @@ -154,15 +163,7 @@ export const handler = async (argv: Arguments { - const items = await paginator(source.related.contentItems.list, { status: 'ARCHIVED' }); - + const items = await paginator(source.related.contentItems.list, { status: Status.ARCHIVED }); contentItems.push(...items); }) ) : await Promise.all( contentRepositories.map(async source => { - const items = await paginator(source.related.contentItems.list, { status: 'ARCHIVED' }); - + const items = await paginator(source.related.contentItems.list, { status: Status.ARCHIVED }); contentItems.push(...items); }) ); diff --git a/src/commands/content-type-schema/__snapshots__/export.spec.ts.snap b/src/commands/content-type-schema/__snapshots__/export.spec.ts.snap index 1e554ad6..f55265ae 100644 --- a/src/commands/content-type-schema/__snapshots__/export.spec.ts.snap +++ b/src/commands/content-type-schema/__snapshots__/export.spec.ts.snap @@ -581,35 +581,52 @@ exports[`content-type-schema export command processContentTypeSchemas should not Array [ Array [ Array [ - "File", - "Schema file", - "Schema ID", - "Result", - ], - ], - Array [ - Array [ - "export-dir/export-filename-1.json", - "", - "content-type-schema-id-1", - "UP-TO-DATE", - ], - ], - Array [ - Array [ - "export-dir/export-filename-2.json", - "", - "content-type-schema-id-2", - "UP-TO-DATE", - ], - ], - Array [ - Array [ - "export-dir/export-filename-3.json", - "", - "content-type-schema-id-3", - "UP-TO-DATE", + Array [ + "File", + "Schema file", + "Schema ID", + "Result", + ], + Array [ + "export-dir/export-filename-1.json", + "", + "content-type-schema-id-1", + "UP-TO-DATE", + ], + Array [ + "export-dir/export-filename-2.json", + "", + "content-type-schema-id-2", + "UP-TO-DATE", + ], + Array [ + "export-dir/export-filename-3.json", + "", + "content-type-schema-id-3", + "UP-TO-DATE", + ], ], + Object { + "border": undefined, + "columnCount": 4, + "columnDefault": Object { + "width": 50, + }, + "columns": Object { + "0": Object { + "width": 30, + }, + "1": Object { + "width": 30, + }, + "2": Object { + "width": 100, + }, + "3": Object { + "width": 10, + }, + }, + }, ], ] `; @@ -727,35 +744,52 @@ exports[`content-type-schema export command processContentTypeSchemas should out Array [ Array [ Array [ - "File", - "Schema file", - "Schema ID", - "Result", - ], - ], - Array [ - Array [ - "export-dir/export-filename-1.json", - "export-dir/schemas/export-filename-1-schema.json", - "content-type-schema-id-1", - "CREATED", - ], - ], - Array [ - Array [ - "export-dir/export-filename-2.json", - "export-dir/schemas/export-filename-2-schema.json", - "content-type-schema-id-2", - "CREATED", - ], - ], - Array [ - Array [ - "export-dir/export-filename-3.json", - "export-dir/schemas/export-filename-3-schema.json", - "content-type-schema-id-3", - "CREATED", + Array [ + "File", + "Schema file", + "Schema ID", + "Result", + ], + Array [ + "export-dir/export-filename-1.json", + "export-dir/schemas/export-filename-1-schema.json", + "content-type-schema-id-1", + "CREATED", + ], + Array [ + "export-dir/export-filename-2.json", + "export-dir/schemas/export-filename-2-schema.json", + "content-type-schema-id-2", + "CREATED", + ], + Array [ + "export-dir/export-filename-3.json", + "export-dir/schemas/export-filename-3-schema.json", + "content-type-schema-id-3", + "CREATED", + ], ], + Object { + "border": undefined, + "columnCount": 4, + "columnDefault": Object { + "width": 50, + }, + "columns": Object { + "0": Object { + "width": 30, + }, + "1": Object { + "width": 30, + }, + "2": Object { + "width": 100, + }, + "3": Object { + "width": 10, + }, + }, + }, ], ] `; @@ -804,35 +838,52 @@ exports[`content-type-schema export command processContentTypeSchemas should upd Array [ Array [ Array [ - "File", - "Schema file", - "Schema ID", - "Result", - ], - ], - Array [ - Array [ - "export-dir/export-filename-1.json", - "", - "content-type-schema-id-1", - "UP-TO-DATE", - ], - ], - Array [ - Array [ - "export-dir/export-filename-2.json", - "", - "content-type-schema-id-2", - "UP-TO-DATE", - ], - ], - Array [ - Array [ - "export-dir/export-filename-3.json", - "export-dir/schemas/export-filename-3-schema.json", - "content-type-schema-id-3", - "UPDATED", + Array [ + "File", + "Schema file", + "Schema ID", + "Result", + ], + Array [ + "export-dir/export-filename-1.json", + "", + "content-type-schema-id-1", + "UP-TO-DATE", + ], + Array [ + "export-dir/export-filename-2.json", + "", + "content-type-schema-id-2", + "UP-TO-DATE", + ], + Array [ + "export-dir/export-filename-3.json", + "export-dir/schemas/export-filename-3-schema.json", + "content-type-schema-id-3", + "UPDATED", + ], ], + Object { + "border": undefined, + "columnCount": 4, + "columnDefault": Object { + "width": 50, + }, + "columns": Object { + "0": Object { + "width": 30, + }, + "1": Object { + "width": 30, + }, + "2": Object { + "width": 100, + }, + "3": Object { + "width": 10, + }, + }, + }, ], ] `; diff --git a/src/commands/content-type-schema/archive.ts b/src/commands/content-type-schema/archive.ts index 0583b2ab..fc4ba6c2 100644 --- a/src/commands/content-type-schema/archive.ts +++ b/src/commands/content-type-schema/archive.ts @@ -1,6 +1,6 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; -import { ContentTypeSchema } from 'dc-management-sdk-js'; +import { ContentTypeSchema, Status } from 'dc-management-sdk-js'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ArchiveLog } from '../../common/archive/archive-log'; import paginator from '../../common/dc-management-sdk-js/paginator'; @@ -78,8 +78,8 @@ export const handler = async (argv: Arguments client.contentTypeSchemas.get(id))); } catch (e) { console.log(`Fatal error: could not find schema with ID ${id}. Error: \n${e.toString()}`); return; @@ -87,7 +87,7 @@ export const handler = async (argv: Arguments { 'The Schema ID of a Content Type Schema to be exported.\nIf no --schemaId option is given, all content type schemas for the hub are exported.\nA single --schemaId option may be given to export a single content type schema.\nMultiple --schemaId options may be given to export multiple content type schemas at the same time.', requiresArg: true }); + expect(spyOption).toHaveBeenCalledWith('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite content type schema without asking.' + }); expect(spyOption).toHaveBeenCalledWith('archived', { type: 'boolean', describe: 'If present, archived content type schemas will also be considered.', boolean: true }); + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); }); }); @@ -75,7 +89,8 @@ describe('content-type-schema export command', (): void => { let mockGetContentTypeSchemaExports: jest.SpyInstance; let mockWriteJsonToFile: jest.SpyInstance; let mockWriteSchemaBody: jest.SpyInstance; - const mockStreamWrite = jest.fn(); + let mockTable: jest.Mock; + const exportedContentTypeSchemas = [ { schemaId: 'content-type-schema-id-1', @@ -106,9 +121,8 @@ describe('content-type-schema export command', (): void => { mockGetContentTypeSchemaExports = jest.spyOn(exportModule, 'getContentTypeSchemaExports'); mockWriteSchemaBody = jest.spyOn(exportModule, 'writeSchemaBody'); mockWriteJsonToFile = jest.spyOn(exportServiceModule, 'writeJsonToFile'); - (createStream as jest.Mock).mockReturnValue({ - write: mockStreamWrite - }); + mockTable = table as jest.Mock; + mockTable.mockImplementation(jest.requireActual('table').table); mockWriteJsonToFile.mockImplementation(); mockWriteSchemaBody.mockImplementation(); }); @@ -142,7 +156,7 @@ describe('content-type-schema export command', (): void => { [] ]); - await processContentTypeSchemas('export-dir', {}, contentTypeSchemasToProcess); + await processContentTypeSchemas('export-dir', {}, contentTypeSchemasToProcess, new FileLog(), false); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledTimes(1); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledWith('export-dir', {}, contentTypeSchemasToProcess); @@ -155,8 +169,8 @@ describe('content-type-schema export command', (): void => { expect(mockWriteSchemaBody).toHaveBeenCalledTimes(3); expect(mockWriteSchemaBody.mock.calls).toMatchSnapshot(); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite.mock.calls).toMatchSnapshot(); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable.mock.calls).toMatchSnapshot(); }); it('should not output any export files if a previous export exists and the content type is unchanged', async () => { @@ -184,7 +198,13 @@ describe('content-type-schema export command', (): void => { const previouslyExportedContentTypeSchemas = { 'export-dir/export-filename-2.json': contentTypeSchemasToProcess[1] }; - await processContentTypeSchemas('export-dir', previouslyExportedContentTypeSchemas, contentTypeSchemasToProcess); + await processContentTypeSchemas( + 'export-dir', + previouslyExportedContentTypeSchemas, + contentTypeSchemasToProcess, + new FileLog(), + false + ); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledTimes(1); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledWith( @@ -197,8 +217,8 @@ describe('content-type-schema export command', (): void => { expect(mockWriteJsonToFile).toHaveBeenCalledTimes(0); expect(mockWriteSchemaBody).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite.mock.calls).toMatchSnapshot(); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable.mock.calls).toMatchSnapshot(); }); it('should update the existing export file for a changed content type', async () => { @@ -241,7 +261,13 @@ describe('content-type-schema export command', (): void => { 'export-dir/export-filename-3.json': contentTypeSchemasToProcess[2] }; - await processContentTypeSchemas('export-dir', previouslyExportedContentTypeSchemas, mutatedContentTypeSchemas); + await processContentTypeSchemas( + 'export-dir', + previouslyExportedContentTypeSchemas, + mutatedContentTypeSchemas, + new FileLog(), + false + ); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledTimes(1); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledWith( @@ -258,8 +284,8 @@ describe('content-type-schema export command', (): void => { expect(mockWriteSchemaBody).toHaveBeenCalledTimes(1); expect(mockWriteSchemaBody.mock.calls).toMatchSnapshot(); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite.mock.calls).toMatchSnapshot(); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable.mock.calls).toMatchSnapshot(); }); it('should not update anything if the user says "n" to the overwrite prompt', async () => { @@ -271,10 +297,6 @@ describe('content-type-schema export command', (): void => { validationLevel: ValidationLevel.CONTENT_TYPE }); - const exitError = new Error('ERROR TO VALIDATE PROCESS EXIT'); - jest.spyOn(process, 'exit').mockImplementation(() => { - throw exitError; - }); const stdoutSpy = jest.spyOn(process.stdout, 'write'); stdoutSpy.mockImplementation(); @@ -309,9 +331,13 @@ describe('content-type-schema export command', (): void => { 'export-dir/export-filename-3.json': contentTypeSchemasToProcess[2] }; - await expect( - processContentTypeSchemas('export-dir', previouslyExportedContentTypeSchemas, mutatedContentTypeSchemas) - ).rejects.toThrowError(exitError); + await processContentTypeSchemas( + 'export-dir', + previouslyExportedContentTypeSchemas, + mutatedContentTypeSchemas, + new FileLog(), + false + ); expect(stdoutSpy.mock.calls).toMatchSnapshot(); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledTimes(1); @@ -324,19 +350,14 @@ describe('content-type-schema export command', (): void => { expect(mockEnsureDirectory).toHaveBeenCalledTimes(0); expect(mockWriteJsonToFile).toHaveBeenCalledTimes(0); expect(mockWriteSchemaBody).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(0); - expect(process.exit).toHaveBeenCalled(); + expect(mockTable).toHaveBeenCalledTimes(0); }); it('should not do anything if the list of schemas to export is empty', async () => { - const exitError = new Error('ERROR TO VALIDATE PROCESS EXIT'); - jest.spyOn(process, 'exit').mockImplementation(() => { - throw exitError; - }); const stdoutSpy = jest.spyOn(process.stdout, 'write'); stdoutSpy.mockImplementation(); - await expect(processContentTypeSchemas('export-dir', {}, [])).rejects.toThrowError(exitError); + expect(processContentTypeSchemas('export-dir', {}, [], new FileLog(), false)); expect(stdoutSpy.mock.calls).toMatchSnapshot(); expect(mockGetContentTypeSchemaExports).toHaveBeenCalledTimes(0); @@ -344,8 +365,7 @@ describe('content-type-schema export command', (): void => { expect(mockEnsureDirectory).toHaveBeenCalledTimes(0); expect(mockWriteJsonToFile).toHaveBeenCalledTimes(0); expect(mockWriteSchemaBody).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(0); - expect(process.exit).toHaveBeenCalled(); + expect(mockTable).toHaveBeenCalledTimes(0); }); }); @@ -597,7 +617,8 @@ describe('content-type-schema export command', (): void => { const config = { clientId: 'client-id', clientSecret: 'client-id', - hubId: 'hub-id' + hubId: 'hub-id', + logFile: new FileLog() }; const contentTypeSchemasToExport: ContentTypeSchema[] = [ new ContentTypeSchema({ @@ -644,6 +665,10 @@ describe('content-type-schema export command', (): void => { }); }); + function expectProcessArguments(dir: string, schemas: ContentTypeSchema[]): void { + expect(processContentTypeSchemasSpy.mock.calls[0].slice(0, 3)).toEqual([dir, {}, schemas]); + } + it('should export all content type schemas for the current hub', async (): Promise => { filterContentTypeSchemasBySchemaIdSpy.mockReturnValue(contentTypeSchemasToExport); @@ -655,7 +680,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); it('should ignore any resolve schema errors', async (): Promise => { @@ -670,7 +695,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); it('should export all content type schemas for the current hub if schemaId is not supplied', async (): Promise< @@ -686,7 +711,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); it('should export only the specified content type schema when schemaId is provided', async (): Promise => { @@ -703,7 +728,7 @@ describe('content-type-schema export command', (): void => { expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, [ 'content-type-schema-id-1' ]); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, filteredContentTypeSchemas); + expectProcessArguments(argv.dir, filteredContentTypeSchemas); }); it('should export all content type schemas when schemaId is undefined', async (): Promise => { @@ -715,7 +740,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); it('should export all content type schemas when schemaId is an empty array', async (): Promise => { @@ -727,7 +752,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); it('should export even archived content type schemas when --archived is provided', async (): Promise => { @@ -739,7 +764,7 @@ describe('content-type-schema export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentTypeSchema); expect(resolveSchemaBodyMock).toHaveBeenCalledWith({}, 'my-dir'); expect(filterContentTypeSchemasBySchemaIdSpy).toHaveBeenCalledWith(contentTypeSchemasToExport, []); - expect(processContentTypeSchemasSpy).toHaveBeenCalledWith(argv.dir, {}, contentTypeSchemasToExport); + expectProcessArguments(argv.dir, contentTypeSchemasToExport); }); }); diff --git a/src/commands/content-type-schema/export.ts b/src/commands/content-type-schema/export.ts index 83583ea7..2622971b 100644 --- a/src/commands/content-type-schema/export.ts +++ b/src/commands/content-type-schema/export.ts @@ -2,10 +2,9 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { ContentTypeSchema } from 'dc-management-sdk-js'; -import { createStream } from 'table'; +import { ContentTypeSchema, Status } from 'dc-management-sdk-js'; +import { table } from 'table'; import { baseTableConfig } from '../../common/table/table.consts'; -import { TableStream } from '../../interfaces/table.interface'; import chalk from 'chalk'; import { ExportResult, @@ -20,6 +19,8 @@ import * as path from 'path'; import * as fs from 'fs'; import { resolveSchemaBody } from '../../services/resolve-schema-body'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import { FileLog } from '../../common/file-log'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; export const streamTableOptions = { ...baseTableConfig, @@ -47,6 +48,9 @@ export const command = 'export '; export const desc = 'Export Content Type Schemas'; +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('schema', 'export', platform); + export const builder = (yargs: Argv): void => { yargs .positional('dir', { @@ -59,10 +63,22 @@ export const builder = (yargs: Argv): void => { 'The Schema ID of a Content Type Schema to be exported.\nIf no --schemaId option is given, all content type schemas for the hub are exported.\nA single --schemaId option may be given to export a single content type schema.\nMultiple --schemaId options may be given to export multiple content type schemas at the same time.', requiresArg: true }) + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite content type schema without asking.' + }) .option('archived', { type: 'boolean', describe: 'If present, archived content type schemas will also be considered.', boolean: true + }) + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog }); }; @@ -199,10 +215,13 @@ export const getContentTypeSchemaExports = ( export const processContentTypeSchemas = async ( outputDir: string, previouslyExportedContentTypeSchemas: { [filename: string]: ContentTypeSchema }, - storedContentTypeSchemas: ContentTypeSchema[] + storedContentTypeSchemas: ContentTypeSchema[], + log: FileLog, + force: boolean ): Promise => { if (storedContentTypeSchemas.length === 0) { - nothingExportedExit('No content type schemas to export from this hub, exiting.\n'); + nothingExportedExit(log, 'No content type schemas to export from this hub, exiting.'); + return; } const [allExports, updatedExportsMap] = getContentTypeSchemaExports( @@ -212,15 +231,16 @@ export const processContentTypeSchemas = async ( ); if ( allExports.length === 0 || - (Object.keys(updatedExportsMap).length > 0 && !(await promptToOverwriteExports(updatedExportsMap))) + (Object.keys(updatedExportsMap).length > 0 && !(force || (await promptToOverwriteExports(updatedExportsMap, log)))) ) { - nothingExportedExit(); + nothingExportedExit(log); + return; } await ensureDirectoryExists(outputDir); - const tableStream = (createStream(streamTableOptions) as unknown) as TableStream; - tableStream.write([chalk.bold('File'), chalk.bold('Schema file'), chalk.bold('Schema ID'), chalk.bold('Result')]); + const data: string[][] = []; + data.push([chalk.bold('File'), chalk.bold('Schema file'), chalk.bold('Schema ID'), chalk.bold('Result')]); for (const { filename, status, contentTypeSchema } of allExports) { let schemaFilename = ''; if (status !== 'UP-TO-DATE') { @@ -239,24 +259,28 @@ export const processContentTypeSchemas = async ( }) ); } - tableStream.write([filename, schemaFilename, contentTypeSchema.schemaId || '', status]); + data.push([filename, schemaFilename, contentTypeSchema.schemaId || '', status]); } - process.stdout.write('\n'); + + log.appendLine(table(data, streamTableOptions)); }; export const handler = async (argv: Arguments): Promise => { - const { dir, schemaId } = argv; + const { dir, schemaId, logFile, force } = argv; const [contentTypeSchemas] = await resolveSchemaBody( loadJsonFromDirectory(dir, ContentTypeSchema), dir ); const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); + const log = logFile.open(); const storedContentTypeSchemas = await paginator( hub.related.contentTypeSchema.list, - argv.archived ? undefined : { status: 'ACTIVE' } + argv.archived ? undefined : { status: Status.ACTIVE } ); const schemaIdArray: string[] = schemaId ? (Array.isArray(schemaId) ? schemaId : [schemaId]) : []; const filteredContentTypeSchemas = filterContentTypeSchemasBySchemaId(storedContentTypeSchemas, schemaIdArray); - await processContentTypeSchemas(dir, contentTypeSchemas, filteredContentTypeSchemas); + await processContentTypeSchemas(dir, contentTypeSchemas, filteredContentTypeSchemas, log, force || false); + + await log.close(); }; diff --git a/src/commands/content-type-schema/import.spec.ts b/src/commands/content-type-schema/import.spec.ts index 3a507f17..6b2c239e 100644 --- a/src/commands/content-type-schema/import.spec.ts +++ b/src/commands/content-type-schema/import.spec.ts @@ -1,15 +1,28 @@ import Yargs = require('yargs/yargs'); import * as importModule from './import'; -import { command, builder, handler, storedSchemaMapper, processSchemas, doCreate, doUpdate } from './import'; +import { + command, + builder, + handler, + storedSchemaMapper, + processSchemas, + doCreate, + doUpdate, + LOG_FILENAME +} from './import'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ContentTypeSchema, ValidationLevel, Hub } from 'dc-management-sdk-js'; -import { createStream } from 'table'; +import { table } from 'table'; import { createContentTypeSchema } from './create.service'; import { updateContentTypeSchema } from './update.service'; import paginator from '../../common/dc-management-sdk-js/paginator'; import { loadJsonFromDirectory, UpdateStatus } from '../../services/import.service'; import { resolveSchemaBody } from '../../services/resolve-schema-body'; +import { FileLog } from '../../common/file-log'; +import { streamTableOptions } from '../../common/table/table.consts'; +import chalk from 'chalk'; +import { createLog } from '../../common/log-helpers'; jest.mock('fs'); jest.mock('table'); @@ -37,12 +50,20 @@ describe('content-type-schema import command', (): void => { it('should configure yargs', () => { const argv = Yargs(process.argv.slice(2)); const spyPositional = jest.spyOn(argv, 'positional').mockReturnThis(); + const spyOption = jest.spyOn(argv, 'option').mockReturnThis(); builder(argv); expect(spyPositional).toHaveBeenCalledWith('dir', { describe: 'Directory containing Content Type Schema definitions', type: 'string' }); + + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); }); }); @@ -88,13 +109,14 @@ describe('content-type-schema import command', (): void => { describe('doCreate', () => { it('should create a content type schema and report the results', async () => { const hub = new Hub(); + const log = new FileLog(); const contentTypeSchema = { body: schemaBodyJson, validationLevel: ValidationLevel.CONTENT_TYPE } as ContentTypeSchema; (createContentTypeSchema as jest.Mock).mockResolvedValueOnce({ ...contentTypeSchema, id: 'create-id', schemaId }); - const result = await doCreate(hub, contentTypeSchema); + const result = await doCreate(hub, contentTypeSchema, log); expect(createContentTypeSchema).toHaveBeenCalledWith( contentTypeSchema.body, @@ -102,10 +124,16 @@ describe('content-type-schema import command', (): void => { hub ); expect(result).toEqual({ ...contentTypeSchema, id: 'create-id', schemaId }); + expect(log.getData('CREATE')).toMatchInlineSnapshot(` + Array [ + "create-id", + ] + `); }); it('should throw an error when content type schema fails to create', async () => { const hub = new Hub(); + const log = new FileLog(); const contentTypeSchema = { body: schemaBodyJson, @@ -115,7 +143,8 @@ describe('content-type-schema import command', (): void => { throw new Error('Error creating content type schema'); }); - await expect(doCreate(hub, contentTypeSchema)).rejects.toThrowErrorMatchingSnapshot(); + await expect(doCreate(hub, contentTypeSchema, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('CREATE')).toEqual([]); }); }); @@ -131,16 +160,19 @@ describe('content-type-schema import command', (): void => { }); it('should update a content type schema and report the results', async () => { const client = (dynamicContentClientFactory as jest.Mock)(); + const log = new FileLog(); const storedContentTypeSchema = { id: 'stored-id', schemaId, body: schemaBodyJson, - validationLevel: ValidationLevel.CONTENT_TYPE + validationLevel: ValidationLevel.CONTENT_TYPE, + version: 1 } as ContentTypeSchema; const mutatedContentTypeSchema = { ...storedContentTypeSchema, - body: `{\n\t"$schema": "http://json-schema.org/draft-07/schema#",\n\t"$id": "${schemaId}",\n\n\t"title": "Test Schema 1 - updated",\n\t"description": "Test Schema 1- updated",\n\n\t"allOf": [\n\t\t{\n\t\t\t"$ref": "http://bigcontent.io/cms/schema/v1/core#/definitions/content"\n\t\t}\n\t],\n\t\n\t"type": "object",\n\t"properties": {\n\t\t\n\t},\n\t"propertyOrder": []\n}` + body: `{\n\t"$schema": "http://json-schema.org/draft-07/schema#",\n\t"$id": "${schemaId}",\n\n\t"title": "Test Schema 1 - updated",\n\t"description": "Test Schema 1- updated",\n\n\t"allOf": [\n\t\t{\n\t\t\t"$ref": "http://bigcontent.io/cms/schema/v1/core#/definitions/content"\n\t\t}\n\t],\n\t\n\t"type": "object",\n\t"properties": {\n\t\t\n\t},\n\t"propertyOrder": []\n}`, + version: 2 } as ContentTypeSchema; mockGetContentTypeSchema.mockResolvedValueOnce(new ContentTypeSchema(storedContentTypeSchema)); (updateContentTypeSchema as jest.Mock).mockResolvedValueOnce({ @@ -148,7 +180,13 @@ describe('content-type-schema import command', (): void => { id: 'stored-id', schemaId }); - const result = await doUpdate(client, mutatedContentTypeSchema); + const result = await doUpdate(client, mutatedContentTypeSchema, log); + + expect(log.getData('UPDATE')).toMatchInlineSnapshot(` + Array [ + "stored-id 1 2", + ] + `); expect(updateContentTypeSchema).toHaveBeenCalledWith( expect.objectContaining(storedContentTypeSchema), @@ -160,15 +198,18 @@ describe('content-type-schema import command', (): void => { it('should update a content type when only the validationLevel has been updated', async () => { const client = (dynamicContentClientFactory as jest.Mock)(); + const log = new FileLog(); const storedContentTypeSchema = { id: 'stored-id', schemaId, body: schemaBodyJson, - validationLevel: ValidationLevel.CONTENT_TYPE + validationLevel: ValidationLevel.CONTENT_TYPE, + version: 1 } as ContentTypeSchema; const mutatedContentTypeSchema = { ...storedContentTypeSchema, - validationLevel: ValidationLevel.SLOT + validationLevel: ValidationLevel.SLOT, + version: 2 } as ContentTypeSchema; mockGetContentTypeSchema.mockResolvedValueOnce(new ContentTypeSchema(storedContentTypeSchema)); (updateContentTypeSchema as jest.Mock).mockResolvedValueOnce({ @@ -176,8 +217,13 @@ describe('content-type-schema import command', (): void => { id: 'stored-id', schemaId }); - const result = await doUpdate(client, mutatedContentTypeSchema); + const result = await doUpdate(client, mutatedContentTypeSchema, log); + expect(log.getData('UPDATE')).toMatchInlineSnapshot(` + Array [ + "stored-id 1 2", + ] + `); expect(updateContentTypeSchema).toHaveBeenCalledWith( expect.objectContaining(storedContentTypeSchema), mutatedContentTypeSchema.body, @@ -188,6 +234,7 @@ describe('content-type-schema import command', (): void => { it('should skip updating a content type schema when no changes detected and report the results', async () => { const client = (dynamicContentClientFactory as jest.Mock)(); + const log = new FileLog(); const storedContentTypeSchema = { id: 'stored-id', @@ -201,14 +248,16 @@ describe('content-type-schema import command', (): void => { } as ContentTypeSchema; mockGetContentTypeSchema.mockResolvedValueOnce(new ContentTypeSchema(storedContentTypeSchema)); - const result = await doUpdate(client, mutatedContentTypeSchema); + const result = await doUpdate(client, mutatedContentTypeSchema, log); + expect(log.getData('UPDATE')).toEqual([]); expect(updateContentTypeSchema).toHaveBeenCalledTimes(0); expect(result).toEqual(expect.objectContaining({ updateStatus: UpdateStatus.SKIPPED })); }); it('should throw an error when content type schema fails to create', async () => { const client = (dynamicContentClientFactory as jest.Mock)(); + const log = new FileLog(); const contentTypeSchema = { id: 'stored-id', @@ -219,17 +268,17 @@ describe('content-type-schema import command', (): void => { mockGetContentTypeSchema.mockImplementationOnce(() => { throw new Error('Error getting content type schema'); }); - await expect(doUpdate(client, contentTypeSchema)).rejects.toThrowErrorMatchingSnapshot(); + await expect(doUpdate(client, contentTypeSchema, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); }); }); describe('processSchemas', () => { - const mockStreamWrite = jest.fn(); + let mockTable: jest.Mock; beforeEach(() => { - (createStream as jest.Mock).mockReturnValue({ - write: mockStreamWrite - }); + mockTable = table as jest.Mock; + mockTable.mockImplementation(jest.requireActual('table').table); }); it('should successfully create and update a schema', async () => { @@ -251,13 +300,20 @@ describe('content-type-schema import command', (): void => { .spyOn(importModule, 'doUpdate') .mockResolvedValueOnce({ contentTypeSchema: contentTypeSchemaToUpdate, updateStatus: UpdateStatus.UPDATED }); - await processSchemas(schemasToProcess, client, hub); - - expect(importModule.doCreate).toHaveBeenCalledWith(hub, contentTypeSchemaToCreate); - expect(importModule.doUpdate).toHaveBeenCalledWith(client, contentTypeSchemaToUpdate); - expect(mockStreamWrite).toHaveBeenCalledTimes(3); - expect(mockStreamWrite).toHaveBeenNthCalledWith(2, ['new-id', schemaId, 'CREATED']); - expect(mockStreamWrite).toHaveBeenNthCalledWith(3, ['stored-id', schemaId, 'UPDATED']); + await processSchemas(schemasToProcess, client, hub, new FileLog()); + + expect(importModule.doCreate).toHaveBeenCalledWith(hub, contentTypeSchemaToCreate, expect.any(FileLog)); + expect(importModule.doUpdate).toHaveBeenCalledWith(client, contentTypeSchemaToUpdate, expect.any(FileLog)); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable).toHaveBeenNthCalledWith( + 1, + [ + [chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')], + ['new-id', schemaId, 'CREATED'], + ['stored-id', schemaId, 'UPDATED'] + ], + streamTableOptions + ); }); }); @@ -275,7 +331,8 @@ describe('content-type-schema import command', (): void => { const argv = { ...yargArgs, ...config, - dir: 'my-dir' + dir: 'my-dir', + logFile: new FileLog() }; beforeEach(() => { @@ -317,6 +374,7 @@ describe('content-type-schema import command', (): void => { expect(processSchemasSpy).toHaveBeenCalledWith( [expect.objectContaining(schemaToCreate), expect.objectContaining({ ...schemaToUpdate, id: 'stored-id' })], expect.any(Object), + expect.any(Object), expect.any(Object) ); }); diff --git a/src/commands/content-type-schema/import.ts b/src/commands/content-type-schema/import.ts index 6f508225..3a4501d0 100644 --- a/src/commands/content-type-schema/import.ts +++ b/src/commands/content-type-schema/import.ts @@ -1,17 +1,18 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; -import { ContentTypeSchema, DynamicContent, Hub, ValidationLevel } from 'dc-management-sdk-js'; +import { ContentTypeSchema, DynamicContent, Hub, Status, ValidationLevel } from 'dc-management-sdk-js'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { createStream } from 'table'; +import { table } from 'table'; import { streamTableOptions } from '../../common/table/table.consts'; -import { TableStream } from '../../interfaces/table.interface'; import { ImportBuilderOptions } from '../../interfaces/import-builder-options.interface'; import chalk from 'chalk'; import { createContentTypeSchema } from './create.service'; import { updateContentTypeSchema } from './update.service'; import { ImportResult, loadJsonFromDirectory, UpdateStatus } from '../../services/import.service'; import { resolveSchemaBody } from '../../services/resolve-schema-body'; +import { FileLog } from '../../common/file-log'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; export const command = 'import '; @@ -21,11 +22,21 @@ export interface SchemaOptions { validation: ValidationLevel; } +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('schema', 'import', platform); + export const builder = (yargs: Argv): void => { yargs.positional('dir', { describe: 'Directory containing Content Type Schema definitions', type: 'string' }); + + yargs.option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); }; export const storedSchemaMapper = ( @@ -38,7 +49,7 @@ export const storedSchemaMapper = ( return new ContentTypeSchema(mutatedSchema); }; -export const doCreate = async (hub: Hub, schema: ContentTypeSchema): Promise => { +export const doCreate = async (hub: Hub, schema: ContentTypeSchema, log: FileLog): Promise => { try { const createdSchemaType = await createContentTypeSchema( schema.body || '', @@ -46,6 +57,8 @@ export const doCreate = async (hub: Hub, schema: ContentTypeSchema): Promise export const doUpdate = async ( client: DynamicContent, - schema: ContentTypeSchema + schema: ContentTypeSchema, + log: FileLog ): Promise<{ contentTypeSchema: ContentTypeSchema; updateStatus: UpdateStatus }> => { try { - const retrievedSchema = await client.contentTypeSchemas.get(schema.id || ''); + let retrievedSchema: ContentTypeSchema = await client.contentTypeSchemas.get(schema.id || ''); if (equals(retrievedSchema, schema)) { return { contentTypeSchema: retrievedSchema, updateStatus: UpdateStatus.SKIPPED }; } + + if (retrievedSchema.status === Status.ARCHIVED) { + try { + // Resurrect this schema before updating it. + retrievedSchema = await retrievedSchema.related.unarchive(); + } catch (err) { + throw new Error(`Error unable unarchive content type ${schema.id}: ${err.message}`); + } + } + const updatedSchema = await updateContentTypeSchema( retrievedSchema, schema.body || '', schema.validationLevel || ValidationLevel.CONTENT_TYPE ); + log.addAction('UPDATE', `${retrievedSchema.id} ${retrievedSchema.version} ${updatedSchema.version}`); + return { contentTypeSchema: updatedSchema, updateStatus: UpdateStatus.UPDATED }; } catch (err) { throw new Error(`Error updating content type schema ${schema.schemaId || ''}: ${err.message}`); @@ -79,31 +105,34 @@ export const doUpdate = async ( export const processSchemas = async ( schemasToProcess: ContentTypeSchema[], client: DynamicContent, - hub: Hub + hub: Hub, + log: FileLog ): Promise => { - const tableStream = (createStream(streamTableOptions) as unknown) as TableStream; + const data: string[][] = []; - tableStream.write([chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')]); + data.push([chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')]); for (const schema of schemasToProcess) { let status: ImportResult; let contentTypeSchema: ContentTypeSchema; if (schema.id) { - const result = await doUpdate(client, schema); + const result = await doUpdate(client, schema, log); contentTypeSchema = result.contentTypeSchema; status = result.updateStatus === UpdateStatus.SKIPPED ? 'UP-TO-DATE' : 'UPDATED'; } else { - contentTypeSchema = await doCreate(hub, schema); + contentTypeSchema = await doCreate(hub, schema, log); status = 'CREATED'; } - tableStream.write([contentTypeSchema.id || '', contentTypeSchema.schemaId || '', status]); + data.push([contentTypeSchema.id || '', contentTypeSchema.schemaId || '', status]); } - process.stdout.write('\n'); + + log.appendLine(table(data, streamTableOptions)); }; export const handler = async (argv: Arguments): Promise => { - const { dir } = argv; + const { dir, logFile } = argv; const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); + const log = logFile.open(); const schemas = loadJsonFromDirectory(dir, ContentTypeSchema); const [resolvedSchemas, resolveSchemaErrors] = await resolveSchemaBody(schemas, dir); if (Object.keys(resolveSchemaErrors).length > 0) { @@ -120,5 +149,7 @@ export const handler = async (argv: Arguments client.contentTypes.get(id))); } catch (e) { console.log(`Fatal error: could not find content type with ID ${id}. Error: \n${e.toString()}`); return; @@ -88,7 +88,7 @@ export const handler = async (argv: Arguments { 'The Schema ID of a Content Type to be exported.\nIf no --schemaId option is given, all content types for the hub are exported.\nA single --schemaId option may be given to export a single content type.\nMultiple --schemaId options may be given to export multiple content types at the same time.', requiresArg: true }); + expect(spyOption).toHaveBeenCalledWith('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite content types without asking.' + }); expect(spyOption).toHaveBeenCalledWith('archived', { type: 'boolean', describe: 'If present, archived content types will also be considered.', boolean: true }); + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); }); }); @@ -314,7 +329,7 @@ describe('content-type export command', (): void => { describe('processContentTypes', () => { let mockEnsureDirectory: jest.Mock; - let mockStreamWrite: jest.Mock; + let mockTable: jest.Mock; let stdoutSpy: jest.SpyInstance; const contentTypesToProcess = [ @@ -352,10 +367,8 @@ describe('content-type export command', (): void => { beforeEach(() => { mockEnsureDirectory = directoryUtils.ensureDirectoryExists as jest.Mock; - mockStreamWrite = jest.fn(); - (createStream as jest.Mock).mockReturnValue({ - write: mockStreamWrite - }); + mockTable = table as jest.Mock; + mockTable.mockImplementation(jest.requireActual('table').table); jest.spyOn(exportServiceModule, 'writeJsonToFile').mockImplementation(); stdoutSpy = jest.spyOn(process.stdout, 'write'); stdoutSpy.mockImplementation(); @@ -388,7 +401,13 @@ describe('content-type export command', (): void => { ]); const previouslyExportedContentTypes = {}; - await processContentTypes('export-dir', previouslyExportedContentTypes, contentTypesToProcess); + await processContentTypes( + 'export-dir', + previouslyExportedContentTypes, + contentTypesToProcess, + new FileLog(), + false + ); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(1); expect(exportModule.getContentTypeExports).toHaveBeenCalledWith( @@ -416,48 +435,31 @@ describe('content-type export command', (): void => { expect.objectContaining(exportedContentTypes[2]) ); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite).toHaveBeenNthCalledWith(1, [ - chalk.bold('File'), - chalk.bold('Schema ID'), - chalk.bold('Result') - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(2, [ - 'export-dir/export-filename-1.json', - exportedContentTypes[0].contentTypeUri, - 'CREATED' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(3, [ - 'export-dir/export-filename-2.json', - exportedContentTypes[1].contentTypeUri, - 'CREATED' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(4, [ - 'export-dir/export-filename-3.json', - exportedContentTypes[2].contentTypeUri, - 'CREATED' - ]); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable).toHaveBeenNthCalledWith( + 1, + [ + [chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')], + ['export-dir/export-filename-1.json', exportedContentTypes[0].contentTypeUri, 'CREATED'], + ['export-dir/export-filename-2.json', exportedContentTypes[1].contentTypeUri, 'CREATED'], + ['export-dir/export-filename-3.json', exportedContentTypes[2].contentTypeUri, 'CREATED'] + ], + streamTableOptions + ); }); it('should output a message if no content types to export from hub', async () => { jest.spyOn(exportModule, 'getContentTypeExports').mockReturnValueOnce([[], []]); - const exitError = new Error('ERROR TO VALIDATE PROCESS EXIT'); - jest.spyOn(process, 'exit').mockImplementation(() => { - throw exitError; - }); - const previouslyExportedContentTypes = {}; - await expect(processContentTypes('export-dir', previouslyExportedContentTypes, [])).rejects.toThrowError( - exitError - ); + await processContentTypes('export-dir', previouslyExportedContentTypes, [], new FileLog(), false); expect(mockEnsureDirectory).toHaveBeenCalledTimes(0); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(0); expect(stdoutSpy.mock.calls).toMatchSnapshot(); expect(exportServiceModule.writeJsonToFile).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(0); + expect(mockTable).toHaveBeenCalledTimes(0); }); it('should not output any export files if a previous export exists and the content type is unchanged', async () => { @@ -485,7 +487,13 @@ describe('content-type export command', (): void => { const previouslyExportedContentTypes = { 'export-dir/export-filename-2.json': new ContentType(exportedContentTypes[1]) }; - await processContentTypes('export-dir', previouslyExportedContentTypes, contentTypesToProcess); + await processContentTypes( + 'export-dir', + previouslyExportedContentTypes, + contentTypesToProcess, + new FileLog(), + false + ); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(1); expect(exportModule.getContentTypeExports).toHaveBeenCalledWith( @@ -497,27 +505,17 @@ describe('content-type export command', (): void => { expect(mockEnsureDirectory).toHaveBeenCalledTimes(1); expect(exportServiceModule.writeJsonToFile).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite).toHaveBeenNthCalledWith(1, [ - chalk.bold('File'), - chalk.bold('Schema ID'), - chalk.bold('Result') - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(2, [ - 'export-dir/export-filename-1.json', - exportedContentTypes[0].contentTypeUri, - 'UP-TO-DATE' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(3, [ - 'export-dir/export-filename-2.json', - exportedContentTypes[1].contentTypeUri, - 'UP-TO-DATE' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(4, [ - 'export-dir/export-filename-3.json', - exportedContentTypes[2].contentTypeUri, - 'UP-TO-DATE' - ]); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable).toHaveBeenNthCalledWith( + 1, + [ + [chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')], + ['export-dir/export-filename-1.json', exportedContentTypes[0].contentTypeUri, 'UP-TO-DATE'], + ['export-dir/export-filename-2.json', exportedContentTypes[1].contentTypeUri, 'UP-TO-DATE'], + ['export-dir/export-filename-3.json', exportedContentTypes[2].contentTypeUri, 'UP-TO-DATE'] + ], + streamTableOptions + ); }); it('should update the existing export file for a changed content type', async () => { @@ -560,7 +558,13 @@ describe('content-type export command', (): void => { 'export-dir/export-filename-2.json': new ContentType(exportedContentTypes[1]) }; - await processContentTypes('export-dir', previouslyExportedContentTypes, mutatedContentTypes); + await processContentTypes( + 'export-dir', + previouslyExportedContentTypes, + mutatedContentTypes, + new FileLog(), + false + ); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(1); expect(exportModule.getContentTypeExports).toHaveBeenCalledWith( @@ -572,27 +576,17 @@ describe('content-type export command', (): void => { expect(mockEnsureDirectory).toHaveBeenCalledTimes(1); expect(exportServiceModule.writeJsonToFile).toHaveBeenCalledTimes(1); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite).toHaveBeenNthCalledWith(1, [ - chalk.bold('File'), - chalk.bold('Schema ID'), - chalk.bold('Result') - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(2, [ - 'export-dir/export-filename-1.json', - exportedContentTypes[0].contentTypeUri, - 'UP-TO-DATE' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(3, [ - 'export-dir/export-filename-2.json', - exportedContentTypes[1].contentTypeUri, - 'UPDATED' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(4, [ - 'export-dir/export-filename-3.json', - exportedContentTypes[2].contentTypeUri, - 'UP-TO-DATE' - ]); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable).toHaveBeenNthCalledWith( + 1, + [ + [chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')], + ['export-dir/export-filename-1.json', exportedContentTypes[0].contentTypeUri, 'UP-TO-DATE'], + ['export-dir/export-filename-2.json', exportedContentTypes[1].contentTypeUri, 'UPDATED'], + ['export-dir/export-filename-3.json', exportedContentTypes[2].contentTypeUri, 'UP-TO-DATE'] + ], + streamTableOptions + ); }); it('should not update anything if the user says "n" to the overwrite prompt', async () => { @@ -603,10 +597,6 @@ describe('content-type export command', (): void => { settings: { label: 'content type 2 - mutated label' } }); - const exitError = new Error('ERROR TO VALIDATE PROCESS EXIT'); - jest.spyOn(process, 'exit').mockImplementation(() => { - throw exitError; - }); jest.spyOn(exportServiceModule, 'promptToOverwriteExports').mockResolvedValueOnce(false); jest.spyOn(exportModule, 'getContentTypeExports').mockReturnValueOnce([ [ @@ -638,9 +628,13 @@ describe('content-type export command', (): void => { 'export-dir/export-filename-2.json': new ContentType(exportedContentTypes[1]) }; - await expect( - processContentTypes('export-dir', previouslyExportedContentTypes, mutatedContentTypes) - ).rejects.toThrowError(exitError); + await processContentTypes( + 'export-dir', + previouslyExportedContentTypes, + mutatedContentTypes, + new FileLog(), + false + ); expect(exportModule.getContentTypeExports).toHaveBeenCalledTimes(1); expect(exportModule.getContentTypeExports).toHaveBeenCalledWith( @@ -651,8 +645,7 @@ describe('content-type export command', (): void => { expect(mockEnsureDirectory).toHaveBeenCalledTimes(0); expect(exportServiceModule.writeJsonToFile).toHaveBeenCalledTimes(0); - expect(mockStreamWrite).toHaveBeenCalledTimes(0); - expect(process.exit).toHaveBeenCalled(); + expect(mockTable).toHaveBeenCalledTimes(0); }); }); @@ -708,9 +701,13 @@ describe('content-type export command', (): void => { jest.spyOn(exportModule, 'processContentTypes').mockResolvedValue(); }); + function expectProcessArguments(dir: string, types: ContentType[]): void { + expect((exportModule.processContentTypes as jest.Mock).mock.calls[0].slice(0, 3)).toEqual([dir, [], types]); + } + it('should export all content types for the current hub if no schemaIds specified', async (): Promise => { const schemaIdsToExport: string[] | undefined = undefined; - const argv = { ...yargArgs, ...config, dir: 'my-dir', schemaId: schemaIdsToExport }; + const argv = { ...yargArgs, ...config, dir: 'my-dir', schemaId: schemaIdsToExport, logFile: new FileLog() }; const filteredContentTypesToExport = [...contentTypesToExport]; jest.spyOn(exportModule, 'filterContentTypesByUri').mockReturnValue(filteredContentTypesToExport); @@ -723,14 +720,21 @@ describe('content-type export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentType); expect(validateNoDuplicateContentTypeUris).toHaveBeenCalled(); expect(exportModule.filterContentTypesByUri).toHaveBeenCalledWith(contentTypesToExport, []); - expect(exportModule.processContentTypes).toHaveBeenCalledWith(argv.dir, [], filteredContentTypesToExport); + expectProcessArguments(argv.dir, filteredContentTypesToExport); }); it('should export even archived content types for the current hub if --archived is provided', async (): Promise< void > => { const schemaIdsToExport: string[] | undefined = undefined; - const argv = { ...yargArgs, ...config, dir: 'my-dir', schemaId: schemaIdsToExport, archived: true }; + const argv = { + ...yargArgs, + ...config, + dir: 'my-dir', + schemaId: schemaIdsToExport, + archived: true, + logFile: new FileLog() + }; const filteredContentTypesToExport = [...contentTypesToExport]; jest.spyOn(exportModule, 'filterContentTypesByUri').mockReturnValue(filteredContentTypesToExport); @@ -744,12 +748,12 @@ describe('content-type export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentType); expect(validateNoDuplicateContentTypeUris).toHaveBeenCalled(); expect(exportModule.filterContentTypesByUri).toHaveBeenCalledWith(contentTypesToExport, []); - expect(exportModule.processContentTypes).toHaveBeenCalledWith(argv.dir, [], filteredContentTypesToExport); + expectProcessArguments(argv.dir, filteredContentTypesToExport); }); it('should export only selected content types if schemaIds specified', async (): Promise => { const schemaIdsToExport: string[] | undefined = ['content-type-uri-2']; - const argv = { ...yargArgs, ...config, dir: 'my-dir', schemaId: schemaIdsToExport }; + const argv = { ...yargArgs, ...config, dir: 'my-dir', schemaId: schemaIdsToExport, logFile: new FileLog() }; const filteredContentTypesToExport = [contentTypesToExport[1]]; jest.spyOn(exportModule, 'filterContentTypesByUri').mockReturnValue(filteredContentTypesToExport); @@ -761,7 +765,7 @@ describe('content-type export command', (): void => { expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, ContentType); expect(validateNoDuplicateContentTypeUris).toHaveBeenCalled(); expect(exportModule.filterContentTypesByUri).toHaveBeenCalledWith(contentTypesToExport, schemaIdsToExport); - expect(exportModule.processContentTypes).toHaveBeenCalledWith(argv.dir, [], filteredContentTypesToExport); + expectProcessArguments(argv.dir, filteredContentTypesToExport); }); }); }); diff --git a/src/commands/content-type/export.ts b/src/commands/content-type/export.ts index 9c5c9510..ad00f743 100644 --- a/src/commands/content-type/export.ts +++ b/src/commands/content-type/export.ts @@ -2,10 +2,9 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { ContentType } from 'dc-management-sdk-js'; -import { createStream } from 'table'; +import { ContentType, Status } from 'dc-management-sdk-js'; +import { table } from 'table'; import { streamTableOptions } from '../../common/table/table.consts'; -import { TableStream } from '../../interfaces/table.interface'; import chalk from 'chalk'; import { ExportResult, @@ -19,11 +18,16 @@ import { validateNoDuplicateContentTypeUris } from './import'; import { isEqual } from 'lodash'; import { ExportBuilderOptions } from '../../interfaces/export-builder-options.interface'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import { FileLog } from '../../common/file-log'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; export const command = 'export '; export const desc = 'Export Content Types'; +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('type', 'export', platform); + export const builder = (yargs: Argv): void => { yargs .positional('dir', { @@ -36,10 +40,22 @@ export const builder = (yargs: Argv): void => { 'The Schema ID of a Content Type to be exported.\nIf no --schemaId option is given, all content types for the hub are exported.\nA single --schemaId option may be given to export a single content type.\nMultiple --schemaId options may be given to export multiple content types at the same time.', requiresArg: true }) + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite content types without asking.' + }) .option('archived', { type: 'boolean', describe: 'If present, archived content types will also be considered.', boolean: true + }) + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog }); }; @@ -137,10 +153,13 @@ export const getContentTypeExports = ( export const processContentTypes = async ( outputDir: string, previouslyExportedContentTypes: { [filename: string]: ContentType }, - contentTypesBeingExported: ContentType[] + contentTypesBeingExported: ContentType[], + log: FileLog, + force: boolean ): Promise => { if (contentTypesBeingExported.length === 0) { - nothingExportedExit('No content types to export from this hub, exiting.\n'); + nothingExportedExit(log, 'No content types to export from this hub, exiting.'); + return; } const [allExports, updatedExportsMap] = getContentTypeExports( @@ -150,39 +169,45 @@ export const processContentTypes = async ( ); if ( allExports.length === 0 || - (Object.keys(updatedExportsMap).length > 0 && !(await promptToOverwriteExports(updatedExportsMap))) + (Object.keys(updatedExportsMap).length > 0 && !(force || (await promptToOverwriteExports(updatedExportsMap, log)))) ) { - nothingExportedExit(); + nothingExportedExit(log); + return; } await ensureDirectoryExists(outputDir); - const tableStream = (createStream(streamTableOptions) as unknown) as TableStream; - tableStream.write([chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')]); + const data: string[][] = []; + + data.push([chalk.bold('File'), chalk.bold('Schema ID'), chalk.bold('Result')]); for (const { filename, status, contentType } of allExports) { if (status !== 'UP-TO-DATE') { delete contentType.id; // do not export id writeJsonToFile(filename, contentType); } - tableStream.write([filename, contentType.contentTypeUri || '', status]); + data.push([filename, contentType.contentTypeUri || '', status]); } - process.stdout.write('\n'); + + log.appendLine(table(data, streamTableOptions)); }; export const handler = async (argv: Arguments): Promise => { - const { dir, schemaId } = argv; + const { dir, schemaId, logFile, force } = argv; const previouslyExportedContentTypes = loadJsonFromDirectory(dir, ContentType); validateNoDuplicateContentTypeUris(previouslyExportedContentTypes); const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); - const storedContentTypes = await paginator(hub.related.contentTypes.list, { status: 'ACTIVE' }); + const log = logFile.open(); + const storedContentTypes = await paginator(hub.related.contentTypes.list, { status: Status.ACTIVE }); if (argv.archived) { - const archivedContentTypes = await paginator(hub.related.contentTypes.list, { status: 'ARCHIVED' }); + const archivedContentTypes = await paginator(hub.related.contentTypes.list, { status: Status.ARCHIVED }); Array.prototype.push.apply(storedContentTypes, archivedContentTypes); } const schemaIdArray: string[] = schemaId ? (Array.isArray(schemaId) ? schemaId : [schemaId]) : []; const filteredContentTypes = filterContentTypesByUri(storedContentTypes, schemaIdArray); - await processContentTypes(dir, previouslyExportedContentTypes, filteredContentTypes); + await processContentTypes(dir, previouslyExportedContentTypes, filteredContentTypes, log, force || false); + + await log.close(); }; diff --git a/src/commands/content-type/import.spec.ts b/src/commands/content-type/import.spec.ts index e58b7645..846fc8ee 100644 --- a/src/commands/content-type/import.spec.ts +++ b/src/commands/content-type/import.spec.ts @@ -13,13 +13,17 @@ import { processContentTypes, storedContentTypeMapper, synchronizeContentTypeRepositories, - validateNoDuplicateContentTypeUris + validateNoDuplicateContentTypeUris, + LOG_FILENAME } from './import'; import Yargs from 'yargs/yargs'; -import { createStream } from 'table'; +import { table } from 'table'; +import { streamTableOptions } from '../../common/table/table.consts'; import { loadJsonFromDirectory, UpdateStatus } from '../../services/import.service'; import paginator from '../../common/dc-management-sdk-js/paginator'; import chalk from 'chalk'; +import { FileLog } from '../../common/file-log'; +import { createLog } from '../../common/log-helpers'; jest.mock('../../services/dynamic-content-client-factory'); jest.mock('../../view/data-presenter'); @@ -50,6 +54,7 @@ describe('content-type import command', (): void => { it('should configure yargs', () => { const argv = Yargs(process.argv.slice(2)); const spyPositional = jest.spyOn(argv, 'positional').mockReturnThis(); + const spyOption = jest.spyOn(argv, 'option').mockReturnThis(); builder(argv); @@ -57,6 +62,13 @@ describe('content-type import command', (): void => { describe: 'Path to Content Type definitions', type: 'string' }); + + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); }); }); @@ -99,29 +111,38 @@ describe('content-type import command', (): void => { describe('doCreate', () => { it('should create a content type and return report', async () => { const mockHub = new Hub(); + const log = new FileLog(); const newContentType = new ContentType({ id: 'created-id' }); const mockRegister = jest.fn().mockResolvedValue(newContentType); mockHub.related.contentTypes.register = mockRegister; const contentType = { contentTypeUri: 'content-type-uri', settings: { label: 'test-label' } }; - const result = await doCreate(mockHub, contentType as ContentType); + const result = await doCreate(mockHub, contentType as ContentType, log); + expect(log.getData('CREATE')).toMatchInlineSnapshot(` + Array [ + "created-id", + ] + `); expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining(contentType)); expect(result).toEqual(newContentType); }); it('should throw an error when content type create fails', async () => { const mockHub = new Hub(); + const log = new FileLog(); const mockRegister = jest.fn().mockImplementation(() => { throw new Error('Error creating content type'); }); mockHub.related.contentTypes.register = mockRegister; const contentType = { contentTypeUri: 'content-type-uri', settings: { label: 'test-label' } }; - await expect(doCreate(mockHub, contentType as ContentType)).rejects.toThrowErrorMatchingSnapshot(); + await expect(doCreate(mockHub, contentType as ContentType, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); }); it('should throw an error when content type create fails if a string error is returned by the sdk', async () => { const mockHub = new Hub(); + const log = new FileLog(); const mockRegister = jest .fn() .mockRejectedValue( @@ -130,7 +151,8 @@ describe('content-type import command', (): void => { mockHub.related.contentTypes.register = mockRegister; const contentType = { contentTypeUri: 'content-type-uri', settings: { label: 'test-label' } }; - await expect(doCreate(mockHub, contentType as ContentType)).rejects.toThrowErrorMatchingSnapshot(); + await expect(doCreate(mockHub, contentType as ContentType, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); }); }); @@ -257,11 +279,21 @@ describe('content-type import command', (): void => { const mockContentTypeSchemaUpdate = jest.fn().mockResolvedValue(new ContentTypeCachedSchema()); updatedContentType.related.contentTypeSchema.update = mockContentTypeSchemaUpdate; const client = mockDynamicContentClientFactory(); - const result = await doUpdate(client, { - ...mutatedContentType, - repositories: ['Slots'] - } as ContentTypeWithRepositoryAssignments); + const log = new FileLog(); + const result = await doUpdate( + client, + { + ...mutatedContentType, + repositories: ['Slots'] + } as ContentTypeWithRepositoryAssignments, + log + ); + expect(log.getData('UPDATE')).toMatchInlineSnapshot(` + Array [ + "stored-id", + ] + `); expect(result).toEqual({ contentType: updatedContentType, updateStatus: UpdateStatus.UPDATED }); expect(mockUpdate).toHaveBeenCalledWith({ ...expectedContentType.toJSON(), @@ -283,9 +315,11 @@ describe('content-type import command', (): void => { }); mockGet.mockResolvedValue(storedContentType); const client = mockDynamicContentClientFactory(); - const result = await doUpdate(client, mutatedContentType); + const log = new FileLog(); + const result = await doUpdate(client, mutatedContentType, log); expect(result).toEqual({ contentType: storedContentType, updateStatus: UpdateStatus.SKIPPED }); + expect(log.getData('UPDATE')).toEqual([]); }); it('should throw an error when unable to get content type during update', async () => { @@ -299,8 +333,10 @@ describe('content-type import command', (): void => { throw new Error('Error retrieving content type'); }); const client = mockDynamicContentClientFactory(); + const log = new FileLog(); - await expect(doUpdate(client, mutatedContentType)).rejects.toThrowErrorMatchingSnapshot(); + await expect(doUpdate(client, mutatedContentType, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); }); it('should throw an error when unable to update content type during update if a string error is returned by sdk', async () => { @@ -320,7 +356,9 @@ describe('content-type import command', (): void => { storedContentType.related.update = mockUpdate; mockGet.mockResolvedValue(storedContentType); const client = mockDynamicContentClientFactory(); - await expect(doUpdate(client, mutatedContentType)).rejects.toThrowErrorMatchingSnapshot(); + const log = new FileLog(); + await expect(doUpdate(client, mutatedContentType, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); expect(mockUpdate).toHaveBeenCalledWith(mutatedContentType); }); @@ -339,18 +377,19 @@ describe('content-type import command', (): void => { storedContentType.related.update = mockUpdate; mockGet.mockResolvedValue(storedContentType); const client = mockDynamicContentClientFactory(); - await expect(doUpdate(client, mutatedContentType)).rejects.toThrowErrorMatchingSnapshot(); + const log = new FileLog(); + await expect(doUpdate(client, mutatedContentType, log)).rejects.toThrowErrorMatchingSnapshot(); + expect(log.getData('UPDATE')).toEqual([]); expect(mockUpdate).toHaveBeenCalledWith(mutatedContentType); }); }); describe('processContentTypes', () => { - const mockStreamWrite = jest.fn(); + let mockTable: jest.Mock; beforeEach(() => { - (createStream as jest.Mock).mockReturnValue({ - write: mockStreamWrite - }); + mockTable = table as jest.Mock; + mockTable.mockImplementation(jest.requireActual('table').table); }); it('should create and update a content type', async () => { @@ -400,11 +439,11 @@ describe('content-type import command', (): void => { }; jest.spyOn(importModule, 'doUpdate').mockResolvedValueOnce(doUpdateResult2); - await processContentTypes(contentTypesToProcess, client, hub, false); + await processContentTypes(contentTypesToProcess, client, hub, false, new FileLog()); expect(paginator).toHaveBeenCalledTimes(1); - expect(importModule.doCreate).toHaveBeenCalledWith(hub, contentTypesToProcess[0]); - expect(importModule.doUpdate).toHaveBeenCalledWith(client, contentTypesToProcess[1]); + expect(importModule.doCreate).toHaveBeenCalledWith(hub, contentTypesToProcess[0], expect.any(FileLog)); + expect(importModule.doUpdate).toHaveBeenCalledWith(client, contentTypesToProcess[1], expect.any(FileLog)); expect(importModule.synchronizeContentTypeRepositories).toHaveBeenCalledTimes(3); const mappedReposByName = createContentRepositoriesMap(contentRepositories); expect(importModule.synchronizeContentTypeRepositories).toHaveBeenCalledTimes(3); @@ -427,27 +466,17 @@ describe('content-type import command', (): void => { expect.objectContaining({ ...contentTypesToProcess[2].toJSON(), repositories: ['Slots'] }), mappedReposByName ); - expect(mockStreamWrite).toHaveBeenCalledTimes(4); - expect(mockStreamWrite).toHaveBeenNthCalledWith(1, [ - chalk.bold('ID'), - chalk.bold('Schema ID'), - chalk.bold('Result') - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(2, [ - createdContentType.id, - createdContentType.contentTypeUri, - 'CREATED' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(3, [ - doUpdateResult1.contentType.id, - doUpdateResult1.contentType.contentTypeUri, - 'UPDATED' - ]); - expect(mockStreamWrite).toHaveBeenNthCalledWith(4, [ - doUpdateResult2.contentType.id, - doUpdateResult2.contentType.contentTypeUri, - 'UP-TO-DATE' - ]); + expect(mockTable).toHaveBeenCalledTimes(1); + expect(mockTable).toHaveBeenNthCalledWith( + 1, + [ + [chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')], + [createdContentType.id, createdContentType.contentTypeUri, 'CREATED'], + [doUpdateResult1.contentType.id, doUpdateResult1.contentType.contentTypeUri, 'UPDATED'], + [doUpdateResult2.contentType.id, doUpdateResult2.contentType.contentTypeUri, 'UP-TO-DATE'] + ], + streamTableOptions + ); }); }); @@ -809,7 +838,7 @@ describe('content-type import command', (): void => { }); it('should create a content type and update', async (): Promise => { - const argv = { ...yargArgs, ...config, dir: 'my-dir', sync: false }; + const argv = { ...yargArgs, ...config, dir: 'my-dir', sync: false, logFile: new FileLog() }; const fileNamesAndContentTypesToImport = { 'file-1': new ContentTypeWithRepositoryAssignments({ contentTypeUri: 'type-uri-1', @@ -841,12 +870,13 @@ describe('content-type import command', (): void => { Object.values(fileNamesAndContentTypesToImport), expect.any(Object), expect.any(Object), - false + false, + expect.any(Object) ); }); it('should create a content type, update and sync a content type', async (): Promise => { - const argv = { ...yargArgs, ...config, dir: 'my-dir', sync: true }; + const argv = { ...yargArgs, ...config, dir: 'my-dir', sync: true, logFile: new FileLog() }; const fileNamesAndContentTypesToImport = { 'file-1': new ContentTypeWithRepositoryAssignments({ contentTypeUri: 'type-uri-1', @@ -878,12 +908,13 @@ describe('content-type import command', (): void => { Object.values(fileNamesAndContentTypesToImport), expect.any(Object), expect.any(Object), - true + true, + expect.any(Object) ); }); it('should throw an error when no content found in import directory', async (): Promise => { - const argv = { ...yargArgs, ...config, dir: 'my-empty-dir', sync: false }; + const argv = { ...yargArgs, ...config, dir: 'my-empty-dir', sync: false, logFile: new FileLog() }; (loadJsonFromDirectory as jest.Mock).mockReturnValue([]); diff --git a/src/commands/content-type/import.ts b/src/commands/content-type/import.ts index 0c5a0c02..3b38d20e 100644 --- a/src/commands/content-type/import.ts +++ b/src/commands/content-type/import.ts @@ -2,19 +2,23 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import paginator from '../../common/dc-management-sdk-js/paginator'; -import { ContentRepository, ContentType, DynamicContent, Hub } from 'dc-management-sdk-js'; +import { ContentRepository, ContentType, DynamicContent, Hub, Status } from 'dc-management-sdk-js'; import { isEqual } from 'lodash'; -import { createStream } from 'table'; +import { table } from 'table'; import chalk from 'chalk'; import { ImportResult, loadJsonFromDirectory, UpdateStatus } from '../../services/import.service'; import { streamTableOptions } from '../../common/table/table.consts'; -import { TableStream } from '../../interfaces/table.interface'; import { ImportBuilderOptions } from '../../interfaces/import-builder-options.interface'; +import { FileLog } from '../../common/file-log'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; export const command = 'import '; export const desc = 'Import Content Types'; +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('type', 'import', platform); + export type CommandParameters = { sync: boolean; }; @@ -30,6 +34,13 @@ export const builder = (yargs: Argv): void => { type: 'boolean', default: false }); + + yargs.option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); }; export class ContentTypeWithRepositoryAssignments extends ContentType { @@ -79,9 +90,28 @@ export const validateNoDuplicateContentTypeUris = (importedContentTypes: { } }; -export const doCreate = async (hub: Hub, contentType: ContentType): Promise => { +export const filterContentTypesById = ( + idFilter: string[], + importedContentTypes: { + [filename: string]: ContentType; + } +): void | never => { + for (const [filename, contentType] of Object.entries(importedContentTypes)) { + if (contentType.contentTypeUri) { + if (idFilter.indexOf(contentType.id as string) === -1) { + delete importedContentTypes[filename]; + } + } + } +}; + +export const doCreate = async (hub: Hub, contentType: ContentType, log: FileLog): Promise => { try { - return await hub.related.contentTypes.register(new ContentType(contentType)); + const result = await hub.related.contentTypes.register(new ContentType(contentType)); + + log.addAction('CREATE', `${result.id}`); + + return result; } catch (err) { throw new Error(`Error registering content type ${contentType.contentTypeUri}: ${err.message || err}`); } @@ -92,7 +122,8 @@ const equals = (a: ContentType, b: ContentType): boolean => export const doUpdate = async ( client: DynamicContent, - contentType: ContentTypeWithRepositoryAssignments + contentType: ContentTypeWithRepositoryAssignments, + log: FileLog ): Promise<{ contentType: ContentType; updateStatus: UpdateStatus }> => { let retrievedContentType: ContentType; try { @@ -102,6 +133,15 @@ export const doUpdate = async ( throw new Error(`Error unable to get content type ${contentType.id}: ${err.message}`); } + if (retrievedContentType.status === Status.ARCHIVED) { + try { + // Resurrect this type before updating it. + retrievedContentType = await retrievedContentType.related.unarchive(); + } catch (err) { + throw new Error(`Error unable unarchive content type ${contentType.id}: ${err.message}`); + } + } + // Check if an update is required contentType.settings = { ...retrievedContentType.settings, ...contentType.settings }; @@ -114,6 +154,9 @@ export const doUpdate = async ( try { // Update the content-type updatedContentType = await retrievedContentType.related.update(contentType); + + log.addAction('UPDATE', `${contentType.id}`); + return { contentType: updatedContentType, updateStatus: UpdateStatus.UPDATED }; } catch (err) { throw new Error(`Error updating content type ${contentType.id}: ${err.message || err}`); @@ -196,22 +239,23 @@ export const processContentTypes = async ( contentTypes: ContentTypeWithRepositoryAssignments[], client: DynamicContent, hub: Hub, - sync: boolean + sync: boolean, + log: FileLog ): Promise => { - const tableStream = (createStream(streamTableOptions) as unknown) as TableStream; + const data: string[][] = []; const contentRepositoryList = await paginator(hub.related.contentRepositories.list, {}); const namedRepositories: MappedContentRepositories = new Map( contentRepositoryList.map(value => [value.name || '', value]) ); - tableStream.write([chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')]); + data.push([chalk.bold('ID'), chalk.bold('Schema ID'), chalk.bold('Result')]); for (const contentType of contentTypes) { let status: ImportResult; let contentTypeResult: ContentType; if (contentType.id) { status = 'UP-TO-DATE'; - const result = await doUpdate(client, contentType); + const result = await doUpdate(client, contentType, log); if (result.updateStatus === UpdateStatus.UPDATED) { status = 'UPDATED'; } @@ -224,7 +268,7 @@ export const processContentTypes = async ( } } } else { - contentTypeResult = await doCreate(hub, contentType); + contentTypeResult = await doCreate(hub, contentType, log); status = 'CREATED'; } @@ -238,15 +282,17 @@ export const processContentTypes = async ( status = contentType.id ? 'UPDATED' : 'CREATED'; } - tableStream.write([contentTypeResult.id || 'UNKNOWN', contentType.contentTypeUri || '', status]); + data.push([contentTypeResult.id || 'UNKNOWN', contentType.contentTypeUri || '', status]); } - process.stdout.write('\n'); + + log.appendLine(table(data, streamTableOptions)); }; export const handler = async ( - argv: Arguments + argv: Arguments, + idFilter?: string[] ): Promise => { - const { dir, sync } = argv; + const { dir, sync, logFile } = argv; const importedContentTypes = loadJsonFromDirectory( dir, ContentTypeWithRepositoryAssignments @@ -256,15 +302,22 @@ export const handler = async ( } validateNoDuplicateContentTypeUris(importedContentTypes); + if (idFilter) { + filterContentTypesById(idFilter, importedContentTypes); + } + const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); + const log = logFile.open(); - const activeContentTypes = await paginator(hub.related.contentTypes.list, { status: 'ACTIVE' }); - const archivedContentTypes = await paginator(hub.related.contentTypes.list, { status: 'ARCHIVED' }); + const activeContentTypes = await paginator(hub.related.contentTypes.list, { status: Status.ACTIVE }); + const archivedContentTypes = await paginator(hub.related.contentTypes.list, { status: Status.ARCHIVED }); const storedContentTypes = [...activeContentTypes, ...archivedContentTypes]; for (const [filename, importedContentType] of Object.entries(importedContentTypes)) { importedContentTypes[filename] = storedContentTypeMapper(importedContentType, storedContentTypes); } - await processContentTypes(Object.values(importedContentTypes), client, hub, sync); + await processContentTypes(Object.values(importedContentTypes), client, hub, sync, log); + + await log.close(); }; diff --git a/src/commands/content-type/unarchive.ts b/src/commands/content-type/unarchive.ts index d5b784b8..2ecc99db 100644 --- a/src/commands/content-type/unarchive.ts +++ b/src/commands/content-type/unarchive.ts @@ -1,6 +1,6 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; -import { ContentType } from 'dc-management-sdk-js'; +import { ContentType, Status } from 'dc-management-sdk-js'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { ArchiveLog } from '../../common/archive/archive-log'; import { equalsOrRegex } from '../../common/filter/filter'; @@ -82,7 +82,7 @@ export const handler = async (argv: Arguments { expect(archiveMock).toBeCalledTimes(2); }); + it('should archive events when multiple ids provided', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (readline as any).setResponses(['y']); + + const { mockGet, mockEditionsList, archiveMock } = mockValues({ status: 'PUBLISHED' }); + + const argv = { + ...yargArgs, + ...config, + id: ['1', '2'] + }; + await handler(argv); + + expect(mockGet).toHaveBeenCalledTimes(4); + expect(mockEditionsList).toHaveBeenCalledTimes(2); + expect(archiveMock).toBeCalledTimes(4); + }); + it('should delete event with scheduled edition', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (readline as any).setResponses(['y']); diff --git a/src/commands/event/archive.ts b/src/commands/event/archive.ts index 6cfe63d5..7f4023cf 100644 --- a/src/commands/event/archive.ts +++ b/src/commands/event/archive.ts @@ -80,7 +80,7 @@ export const getEvents = async ({ hubId, name }: { - id?: string; + id?: string | string[]; hubId: string; name?: string | string[]; client: DynamicContent; @@ -96,19 +96,23 @@ export const getEvents = async ({ > => { try { if (id != null) { - const event = await client.events.get(id); - const editions = await paginator(event.related.editions.list); - - return [ - { - event, - editions, - command: 'ARCHIVE', - unscheduleEditions: [], - deleteEditions: [], - archiveEditions: [] - } - ]; + const ids = Array.isArray(id) ? id : [id]; + + return await Promise.all( + ids.map(async id => { + const event = await client.events.get(id); + const editions = await paginator(event.related.editions.list); + + return { + event, + editions, + command: 'ARCHIVE', + unscheduleEditions: [], + deleteEditions: [], + archiveEditions: [] + }; + }) + ); } const hub = await client.hubs.get(hubId); diff --git a/src/commands/hub/clean.spec.ts b/src/commands/hub/clean.spec.ts index 490a904c..64320b2c 100644 --- a/src/commands/hub/clean.spec.ts +++ b/src/commands/hub/clean.spec.ts @@ -210,7 +210,7 @@ describe('hub clean command', () => { ...yargArgs, ...config, - step: Object.values(CleanHubStepId)[i], + step: steps[i].getId(), logFile: createLog('temp/clean/steps/step' + i + '.log'), force: true }; diff --git a/src/commands/hub/clone.spec.ts b/src/commands/hub/clone.spec.ts new file mode 100644 index 00000000..d864def9 --- /dev/null +++ b/src/commands/hub/clone.spec.ts @@ -0,0 +1,635 @@ +import { builder, command, handler, LOG_FILENAME, getDefaultMappingPath, steps } from './clone'; +import { createLog, getDefaultLogPath, openRevertLog } from '../../common/log-helpers'; +import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import Yargs from 'yargs/yargs'; + +import * as content from './steps/content-clone-step'; +import * as settings from './steps/settings-clone-step'; +import * as schema from './steps/schema-clone-step'; +import * as type from './steps/type-clone-step'; + +import rmdir from 'rimraf'; +import { CloneHubBuilderOptions } from '../../interfaces/clone-hub-builder-options'; +import { ConfigurationParameters } from '../configure'; +import { Arguments } from 'yargs'; +import { FileLog } from '../../common/file-log'; +import { CloneHubState } from './model/clone-hub-state'; +import { CloneHubStepId } from './model/clone-hub-step'; + +jest.mock('readline'); + +jest.mock('../../services/dynamic-content-client-factory'); + +let success = [true, true, true, true]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function succeedOrFail(mock: any, succeed: () => boolean): jest.Mock { + mock.mockImplementation(() => Promise.resolve(succeed())); + return mock; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mockStep(name: string, id: string, success: () => boolean): any { + return jest.fn().mockImplementation(() => ({ + run: succeedOrFail(jest.fn(), success), + revert: succeedOrFail(jest.fn(), success), + getName: jest.fn().mockReturnValue(name), + getId: jest.fn().mockReturnValue(id) + })); +} + +jest.mock('./steps/settings-clone-step', () => ({ + SettingsCloneStep: mockStep('Clone Settings', 'settings', () => success[0]) +})); + +jest.mock('./steps/schema-clone-step', () => ({ + SchemaCloneStep: mockStep('Clone Content Type Schemas', 'schemas', () => success[1]) +})); + +jest.mock('./steps/type-clone-step', () => ({ + TypeCloneStep: mockStep('Clone Content Types', 'types', () => success[2]) +})); + +jest.mock('./steps/content-clone-step', () => ({ + ContentCloneStep: mockStep('Clone Content', 'content', () => success[3]) +})); + +jest.mock('../../common/log-helpers', () => ({ + ...jest.requireActual('../../common/log-helpers'), + getDefaultLogPath: jest.fn() +})); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +function getMocks(): jest.Mock[] { + return [ + settings.SettingsCloneStep as jest.Mock, + schema.SchemaCloneStep as jest.Mock, + type.TypeCloneStep as jest.Mock, + content.ContentCloneStep as jest.Mock + ]; +} + +function clearMocks(): void { + const mocks = getMocks(); + + mocks.forEach(mock => { + mock.mock.results.forEach(obj => { + const instance = obj.value; + (instance.run as jest.Mock).mockClear(); + (instance.revert as jest.Mock).mockClear(); + }); + }); +} + +describe('hub clone command', () => { + afterEach((): void => { + jest.restoreAllMocks(); + }); + + it('should command should defined', function() { + expect(command).toEqual('clone '); + }); + + it('should use getDefaultLogPath for LOG_FILENAME with process.platform as default', function() { + LOG_FILENAME(); + + expect(getDefaultLogPath).toHaveBeenCalledWith('hub', 'clone', process.platform); + }); + + it('should generate a default mapping path containing the given name', function() { + expect(getDefaultMappingPath('hub-1').indexOf('hub-1')).not.toEqual(-1); + }); + + describe('builder tests', function() { + it('should configure yargs', function() { + const argv = Yargs(process.argv.slice(2)); + const spyPositional = jest.spyOn(argv, 'positional').mockReturnThis(); + const spyOption = jest.spyOn(argv, 'option').mockReturnThis(); + + builder(argv); + + expect(spyPositional).toHaveBeenCalledWith('dir', { + describe: + 'Directory to export content to, then import from. This must be set to the previous directory for a revert.', + type: 'string' + }); + + expect(spyOption).toHaveBeenCalledWith('dstHubId', { + type: 'string', + describe: 'Destination hub ID. If not specified, it will be the same as the source.' + }); + + expect(spyOption).toHaveBeenCalledWith('dstClientId', { + type: 'string', + describe: "Destination account's client ID. If not specified, it will be the same as the source." + }); + + expect(spyOption).toHaveBeenCalledWith('dstSecret', { + type: 'string', + describe: "Destination account's secret. Must be used alongside dstClientId." + }); + + expect(spyOption).toHaveBeenCalledWith('mapFile', { + type: 'string', + describe: + 'Mapping file to use when updating content that already exists. Updated with any new mappings that are generated. If not present, will be created.' + }); + + expect(spyOption).toHaveBeenCalledWith('f', { + type: 'boolean', + boolean: true, + describe: + 'Overwrite content, create and assign content types, and ignore content with missing types/references without asking.' + }); + + expect(spyOption).toHaveBeenCalledWith('v', { + type: 'boolean', + boolean: true, + describe: 'Only recreate folder structure - content is validated but not imported.' + }); + + expect(spyOption).toHaveBeenCalledWith('skipIncomplete', { + type: 'boolean', + boolean: true, + describe: 'Skip any content item that has one or more missing dependancy.' + }); + + expect(spyOption).toHaveBeenCalledWith('copyConfig', { + type: 'string', + describe: + 'Path to a JSON configuration file for source/destination account. If the given file does not exist, it will be generated from the arguments.' + }); + + expect(spyOption).toHaveBeenCalledWith('lastPublish', { + type: 'boolean', + boolean: true, + describe: 'When available, export the last published version of a content item rather than its newest version.' + }); + + expect(spyOption).toHaveBeenCalledWith('publish', { + type: 'boolean', + boolean: true, + describe: 'Publish any content items that have an existing publish status in their JSON.' + }); + + expect(spyOption).toHaveBeenCalledWith('republish', { + type: 'boolean', + boolean: true, + describe: + 'Republish content items regardless of whether the import changed them or not. (--publish not required)' + }); + + expect(spyOption).toHaveBeenCalledWith('excludeKeys', { + type: 'boolean', + boolean: true, + describe: 'Exclude delivery keys when importing content items.' + }); + + expect(spyOption).toHaveBeenCalledWith('media', { + type: 'boolean', + boolean: true, + describe: + "Detect and rewrite media links to match assets in the target account's DAM. Your client must have DAM permissions configured." + }); + + expect(spyOption).toHaveBeenCalledWith('revertLog', { + type: 'string', + describe: + 'Revert a previous clone using a given revert log and given directory. Reverts steps in reverse order, starting at the specified one.', + coerce: openRevertLog + }); + + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); + + expect(spyOption).toHaveBeenCalledWith('step', { + type: 'string', + describe: 'Start at a specific step. Steps after the one you specify will also run.', + choices: steps.map(step => step.getId()) + }); + }); + }); + + describe('handler tests', function() { + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id', + + revertLog: Promise.resolve(undefined) + }; + + beforeAll(async () => { + await rimraf('temp/clone/'); + }); + + afterAll(async () => { + await rimraf('temp/clone/'); + }); + + function makeState(argv: Arguments): CloneHubState { + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: argv.dir, + logFile: expect.any(FileLog) + }; + } + + it('should call all steps in order with given parameters', async () => { + clearMocks(); + success = [true, true, true, true]; + + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: 'temp/clone/steps', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + logFile: createLog('temp/clone/steps/all.log'), + + force: false, + validate: false, + skipIncomplete: false, + media: true + }; + + const stepConfig = makeState(argv); + + await handler(argv); + + stepConfig.argv.mapFile = expect.any(String); + + const mocks = getMocks(); + + mocks.forEach(mock => { + const instance = mock.mock.results[0].value; + + expect(instance.run).toHaveBeenCalledWith(stepConfig); + }); + + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone/steps/all.log'); + }); + + it('should handle false returns from each of the steps by stopping the process', async () => { + for (let i = 0; i < 4; i++) { + clearMocks(); + success = [i != 0, i != 1, i != 2, i != 3]; + + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: 'temp/clone/steps', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + logFile: createLog('temp/clone/steps/fail' + i + '.log'), + + mapFile: 'temp/clone/steps/fail' + i + '.json', + force: false, + validate: false, + skipIncomplete: false, + media: true + }; + + const stepConfig = makeState(argv); + + await handler(argv); + + const mocks = getMocks(); + + mocks.forEach((mock, index) => { + const instance = mock.mock.results[0].value; + + if (index > i) { + expect(instance.run).not.toHaveBeenCalled(); + } else { + expect(instance.run).toHaveBeenCalledWith(stepConfig); + } + }); + + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone/steps/fail' + i + '.log'); + } + }); + + it('should start from the step given as a parameter', async () => { + for (let i = 0; i < 4; i++) { + clearMocks(); + success = [true, true, true, true]; + + const argv: Arguments = { + ...yargArgs, + ...config, + + step: steps[i].getId(), + + dir: 'temp/clone/steps', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + logFile: createLog('temp/clone/steps/step' + i + '.log'), + + mapFile: 'temp/clone/steps/step' + i + '.json', + force: false, + validate: false, + skipIncomplete: false, + media: true + }; + + const stepConfig = makeState(argv); + + await handler(argv); + + const mocks = getMocks(); + + mocks.forEach((mock, index) => { + const instance = mock.mock.results[0].value; + + if (index < i) { + expect(instance.run).not.toHaveBeenCalled(); + } else { + expect(instance.run).toHaveBeenCalledWith(stepConfig); + } + }); + + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone/steps/step' + i + '.log'); + } + }); + + it('should only have one of each type of step', () => { + const stepsSoFar = new Set(); + + for (const step of steps) { + const id = step.getId(); + + expect(stepsSoFar.has(id)).toBeFalsy(); + + stepsSoFar.add(id); + } + }); + }); + + describe('revert tests', function() { + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + beforeAll(async () => { + await rimraf('temp/clone-revert/'); + }); + + afterAll(async () => { + await rimraf('temp/clone-revert/'); + }); + + async function prepareFakeLog(path: string): Promise { + const fakeLog = new FileLog(path); + fakeLog.switchGroup('Clone Content Types'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + fakeLog.switchGroup('Clone Content Type Schema'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + await fakeLog.close(); + } + + function makeState(argv: Arguments): CloneHubState { + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: argv.dir, + logFile: expect.any(FileLog), + revertLog: expect.any(FileLog) + }; + } + + it('should revert all steps in order with given parameters', async () => { + clearMocks(); + success = [true, true, true, true]; + await ensureDirectoryExists('temp/clone-revert/'); + await prepareFakeLog('temp/clone-revert/steps.log'); + + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: 'temp/clone-revert/steps', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + logFile: createLog('temp/clone-revert/steps/all.log'), + revertLog: openRevertLog('temp/clone-revert/steps.log'), + + mapFile: 'temp/clone-revert/steps/all.json', + force: false, + validate: false, + skipIncomplete: false, + media: true + }; + + const stepConfig = makeState(argv); + + await handler(argv); + + const mocks = getMocks(); + + mocks.forEach(mock => { + const instance = mock.mock.results[0].value; + + expect(instance.revert).toHaveBeenCalledWith(stepConfig); + }); + + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone-revert/steps/all.log'); + }); + + it('should handle exceptions from each of the revert steps by stopping the process', async () => { + for (let i = 0; i < 4; i++) { + clearMocks(); + success = [i != 0, i != 1, i != 2, i != 3]; + + await ensureDirectoryExists('temp/clone-revert/'); + await prepareFakeLog('temp/clone-revert/fail.log'); + + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: 'temp/clone-revert/fail', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + logFile: createLog('temp/clone-revert/fail/fail' + i + '.log'), + revertLog: openRevertLog('temp/clone-revert/fail.log'), + + mapFile: 'temp/clone-revert/fail/fail' + i + '.json', + force: false, + validate: false, + skipIncomplete: false, + media: true + }; + + const stepConfig = makeState(argv); + + await handler(argv); + + const mocks = getMocks(); + + mocks.forEach((mock, index) => { + const instance = mock.mock.results[0].value; + + if (index > i) { + expect(instance.revert).not.toHaveBeenCalled(); + } else { + expect(instance.revert).toHaveBeenCalledWith(stepConfig); + } + }); + + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone-revert/fail/fail' + i + '.log'); + } + }); + + it('should exit early if revert log cannot be read', async () => { + clearMocks(); + success = [true, true, true, true]; + await ensureDirectoryExists('temp/clone-revert/'); + + const argv: Arguments = { + ...yargArgs, + ...config, + + dir: 'temp/clone-revert/steps', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + logFile: createLog('temp/clone-revert/steps/early.log'), + revertLog: openRevertLog('temp/clone-revert/missing.log'), + + mapFile: 'temp/clone-revert/steps/all.json', + force: false, + validate: false, + skipIncomplete: false, + media: true + }; + + await handler(argv); + + const mocks = getMocks(); + + mocks.forEach(mock => { + const instance = mock.mock.results[0].value; + + expect(instance.revert).not.toHaveBeenCalled(); + }); + + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone-revert/steps/early.log'); + }); + + it('should start reverting from the step given as a parameter (steps in decreasing order)', async () => { + for (let i = 0; i < 4; i++) { + clearMocks(); + success = [true, true, true, true]; + + await ensureDirectoryExists('temp/clone-revert/'); + await prepareFakeLog('temp/clone-revert/step.log'); + + const argv: Arguments = { + ...yargArgs, + ...config, + + step: steps[i].getId(), + + dir: 'temp/clone-revert/step', + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + logFile: createLog('temp/clone-revert/step/step' + i + '.log'), + revertLog: openRevertLog('temp/clone-revert/step.log'), + + mapFile: 'temp/clone-revert/step/step' + i + '.json', + force: false, + validate: false, + skipIncomplete: false, + media: true + }; + + const stepConfig = makeState(argv); + + await handler(argv); + + const mocks = getMocks(); + + mocks.forEach((mock, index) => { + const instance = mock.mock.results[0].value; + + if (index < i) { + expect(instance.revert).not.toHaveBeenCalled(); + } else { + expect(instance.revert).toHaveBeenCalledWith(stepConfig); + } + }); + + const loadLog = new FileLog(); + await loadLog.loadFromFile('temp/clone-revert/step/step' + i + '.log'); + } + }); + }); +}); diff --git a/src/commands/hub/clone.ts b/src/commands/hub/clone.ts new file mode 100644 index 00000000..8a31734c --- /dev/null +++ b/src/commands/hub/clone.ts @@ -0,0 +1,246 @@ +import { createLog, getDefaultLogPath, openRevertLog } from '../../common/log-helpers'; +import { Argv, Arguments } from 'yargs'; +import { join } from 'path'; +import { ConfigurationParameters } from '../configure'; + +import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import { loadCopyConfig } from '../../common/content-item/copy-config'; +import { CloneHubBuilderOptions } from '../../interfaces/clone-hub-builder-options'; + +import { ContentCloneStep } from './steps/content-clone-step'; +import { SchemaCloneStep } from './steps/schema-clone-step'; +import { SettingsCloneStep } from './steps/settings-clone-step'; +import { TypeCloneStep } from './steps/type-clone-step'; +import { CloneHubState } from './model/clone-hub-state'; +import { LogErrorLevel } from '../../common/archive/archive-log'; + +export function getDefaultMappingPath(name: string, platform: string = process.platform): string { + return join( + process.env[platform == 'win32' ? 'USERPROFILE' : 'HOME'] || __dirname, + '.amplience', + `clone/`, + `${name}.json` + ); +} + +// Temp folder structure: +// hub-*/settings/ +// hub-*/extensions/ +// hub-*/schemas/ +// hub-*/types/ +// hub-*/content/ +// hub-*/events/ + +export const command = 'clone '; + +export const desc = + 'Clone an entire hub. The active account and hub are the source for the copy. Exported data from the source hub will be placed in the specified folder.'; + +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('hub', 'clone', platform); + +export const steps = [new SettingsCloneStep(), new SchemaCloneStep(), new TypeCloneStep(), new ContentCloneStep()]; + +export const builder = (yargs: Argv): void => { + yargs + .positional('dir', { + describe: + 'Directory to export content to, then import from. This must be set to the previous directory for a revert.', + type: 'string' + }) + + .option('dstHubId', { + type: 'string', + describe: 'Destination hub ID. If not specified, it will be the same as the source.' + }) + + .option('dstClientId', { + type: 'string', + describe: "Destination account's client ID. If not specified, it will be the same as the source." + }) + + .option('dstSecret', { + type: 'string', + describe: "Destination account's secret. Must be used alongside dstClientId." + }) + + .option('mapFile', { + type: 'string', + describe: + 'Mapping file to use when updating content that already exists. Updated with any new mappings that are generated. If not present, will be created.' + }) + + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: + 'Overwrite content, create and assign content types, and ignore content with missing types/references without asking.' + }) + + .alias('v', 'validate') + .option('v', { + type: 'boolean', + boolean: true, + describe: 'Only recreate folder structure - content is validated but not imported.' + }) + + .option('skipIncomplete', { + type: 'boolean', + boolean: true, + describe: 'Skip any content item that has one or more missing dependancy.' + }) + + .option('copyConfig', { + type: 'string', + describe: + 'Path to a JSON configuration file for source/destination account. If the given file does not exist, it will be generated from the arguments.' + }) + + .option('lastPublish', { + type: 'boolean', + boolean: true, + describe: 'When available, export the last published version of a content item rather than its newest version.' + }) + + .option('publish', { + type: 'boolean', + boolean: true, + describe: 'Publish any content items that have an existing publish status in their JSON.' + }) + + .option('republish', { + type: 'boolean', + boolean: true, + describe: 'Republish content items regardless of whether the import changed them or not. (--publish not required)' + }) + + .option('excludeKeys', { + type: 'boolean', + boolean: true, + describe: 'Exclude delivery keys when importing content items.' + }) + + .option('media', { + type: 'boolean', + boolean: true, + describe: + "Detect and rewrite media links to match assets in the target account's DAM. Your client must have DAM permissions configured." + }) + + .option('revertLog', { + type: 'string', + describe: + 'Revert a previous clone using a given revert log and given directory. Reverts steps in reverse order, starting at the specified one.', + coerce: openRevertLog + }) + + .option('step', { + type: 'string', + describe: 'Start at a specific step. Steps after the one you specify will also run.', + choices: steps.map(step => step.getId()) + }) + + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); +}; + +export const handler = async (argv: Arguments): Promise => { + const log = argv.logFile.open(); + const tempFolder = argv.dir; + + if (argv.mapFile == null) { + argv.mapFile = getDefaultMappingPath(`hub-${argv.dstHubId}`); + } + + const copyConfig = typeof argv.copyConfig !== 'object' ? await loadCopyConfig(argv, log) : argv.copyConfig; + + if (copyConfig == null) { + return; + } + + const argvCore = { + $0: argv.$0, + _: argv._ + }; + + const state: CloneHubState = { + argv: argv, + from: { + clientId: copyConfig.srcClientId, + clientSecret: copyConfig.srcSecret, + hubId: copyConfig.srcHubId, + ...argvCore + }, + to: { + clientId: copyConfig.dstClientId, + clientSecret: copyConfig.dstSecret, + hubId: copyConfig.dstHubId, + ...argvCore + }, + path: tempFolder, + logFile: log + }; + + await ensureDirectoryExists(tempFolder); + + // Steps system: Each step performs another part of the clone command. + // If a step fails, we can return to that step on a future attempt. + + const revertLog = await argv.revertLog; + + const stepIndex = Math.max(0, steps.findIndex(step => step.getId() === argv.step)); + + if (revertLog) { + if (revertLog.errorLevel === LogErrorLevel.INVALID) { + log.error('Could not read the revert log.'); + await log.close(); + return; + } + + state.revertLog = revertLog; + + for (let i = stepIndex; i < steps.length; i++) { + const step = steps[i]; + + log.switchGroup(step.getName()); + revertLog.switchGroup(step.getName()); + log.appendLine(`=== Reverting Step ${i} - ${step.getName()} ===`); + + const success = await step.revert(state); + + if (!success) { + log.appendLine(`Reverting step ${i} ('${step.getId()}': ${step.getName()}) Failed. Terminating.`); + log.appendLine(''); + log.appendLine('To continue the revert from this point, use the option:'); + log.appendLine(`--step ${step.getId()}`); + + break; + } + } + } else { + for (let i = stepIndex; i < steps.length; i++) { + const step = steps[i]; + + log.switchGroup(step.getName()); + log.appendLine(`=== Running Step ${i} - ${step.getName()} ===`); + + const success = await step.run(state); + + if (!success) { + log.appendLine(`Step ${i} ('${step.getId()}': ${step.getName()}) Failed. Terminating.`); + log.appendLine(''); + log.appendLine('To continue the clone from this point, use the option:'); + log.appendLine(`--step ${step.getId()}`); + + break; + } + } + } + + await log.close(); +}; diff --git a/src/commands/hub/model/clone-hub-state.ts b/src/commands/hub/model/clone-hub-state.ts new file mode 100644 index 00000000..2f89bac0 --- /dev/null +++ b/src/commands/hub/model/clone-hub-state.ts @@ -0,0 +1,14 @@ +import { Arguments } from 'yargs'; +import { FileLog } from '../../../common/file-log'; +import { CloneHubBuilderOptions } from '../../../interfaces/clone-hub-builder-options'; +import { ConfigurationParameters } from '../../configure'; + +export interface CloneHubState { + argv: Arguments; + from: Arguments; + to: Arguments; + path: string; + + logFile: FileLog; + revertLog?: FileLog; +} diff --git a/src/commands/hub/model/clone-hub-step.ts b/src/commands/hub/model/clone-hub-step.ts new file mode 100644 index 00000000..325d448a --- /dev/null +++ b/src/commands/hub/model/clone-hub-step.ts @@ -0,0 +1,15 @@ +import { CloneHubState } from './clone-hub-state'; + +export enum CloneHubStepId { + Settings = 'settings', + Schema = 'schema', + Type = 'type', + Content = 'content' +} + +export interface CloneHubStep { + getId(): CloneHubStepId; + getName(): string; + run(state: CloneHubState): Promise; + revert(state: CloneHubState): Promise; +} diff --git a/src/commands/hub/steps/content-clone-step.spec.ts b/src/commands/hub/steps/content-clone-step.spec.ts new file mode 100644 index 00000000..9ce9e8e7 --- /dev/null +++ b/src/commands/hub/steps/content-clone-step.spec.ts @@ -0,0 +1,158 @@ +import { Arguments } from 'yargs'; +import { FileLog } from '../../../common/file-log'; +import { CloneHubBuilderOptions } from '../../../interfaces/clone-hub-builder-options'; +import { ConfigurationParameters } from '../../configure'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; + +import * as copy from '../../content-item/copy'; + +import { ContentCloneStep } from './content-clone-step'; +import { CopyItemBuilderOptions } from '../../../interfaces/copy-item-builder-options.interface'; +import { CloneHubStepId } from '../model/clone-hub-step'; + +jest.mock('../../../services/dynamic-content-client-factory'); +jest.mock('../../content-item/copy'); + +describe('content clone step', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let copierAny: any; + + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + function reset(): void { + jest.resetAllMocks(); + + copierAny = copy; + } + + beforeEach(async () => { + reset(); + }); + + function generateState(directory: string, logName: string): CloneHubState { + const argv: Arguments = { + ...yargArgs, + ...config, + logFile: new FileLog(), + + dir: directory, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + revertLog: Promise.resolve(new FileLog()) + }; + + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: directory, + logFile: new FileLog(join(directory, logName + '.log')) + }; + } + + it('should have the id "content"', () => { + const step = new ContentCloneStep(); + expect(step.getId()).toEqual(CloneHubStepId.Content); + }); + + it('should have the name "Clone Content"', () => { + const step = new ContentCloneStep(); + expect(step.getName()).toEqual('Clone Content'); + }); + + it('should call the copy command with arguments from the state', async () => { + const state = generateState('temp/clone-content/run/', 'run'); + + const copyCalls: Arguments[] = copierAny.calls; + copyCalls.splice(0, copyCalls.length); + copierAny.setForceFail(false); + + const step = new ContentCloneStep(); + const result = await step.run(state); + + expect(copyCalls).toEqual([ + { + ...state.argv, + dir: join(state.path, 'content'), + logFile: state.logFile, + revertLog: expect.any(Promise) + } + ]); + + expect(result).toBeTruthy(); + }); + + it('should return false when the copy command fails', async () => { + const state = generateState('temp/clone-content/fail/', 'fail'); + + const copyCalls: Arguments[] = copierAny.calls; + copyCalls.splice(0, copyCalls.length); + copierAny.setForceFail(true); + + const step = new ContentCloneStep(); + const result = await step.run(state); + + expect(copyCalls.length).toEqual(1); + expect(result).toBeFalsy(); + }); + + it('should call the copy revert command with arguments from the state', async () => { + const state = generateState('temp/clone-content/run/', 'run'); + state.revertLog = new FileLog(); + + const copyCalls: Arguments[] = copierAny.calls; + copyCalls.splice(0, copyCalls.length); + copierAny.setForceFail(false); + + const step = new ContentCloneStep(); + const result = await step.revert(state); + + expect(copyCalls).toEqual([ + { + ...state.argv, + dir: join(state.path, 'content'), + logFile: state.logFile, + revertLog: expect.any(Promise) + } + ]); + + expect(result).toBeTruthy(); + }); + + it('should return false when the copy revert command fails', async () => { + const state = generateState('temp/clone-content/fail/', 'fail'); + state.revertLog = new FileLog(); + + const copyCalls: Arguments[] = copierAny.calls; + copyCalls.splice(0, copyCalls.length); + copierAny.setForceFail(true); + + const step = new ContentCloneStep(); + const result = await step.revert(state); + + expect(copyCalls.length).toEqual(1); + expect(result).toBeFalsy(); + }); +}); diff --git a/src/commands/hub/steps/content-clone-step.ts b/src/commands/hub/steps/content-clone-step.ts new file mode 100644 index 00000000..71c36141 --- /dev/null +++ b/src/commands/hub/steps/content-clone-step.ts @@ -0,0 +1,37 @@ +import { CloneHubStep, CloneHubStepId } from '../model/clone-hub-step'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; + +import { handler as copyContent } from '../../content-item/copy'; + +export class ContentCloneStep implements CloneHubStep { + getId(): CloneHubStepId { + return CloneHubStepId.Content; + } + + getName(): string { + return 'Clone Content'; + } + + async run(state: CloneHubState): Promise { + const copySuccess = await copyContent({ + ...state.argv, + dir: join(state.path, 'content'), + logFile: state.logFile + }); + + return copySuccess; + } + + async revert(state: CloneHubState): Promise { + // Revert argument is passed as true to the clone command. + const revertSuccess = await copyContent({ + ...state.argv, + dir: join(state.path, 'content'), + logFile: state.logFile, + revertLog: Promise.resolve(state.revertLog) + }); + + return revertSuccess; + } +} diff --git a/src/commands/hub/steps/schema-clone-step.spec.ts b/src/commands/hub/steps/schema-clone-step.spec.ts new file mode 100644 index 00000000..03a57f64 --- /dev/null +++ b/src/commands/hub/steps/schema-clone-step.spec.ts @@ -0,0 +1,218 @@ +import { Arguments } from 'yargs'; +import { MockContent } from '../../../common/dc-management-sdk-js/mock-content'; +import { FileLog } from '../../../common/file-log'; +import { ensureDirectoryExists } from '../../../common/import/directory-utils'; +import { CloneHubBuilderOptions } from '../../../interfaces/clone-hub-builder-options'; +import dynamicContentClientFactory from '../../../services/dynamic-content-client-factory'; +import { ConfigurationParameters } from '../../configure'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; +import rmdir from 'rimraf'; + +import * as schemaImport from '../../content-type-schema/import'; +import * as schemaExport from '../../content-type-schema/export'; + +import { SchemaCloneStep } from './schema-clone-step'; +import { CloneHubStepId } from '../model/clone-hub-step'; + +jest.mock('../../../services/dynamic-content-client-factory'); +jest.mock('../../content-type-schema/import'); +jest.mock('../../content-type-schema/export'); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +describe('schema clone step', () => { + let mockContent: MockContent; + + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + function reset(): void { + jest.resetAllMocks(); + + mockContent = new MockContent(dynamicContentClientFactory as jest.Mock); + mockContent.createMockRepository('targetRepo'); + mockContent.registerContentType('http://type', 'type', 'targetRepo'); + mockContent.registerContentType('http://type2', 'type2', 'targetRepo'); + mockContent.registerContentType('http://type3', 'type3', 'targetRepo'); + } + + beforeEach(async () => { + reset(); + }); + + beforeAll(async () => { + await rimraf('temp/clone-schema/'); + }); + + afterAll(async () => { + await rimraf('temp/clone-schema/'); + }); + + function generateState(directory: string, logName: string): CloneHubState { + const argv: Arguments = { + ...yargArgs, + ...config, + logFile: new FileLog(), + + dir: directory, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + revertLog: Promise.resolve(new FileLog()) + }; + + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: directory, + logFile: new FileLog(join(directory, logName + '.log')) + }; + } + + it('should have the id "schema"', () => { + const step = new SchemaCloneStep(); + expect(step.getId()).toEqual(CloneHubStepId.Schema); + }); + + it('should have the name "Clone Content Type Schemas"', () => { + const step = new SchemaCloneStep(); + expect(step.getName()).toEqual('Clone Content Type Schemas'); + }); + + it('should call export on the source and import to the destination', async () => { + const state = generateState('temp/clone-schema/run/', 'run'); + + (schemaImport.handler as jest.Mock).mockResolvedValue(true); + (schemaExport.handler as jest.Mock).mockResolvedValue(true); + + const step = new SchemaCloneStep(); + const result = await step.run(state); + + expect(schemaExport.handler).toHaveBeenCalledWith({ + dir: join(state.path, 'schema'), + force: true, + logFile: state.logFile, + ...state.from + }); + + expect(schemaImport.handler).toBeCalledWith({ + dir: join(state.path, 'schema'), + logFile: state.logFile, + ...state.to + }); + + expect(result).toBeTruthy(); + }); + + it('should fail the step when the export or import fails', async () => { + const state = generateState('temp/clone-schema/run/', 'run'); + + (schemaExport.handler as jest.Mock).mockRejectedValue(false); + + const step = new SchemaCloneStep(); + const exportFail = await step.run(state); + + expect(exportFail).toBeFalsy(); + expect(schemaExport.handler).toHaveBeenCalled(); + expect(schemaImport.handler).not.toHaveBeenCalled(); + + reset(); + + (schemaExport.handler as jest.Mock).mockResolvedValue(true); + (schemaImport.handler as jest.Mock).mockRejectedValue(false); + + const importFail = await step.run(state); + + expect(importFail).toBeFalsy(); + expect(schemaExport.handler).toHaveBeenCalled(); + expect(schemaImport.handler).toHaveBeenCalled(); + }); + + it('should attempt to archive schemas with the CREATE action on revert, skipping archived schemas', async () => { + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Type Schemas'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('CREATE', 'type3'); // is archived + + const state = generateState('temp/clone-schema/revert-create/', 'revert-create'); + + const client = dynamicContentClientFactory(config); + await (await client.contentTypeSchemas.get('type3')).related.archive(); + + state.revertLog = fakeLog; + mockContent.metrics.typeSchemasArchived = 0; + + const step = new SchemaCloneStep(); + await step.revert(state); + + expect(mockContent.metrics.typeSchemasArchived).toEqual(1); + }); + + it('should attempt to fetch and revert to the version of the schema in the revert log', async () => { + const state = generateState('temp/clone-schema/revert-update/', 'revert-update'); + + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Type Schemas'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + + await ensureDirectoryExists('temp/clone-schema/revert-update/oldType'); + + state.revertLog = fakeLog; + + const step = new SchemaCloneStep(); + const result = await step.revert(state); + + expect(mockContent.metrics.typeSchemasArchived).toEqual(1); + expect(mockContent.metrics.typeSchemasUpdated).toEqual(1); + + expect(result).toBeTruthy(); + }); + + it('should return true when importing types for revert fails (ignore)', async () => { + const state = generateState('temp/clone-schema/revert-fail/', 'revert-fail'); + + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Type Schemas'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + + await ensureDirectoryExists('temp/clone-schema/revert-fail/oldType'); + + state.revertLog = fakeLog; + mockContent.failSchemaActions = 'all'; + + const step = new SchemaCloneStep(); + const result = await step.revert(state); + + expect(mockContent.metrics.typeSchemasArchived).toEqual(0); + expect(mockContent.metrics.typeSchemasUpdated).toEqual(0); + + expect(result).toBeTruthy(); + }); +}); diff --git a/src/commands/hub/steps/schema-clone-step.ts b/src/commands/hub/steps/schema-clone-step.ts new file mode 100644 index 00000000..e46e34ff --- /dev/null +++ b/src/commands/hub/steps/schema-clone-step.ts @@ -0,0 +1,87 @@ +import { CloneHubStep, CloneHubStepId } from '../model/clone-hub-step'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; + +import { handler as exportSchema } from '../../content-type-schema/export'; +import { handler as importSchema } from '../../content-type-schema/import'; +import dynamicContentClientFactory from '../../../services/dynamic-content-client-factory'; +import paginator from '../../../common/dc-management-sdk-js/paginator'; +import { FileLog } from '../../../common/file-log'; +import { Status } from 'dc-management-sdk-js'; + +export class SchemaCloneStep implements CloneHubStep { + getId(): CloneHubStepId { + return CloneHubStepId.Schema; + } + + getName(): string { + return 'Clone Content Type Schemas'; + } + + async run(state: CloneHubState): Promise { + try { + await exportSchema({ + dir: join(state.path, 'schema'), + force: true, + logFile: state.logFile, + ...state.from + }); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not export schemas. \n${e}`); + return false; + } + + try { + await importSchema({ + dir: join(state.path, 'schema'), + logFile: state.logFile, + ...state.to + }); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not import schemas. \n${e}`); + return false; + } + + return true; + } + + async revert(state: CloneHubState): Promise { + const client = dynamicContentClientFactory(state.to); + const hub = await client.hubs.get(state.to.hubId); + + const types = await paginator(hub.related.contentTypes.list); + + const revertLog = state.revertLog as FileLog; + const toArchive = revertLog.getData('CREATE', this.getName()); + const toUpdate = revertLog.getData('UPDATE', this.getName()); + + for (const id of toArchive) { + try { + const schema = await client.contentTypeSchemas.get(id); + if (schema.status === Status.ACTIVE) { + await schema.related.archive(); + } + } catch (e) { + state.logFile.appendLine(`Could not archive ${id}. Continuing...`); + } + } + + for (const id of toUpdate) { + const updateArgs = id.split(' '); + + try { + const schema = await client.contentTypeSchemas.getByVersion(updateArgs[0], Number(updateArgs[1])); + await schema.related.update(schema); + + const typeToSync = types.find(type => type.contentTypeUri === schema.schemaId); + if (typeToSync) { + typeToSync.related.contentTypeSchema.update(); + } + } catch (e) { + state.logFile.appendLine(`Error while updating ${id}. Continuing...`); + } + } + + return true; + } +} diff --git a/src/commands/hub/steps/settings-clone-step.spec.ts b/src/commands/hub/steps/settings-clone-step.spec.ts new file mode 100644 index 00000000..43394ccd --- /dev/null +++ b/src/commands/hub/steps/settings-clone-step.spec.ts @@ -0,0 +1,226 @@ +import { Arguments } from 'yargs'; +import { FileLog } from '../../../common/file-log'; +import { CloneHubBuilderOptions } from '../../../interfaces/clone-hub-builder-options'; +import { ConfigurationParameters } from '../../configure'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; +import * as fs from 'fs'; + +import * as settingsImport from '../../settings/import'; +import * as settingsExport from '../../settings/export'; + +import { SettingsCloneStep } from './settings-clone-step'; +import { CloneHubStepId } from '../model/clone-hub-step'; + +jest.mock('../../../services/dynamic-content-client-factory'); +jest.mock('../../settings/import'); +jest.mock('../../settings/export'); +jest.mock('fs'); +jest.mock('../../../common/import/directory-utils'); + +describe('settings clone step', () => { + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + function reset(): void { + jest.resetAllMocks(); + } + + beforeEach(async () => { + reset(); + }); + + function generateState(directory: string, logName: string): CloneHubState { + const argv: Arguments = { + ...yargArgs, + ...config, + logFile: new FileLog(), + + dir: directory, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + revertLog: Promise.resolve(new FileLog()) + }; + + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: directory, + logFile: new FileLog(join(directory, logName + '.log')) + }; + } + + it('should have the id "settings"', () => { + const step = new SettingsCloneStep(); + expect(step.getId()).toEqual(CloneHubStepId.Settings); + }); + + it('should have the name "Clone Settings"', () => { + const step = new SettingsCloneStep(); + expect(step.getName()).toEqual('Clone Settings'); + }); + + it('should call the settings commands with arguments from the state, importing the result of the export and performing a backup', async () => { + const state = generateState('temp/clone-settings/run/', 'run'); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + (settingsExport.handler as jest.Mock).mockResolvedValue(true); + + const settingsFile = 'hub-hub-id-test.json'; + (fs.readdirSync as jest.Mock).mockReturnValue([settingsFile]); + + const step = new SettingsCloneStep(); + const result = await step.run(state); + + // Export + expect(settingsExport.handler).toHaveBeenNthCalledWith(1, { + dir: join(state.path, 'settings'), + logFile: state.logFile, + force: true, + ...state.from + }); + + // Backup + expect(settingsExport.handler).toHaveBeenNthCalledWith(2, { + dir: join(state.path, 'settings'), + logFile: state.logFile, + force: true, + ...state.to + }); + + // Import + expect(settingsImport.handler).toHaveBeenCalledWith({ + filePath: join(state.path, 'settings', settingsFile), + mapFile: state.argv.mapFile, + force: state.argv.force, + logFile: state.logFile, + ...state.to + }); + + expect(result).toBeTruthy(); + }); + + it('should return false when exporting fails, the exported file is missing or import fails', async () => { + const state = generateState('temp/clone-settings/fail/', 'fail'); + const step = new SettingsCloneStep(); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + (settingsExport.handler as jest.Mock).mockRejectedValue(false); + + const settingsFile = 'hub-hub-id-test.json'; + (fs.readdirSync as jest.Mock).mockReturnValue([settingsFile]); + + const failedExport = await step.run(state); + + expect(failedExport).toBeFalsy(); + expect(settingsExport.handler).toHaveBeenCalledTimes(1); + expect(settingsImport.handler).not.toHaveBeenCalled(); + + reset(); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + (settingsExport.handler as jest.Mock).mockResolvedValue(true); + + // Hub ID must match the source. + (fs.readdirSync as jest.Mock).mockReturnValue(['mismatch', 'hub-hub2-id-test.json']); + const missingExport = await step.run(state); + + expect(missingExport).toBeFalsy(); + expect(settingsExport.handler).toHaveBeenCalledTimes(2); + expect(settingsImport.handler).not.toHaveBeenCalled(); + + reset(); + + (settingsImport.handler as jest.Mock).mockRejectedValue(false); + (settingsExport.handler as jest.Mock).mockResolvedValue(true); + + (fs.readdirSync as jest.Mock).mockReturnValue([settingsFile]); + const failingImport = await step.run(state); + + expect(failingImport).toBeFalsy(); + expect(settingsExport.handler).toHaveBeenCalledTimes(2); + expect(settingsImport.handler).toHaveBeenCalled(); + + reset(); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + (settingsExport.handler as jest.Mock).mockResolvedValueOnce(true).mockRejectedValueOnce(false); + + (fs.readdirSync as jest.Mock).mockReturnValue([settingsFile]); + const backupFailiure = await step.run(state); + + expect(backupFailiure).toBeTruthy(); + expect(settingsExport.handler).toHaveBeenCalledTimes(2); + expect(settingsImport.handler).toHaveBeenCalled(); + }); + + it('should import saved settings in the given directory when reverting', async () => { + const state = generateState('temp/clone-settings/revert/', 'revert'); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + + const settingsFile = 'hub-hub2-id-test.json'; + (fs.readdirSync as jest.Mock).mockReturnValue([settingsFile]); + + const step = new SettingsCloneStep(); + const result = await step.revert(state); + + expect(settingsImport.handler).toHaveBeenCalledWith({ + filePath: join(state.path, 'settings', settingsFile), + mapFile: state.argv.mapFile, + force: state.argv.force, + logFile: state.logFile, + ...state.to + }); + + expect(result).toBeTruthy(); + }); + + it('should fail revert if the saved settings are missing, or the import of them fails', async () => { + const state = generateState('temp/clone-settings/revert-fail/', 'revert-fail'); + const step = new SettingsCloneStep(); + + (settingsImport.handler as jest.Mock).mockResolvedValue(true); + + // Settings file is not present. + (fs.readdirSync as jest.Mock).mockReturnValue(['missing', 'hub-hub-id-test.json']); + + const revertSettingsMissing = await step.revert(state); + + expect(settingsImport.handler).not.toHaveBeenCalled(); + expect(revertSettingsMissing).toBeFalsy(); + + reset(); + + // Settings file is present, but import fails. + (settingsImport.handler as jest.Mock).mockRejectedValue(false); + + (fs.readdirSync as jest.Mock).mockReturnValue(['hub-hub2-id-test.json']); + + const importFailed = await step.revert(state); + + expect(settingsImport.handler).toHaveBeenCalled(); + expect(importFailed).toBeFalsy(); + }); +}); diff --git a/src/commands/hub/steps/settings-clone-step.ts b/src/commands/hub/steps/settings-clone-step.ts new file mode 100644 index 00000000..c04af375 --- /dev/null +++ b/src/commands/hub/steps/settings-clone-step.ts @@ -0,0 +1,96 @@ +import { CloneHubStep, CloneHubStepId } from '../model/clone-hub-step'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; +import { readdirSync } from 'fs'; + +import { handler as exportSettings } from '../../settings/export'; +import { handler as importSettings } from '../../settings/import'; +import { ensureDirectoryExists } from '../../../common/import/directory-utils'; + +export class SettingsCloneStep implements CloneHubStep { + getId(): CloneHubStepId { + return CloneHubStepId.Settings; + } + + getName(): string { + return 'Clone Settings'; + } + + findItem(path: string, hubId: string): string | undefined { + const items = readdirSync(join(path, 'settings')); + return items.find(item => { + return /^hub\-.*\.json$/.test(item) && item.indexOf(hubId) != -1; + }); + } + + async run(state: CloneHubState): Promise { + try { + await ensureDirectoryExists(join(state.path, 'settings')); + await exportSettings({ + dir: join(state.path, 'settings'), + logFile: state.logFile, + force: true, + ...state.from + }); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not export settings. \n${e}`); + return false; + } + + try { + try { + state.logFile.appendLine('Backing up destination settings.'); + await exportSettings({ + dir: join(state.path, 'settings'), + logFile: state.logFile, + force: true, + ...state.to + }); + } catch (e) { + state.logFile.appendLine('Failed to back up destination settings. Continuing.'); + } + + const matchingFile = this.findItem(state.path, state.from.hubId); + if (matchingFile == null) { + state.logFile.appendLine('Error: Could not find exported settings file.'); + return false; + } + + await importSettings({ + filePath: join(state.path, 'settings', matchingFile), + mapFile: state.argv.mapFile, + force: state.argv.force, + logFile: state.logFile, + ...state.to + }); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not import settings. \n${e}`); + return false; + } + + return true; + } + + async revert(state: CloneHubState): Promise { + try { + const matchingFile = this.findItem(state.path, state.to.hubId); + if (matchingFile == null) { + state.logFile.appendLine('Error: Could not find exported settings file.'); + return false; + } + + await importSettings({ + filePath: join(state.path, 'settings', matchingFile), + mapFile: state.argv.mapFile, + force: state.argv.force, + logFile: state.logFile, + ...state.to + }); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not import old settings. \n${e}`); + return false; + } + + return true; + } +} diff --git a/src/commands/hub/steps/type-clone-step.spec.ts b/src/commands/hub/steps/type-clone-step.spec.ts new file mode 100644 index 00000000..c3428769 --- /dev/null +++ b/src/commands/hub/steps/type-clone-step.spec.ts @@ -0,0 +1,246 @@ +import { Arguments } from 'yargs'; +import { MockContent } from '../../../common/dc-management-sdk-js/mock-content'; +import { FileLog } from '../../../common/file-log'; +import { ensureDirectoryExists } from '../../../common/import/directory-utils'; +import { CloneHubBuilderOptions } from '../../../interfaces/clone-hub-builder-options'; +import dynamicContentClientFactory from '../../../services/dynamic-content-client-factory'; +import { ConfigurationParameters } from '../../configure'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; +import rmdir from 'rimraf'; + +import * as typeImport from '../../content-type/import'; +import * as typeExport from '../../content-type/export'; + +import { TypeCloneStep } from './type-clone-step'; +import { CloneHubStepId } from '../model/clone-hub-step'; + +jest.mock('../../../services/dynamic-content-client-factory'); +jest.mock('../../content-type/import'); +jest.mock('../../content-type/export'); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +describe('type clone step', () => { + let mockContent: MockContent; + + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + function reset(): void { + jest.resetAllMocks(); + + mockContent = new MockContent(dynamicContentClientFactory as jest.Mock); + mockContent.createMockRepository('targetRepo'); + mockContent.registerContentType('http://type', 'type', 'targetRepo'); + mockContent.registerContentType('http://type2', 'type2', 'targetRepo'); + mockContent.registerContentType('http://type3', 'type3', 'targetRepo'); + } + + beforeEach(async () => { + reset(); + }); + + beforeAll(async () => { + await rimraf('temp/clone-type/'); + }); + + afterAll(async () => { + await rimraf('temp/clone-type/'); + }); + + function generateState(directory: string, logName: string): CloneHubState { + const argv: Arguments = { + ...yargArgs, + ...config, + logFile: new FileLog(), + + dir: directory, + + dstHubId: 'hub2-id', + dstClientId: 'acc2-id', + dstSecret: 'acc2-secret', + revertLog: Promise.resolve(new FileLog()) + }; + + return { + argv: argv, + from: { + clientId: argv.clientId as string, + clientSecret: argv.clientSecret as string, + hubId: argv.hubId as string, + ...yargArgs + }, + to: { + clientId: argv.dstClientId as string, + clientSecret: argv.dstSecret as string, + hubId: argv.dstHubId as string, + ...yargArgs + }, + path: directory, + logFile: new FileLog(join(directory, logName + '.log')) + }; + } + + it('should have the id "type"', () => { + const step = new TypeCloneStep(); + expect(step.getId()).toEqual(CloneHubStepId.Type); + }); + + it('should have the name "Clone Content Types"', () => { + const step = new TypeCloneStep(); + expect(step.getName()).toEqual('Clone Content Types'); + }); + + it('should call export on the source, backup and import to the destination', async () => { + const state = generateState('temp/clone-type/run/', 'run'); + + (typeImport.handler as jest.Mock).mockResolvedValue(true); + (typeExport.handler as jest.Mock).mockResolvedValue(true); + + const step = new TypeCloneStep(); + const result = await step.run(state); + // Backup + expect(typeExport.handler).toHaveBeenNthCalledWith(1, { + dir: join(state.path, 'oldType'), + force: true, + logFile: state.logFile, + ...state.to + }); + + // Export + expect(typeExport.handler).toHaveBeenNthCalledWith(2, { + dir: join(state.path, 'type'), + force: true, + logFile: state.logFile, + ...state.from + }); + + expect(typeImport.handler).toBeCalledWith({ + dir: join(state.path, 'type'), + sync: true, + logFile: state.logFile, + ...state.to + }); + + expect(result).toBeTruthy(); + }); + + it('should fail the step when the export, backup or import fails', async () => { + const state = generateState('temp/clone-type/run/', 'run'); + + (typeExport.handler as jest.Mock).mockRejectedValue(false); + + const step = new TypeCloneStep(); + const backupFail = await step.run(state); + + expect(backupFail).toBeFalsy(); + expect(typeExport.handler).toBeCalledTimes(1); + expect(typeImport.handler).not.toBeCalled(); + + reset(); + + (typeExport.handler as jest.Mock).mockResolvedValueOnce(true); + (typeExport.handler as jest.Mock).mockRejectedValueOnce(false); + + const exportFail = await step.run(state); + + expect(exportFail).toBeFalsy(); + expect(typeExport.handler).toBeCalledTimes(2); + expect(typeImport.handler).not.toBeCalled(); + + reset(); + + (typeExport.handler as jest.Mock).mockResolvedValue(true); + (typeImport.handler as jest.Mock).mockRejectedValue(false); + + const importFail = await step.run(state); + + expect(importFail).toBeFalsy(); + expect(typeExport.handler).toBeCalledTimes(2); + expect(typeImport.handler).toBeCalled(); + }); + + it('should attempt to archive types with the CREATE action on revert, skipping archived types', async () => { + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Types'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('CREATE', 'type3'); // is archived + + const state = generateState('temp/clone-type/revert-create/', 'revert-create'); + + await ensureDirectoryExists('temp/clone-type/revert-create/oldType'); + const client = dynamicContentClientFactory(config); + await (await client.contentTypes.get('type3')).related.archive(); + + state.revertLog = fakeLog; + mockContent.metrics.typesArchived = 0; + + const step = new TypeCloneStep(); + await step.revert(state); + + expect(mockContent.metrics.typesArchived).toEqual(1); + expect(typeImport.handler).not.toBeCalled(); + }); + + it('should pass types with the UPDATE action to the type import command on revert, in the oldType folder', async () => { + const state = generateState('temp/clone-type/revert-update/', 'revert-update'); + + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Types'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + + await ensureDirectoryExists('temp/clone-type/revert-update/oldType'); + + state.revertLog = fakeLog; + + const step = new TypeCloneStep(); + const result = await step.revert(state); + + expect(mockContent.metrics.typesArchived).toEqual(1); + expect(typeImport.handler).toBeCalledWith( + { + dir: join(state.path, 'oldType'), + sync: true, + logFile: state.logFile, + ...state.to + }, + ['type2'] + ); + + expect(result).toBeTruthy(); + }); + + it('should return false when importing types for revert fails', async () => { + const state = generateState('temp/clone-type/revert-update/', 'revert-update'); + + const fakeLog = new FileLog(); + fakeLog.switchGroup('Clone Content Types'); + fakeLog.addAction('CREATE', 'type'); + fakeLog.addAction('UPDATE', 'type2 0 1'); + + await ensureDirectoryExists('temp/clone-type/revert-update/oldType'); + + state.revertLog = fakeLog; + (typeImport.handler as jest.Mock).mockRejectedValue(false); + + const step = new TypeCloneStep(); + const result = await step.revert(state); + + expect(mockContent.metrics.typesArchived).toEqual(1); + expect(result).toBeFalsy(); + }); +}); diff --git a/src/commands/hub/steps/type-clone-step.ts b/src/commands/hub/steps/type-clone-step.ts new file mode 100644 index 00000000..1e06a0bc --- /dev/null +++ b/src/commands/hub/steps/type-clone-step.ts @@ -0,0 +1,98 @@ +import { CloneHubStep, CloneHubStepId } from '../model/clone-hub-step'; +import { CloneHubState } from '../model/clone-hub-state'; +import { join } from 'path'; + +import { handler as exportType } from '../../content-type/export'; +import { handler as importType } from '../../content-type/import'; +import dynamicContentClientFactory from '../../../services/dynamic-content-client-factory'; +import { FileLog } from '../../../common/file-log'; +import { existsSync } from 'fs'; + +export class TypeCloneStep implements CloneHubStep { + getId(): CloneHubStepId { + return CloneHubStepId.Type; + } + + getName(): string { + return 'Clone Content Types'; + } + + async run(state: CloneHubState): Promise { + try { + state.logFile.appendLine(`Exporting existing types from destination.`); + await exportType({ + dir: join(state.path, 'oldType'), + force: true, + logFile: state.logFile, + ...state.to + }); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not export existing destination types. \n${e}`); + return false; + } + + try { + state.logFile.appendLine(`Exporting types from source.`); + await exportType({ + dir: join(state.path, 'type'), + force: true, + logFile: state.logFile, + ...state.from + }); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not export types. \n${e}`); + return false; + } + + try { + await importType({ + dir: join(state.path, 'type'), + sync: true, + logFile: state.logFile, + ...state.to + }); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not import types. \n${e}`); + return false; + } + + return true; + } + + async revert(state: CloneHubState): Promise { + const client = dynamicContentClientFactory(state.to); + + const toArchive = (state.revertLog as FileLog).getData('CREATE', this.getName()); + const toUpdate = (state.revertLog as FileLog).getData('UPDATE', this.getName()); + + for (let i = 0; i < toArchive.length; i++) { + try { + const type = await client.contentTypes.get(toArchive[i]); + await type.related.archive(); + state.logFile.addAction('ARCHIVE', toArchive[i]); + } catch (e) { + state.logFile.appendLine(`Couldn't archive content type ${toArchive[i]}. Continuing...`); + } + } + + // Update using the oldType folder. + if (toUpdate.length > 0 && existsSync(join(state.path, 'oldType'))) { + try { + await importType( + { + dir: join(state.path, 'oldType'), + sync: true, + logFile: state.logFile, + ...state.to + }, + toUpdate.map(item => item.split(' ')[0]) + ); + } catch (e) { + state.logFile.appendLine(`ERROR: Could not import old types. \n${e}`); + return false; + } + } + + return true; + } +} diff --git a/src/commands/settings/export.spec.ts b/src/commands/settings/export.spec.ts index 859476e5..c6686ddf 100644 --- a/src/commands/settings/export.spec.ts +++ b/src/commands/settings/export.spec.ts @@ -6,6 +6,7 @@ import readline from 'readline'; import MockPage from '../../common/dc-management-sdk-js/mock-page'; import { promisify } from 'util'; import { exists, unlink } from 'fs'; +import { FileLog } from '../../common/file-log'; jest.mock('../../services/dynamic-content-client-factory'); jest.mock('readline'); @@ -27,6 +28,7 @@ describe('settings export command', (): void => { it('should configure yargs', () => { const argv = Yargs(process.argv.slice(2)); const spyPositional = jest.spyOn(argv, 'positional').mockReturnThis(); + const spyOption = jest.spyOn(argv, 'option').mockReturnThis(); builder(argv); @@ -34,6 +36,12 @@ describe('settings export command', (): void => { describe: 'Output directory for the exported Settings', type: 'string' }); + + expect(spyOption).toHaveBeenCalledWith('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite settings without asking.' + }); }); }); @@ -230,7 +238,8 @@ describe('settings export command', (): void => { const argv = { ...yargArgs, ...config, - dir: './' + dir: './', + logFile: new FileLog() }; await handler(argv); diff --git a/src/commands/settings/export.ts b/src/commands/settings/export.ts index 1402df58..8e5310a3 100644 --- a/src/commands/settings/export.ts +++ b/src/commands/settings/export.ts @@ -6,22 +6,32 @@ import { Hub, Settings, WorkflowState } from 'dc-management-sdk-js'; import { nothingExportedExit, promptToExportSettings, writeJsonToFile } from '../../services/export.service'; import { ExportBuilderOptions } from '../../interfaces/export-builder-options.interface'; import * as path from 'path'; +import { FileLog } from '../../common/file-log'; export const command = 'export '; export const desc = 'Export Hub Settings'; export const builder = (yargs: Argv): void => { - yargs.positional('dir', { - describe: 'Output directory for the exported Settings', - type: 'string' - }); + yargs + .positional('dir', { + describe: 'Output directory for the exported Settings', + type: 'string' + }) + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite settings without asking.' + }); }; export const processSettings = async ( outputDir: string, hubToExport: Hub, - workflowStates: WorkflowState[] + workflowStates: WorkflowState[], + log: FileLog, + force: boolean ): Promise => { const { id, name, label, settings = new Settings() } = hubToExport; let dir = outputDir; @@ -32,8 +42,8 @@ export const processSettings = async ( const uniqueFilename = dir + path.sep + file + '.json'; - if (!(await promptToExportSettings(uniqueFilename))) { - return nothingExportedExit(); + if (!(force || (await promptToExportSettings(uniqueFilename, log)))) { + return nothingExportedExit(log); } writeJsonToFile(uniqueFilename, { @@ -48,15 +58,18 @@ export const processSettings = async ( workflowStates: workflowStates }); - process.stdout.write('Settings exported successfully! \n'); + log.appendLine('Settings exported successfully!'); }; export const handler = async (argv: Arguments): Promise => { - const { dir } = argv; + const { dir, logFile, force } = argv; const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); + const log = logFile.open(); const workflowStates = await paginator(hub.related.workflowStates.list); - await processSettings(dir, hub, workflowStates); + await processSettings(dir, hub, workflowStates, log, force || false); + + await log.close(); }; diff --git a/src/commands/settings/import.spec.ts b/src/commands/settings/import.spec.ts index 7520cad0..4401c189 100644 --- a/src/commands/settings/import.spec.ts +++ b/src/commands/settings/import.spec.ts @@ -6,6 +6,8 @@ import { Hub, Settings, WorkflowState } from 'dc-management-sdk-js'; import { promisify } from 'util'; import { exists, unlink, writeFile } from 'fs'; import rmdir from 'rimraf'; +import { createLog } from '../../common/log-helpers'; +import { FileLog } from '../../common/file-log'; jest.mock('readline'); jest.mock('../../services/dynamic-content-client-factory'); @@ -329,8 +331,10 @@ describe('settings import command', (): void => { expect(spyOptions).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }); + expect(spyOptions).toHaveBeenCalledWith('f', { type: 'boolean', boolean: true, @@ -449,7 +453,7 @@ describe('settings import command', (): void => { await handler({ ...argv, mapFile: './mapSettings.json', - logFile: './log.json', + logFile: createLog('./log.json'), force: true }); @@ -475,7 +479,8 @@ describe('settings import command', (): void => { ...argv, mapFile: './mapSettings2.json', force: true, - answer: ['n'] + answer: ['n'], + logFile: new FileLog() }); expect(mockGetHub).toHaveBeenCalled(); @@ -490,7 +495,8 @@ describe('settings import command', (): void => { await handler({ ...argv, - force: true + force: true, + logFile: new FileLog() }); expect(mockGetHub).toHaveBeenCalled(); diff --git a/src/commands/settings/import.ts b/src/commands/settings/import.ts index 8da15417..63771148 100644 --- a/src/commands/settings/import.ts +++ b/src/commands/settings/import.ts @@ -5,8 +5,8 @@ import dynamicContentClientFactory from '../../services/dynamic-content-client-f import { ImportSettingsBuilderOptions } from '../../interfaces/import-settings-builder-options.interface'; import { WorkflowStatesMapping } from '../../common/workflowStates/workflowStates-mapping'; import { FileLog } from '../../common/file-log'; -import { getDefaultLogPath } from '../../common/log-helpers'; -import { asyncQuestion } from '../../common/archive/archive-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { asyncQuestion } from '../../common/question-helpers'; import { join } from 'path'; import { readFile } from 'fs'; import { promisify } from 'util'; @@ -61,7 +61,8 @@ export const builder = (yargs: Argv): void => { .option('logFile', { type: 'string', default: LOG_FILENAME, - describe: 'Path to a log file to write to.' + describe: 'Path to a log file to write to.', + coerce: createLog }) .alias('f', 'force') .option('f', { @@ -78,7 +79,7 @@ export const handler = async ( let { mapFile } = argv; const client = dynamicContentClientFactory(argv); const hub = await client.hubs.get(argv.hubId); - const log = typeof logFile === 'string' || logFile == null ? new FileLog(logFile) : logFile; + const log = logFile.open(); const mapping = new WorkflowStatesMapping(); let uniqueLocales = []; let uniqueApplications = []; @@ -125,7 +126,8 @@ export const handler = async ( if (alreadyExists.length > 0) { const question = !force ? await asyncQuestion( - `${alreadyExists.length} of the workflow states being imported already exist in the mapping. Would you like to update these workflow states instead of skipping them? (y/n) ` + `${alreadyExists.length} of the workflow states being imported already exist in the mapping. Would you like to update these workflow states instead of skipping them? (y/n) `, + log ) : answer; @@ -169,9 +171,7 @@ export const handler = async ( await trySaveMapping(mapFile, mapping, log); - if (log) { - await log.close(); - } + await log.close(); process.stdout.write('\n'); } catch (e) { diff --git a/src/common/archive/archive-helpers.ts b/src/common/archive/archive-helpers.ts index 64374904..6ee558c5 100644 --- a/src/common/archive/archive-helpers.ts +++ b/src/common/archive/archive-helpers.ts @@ -1,10 +1,4 @@ -import readline, { ReadLine } from 'readline'; - -function asyncQuestionInternal(rl: ReadLine, question: string): Promise { - return new Promise((resolve): void => { - rl.question(question, resolve); - }); -} +import { asyncQuestion } from '../question-helpers'; export async function confirmArchive( action: string, @@ -12,32 +6,11 @@ export async function confirmArchive( allContent: boolean, missingContent: boolean ): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false - }); - const question = allContent ? `Providing no ID or filter will ${action} ALL ${type}! Are you sure you want to do this? (y/n)\n` : missingContent ? 'Warning: Some content specified on the log is missing. Are you sure you want to continue? (y/n)\n' : `Are you sure you want to ${action} these ${type}? (y/n)\n`; - const answer: string = await asyncQuestionInternal(rl, question); - rl.close(); - return answer.length > 0 && answer[0].toLowerCase() == 'y'; -} - -export async function asyncQuestion(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false - }); - - const answer = await asyncQuestionInternal(rl, question); - - rl.close(); - return answer.length > 0 && answer[0].toLowerCase() === 'y'; + return await asyncQuestion(question); } diff --git a/src/common/archive/archive-log.ts b/src/common/archive/archive-log.ts index d7658f6b..e5d26d9d 100644 --- a/src/common/archive/archive-log.ts +++ b/src/common/archive/archive-log.ts @@ -12,7 +12,8 @@ export interface ArchiveLogItem { export enum LogErrorLevel { NONE = 0, WARNING, - ERROR + ERROR, + INVALID } export class ArchiveLog { diff --git a/src/common/archive/archive-options.ts b/src/common/archive/archive-options.ts index 9d3eb46b..e4857ed2 100644 --- a/src/common/archive/archive-options.ts +++ b/src/common/archive/archive-options.ts @@ -1,7 +1,7 @@ import { FileLog } from '../file-log'; export default interface ArchiveOptions { - id?: string; + id?: string | string[]; schemaId?: string | string[]; revertLog?: string; repoId?: string | string[]; diff --git a/src/common/content-item/copy-config.spec.ts b/src/common/content-item/copy-config.spec.ts index 23ce188d..3e7b4906 100644 --- a/src/common/content-item/copy-config.spec.ts +++ b/src/common/content-item/copy-config.spec.ts @@ -13,7 +13,9 @@ const yargArgs = { $0: 'test', _: ['test'], json: true, - logFile: new FileLog() + + logFile: new FileLog(), + revertLog: Promise.resolve(undefined) }; describe('copy-config', () => { diff --git a/src/common/dc-management-sdk-js/mock-content.ts b/src/common/dc-management-sdk-js/mock-content.ts index e4cd8a44..aa47cf02 100644 --- a/src/common/dc-management-sdk-js/mock-content.ts +++ b/src/common/dc-management-sdk-js/mock-content.ts @@ -49,7 +49,11 @@ export class MockContentMetrics { itemsVersionGet = 0; foldersCreated = 0; typesCreated = 0; + typesArchived = 0; + typesSynced = 0; typeSchemasCreated = 0; + typeSchemasUpdated = 0; + typeSchemasArchived = 0; reset(): void { this.itemsCreated = 0; @@ -60,7 +64,11 @@ export class MockContentMetrics { this.itemsVersionGet = 0; this.foldersCreated = 0; this.typesCreated = 0; + this.typesArchived = 0; + this.typesSynced = 0; this.typeSchemasCreated = 0; + this.typeSchemasUpdated = 0; + this.typeSchemasArchived = 0; } } @@ -83,6 +91,8 @@ export class MockContent { failItemActions: null | 'all' | 'not-version' = null; failFolderActions: null | 'list' | 'parent' | 'items' = null; failRepoActions: null | 'list' | 'create' = null; + failTypeActions: null | 'all' = null; + failSchemaActions: null | 'all' = null; failHubGet: boolean; failRepoList: boolean; @@ -109,6 +119,14 @@ export class MockContent { const mockTypeSchemaGet = jest.fn(id => Promise.resolve(this.typeSchemaById.get(id) as ContentTypeSchema)); + const mockTypeSchemaGetVersion = jest.fn((id, version) => { + const schema = this.typeSchemaById.get(id) as ContentTypeSchema; + + schema.version = version; + + return Promise.resolve(schema); + }); + const mockItemGet = jest.fn(id => { const result = this.items.find(item => item.id === id); if (result == null) { @@ -132,7 +150,8 @@ export class MockContent { get: mockTypeGet }, contentTypeSchemas: { - get: mockTypeSchemaGet + get: mockTypeSchemaGet, + getByVersion: mockTypeSchemaGetVersion }, contentItems: { get: mockItemGet @@ -315,7 +334,7 @@ export class MockContent { mockItemArchive.mockImplementation(() => { if (this.failItemActions) throw new Error('Simulated network failure.'); - if (item.status != Status.ACTIVE) { + if (item.status !== Status.ACTIVE) { throw new Error('Cannot archive content that is already archived.'); } @@ -328,7 +347,7 @@ export class MockContent { mockItemUnarchive.mockImplementation(() => { if (this.failItemActions) throw new Error('Simulated network failure.'); - if (item.status == Status.ACTIVE) { + if (item.status === Status.ACTIVE) { throw new Error('Cannot unarchive content that is not archived.'); } @@ -364,17 +383,62 @@ export class MockContent { schemaOnly?: boolean ): void { if (!this.typeSchemaById.has(id)) { - const schema = new ContentTypeSchema({ id: id, schemaId: schemaName, body: JSON.stringify(body) }); + const schema = new ContentTypeSchema({ + id: id, + schemaId: schemaName, + body: JSON.stringify(body), + status: 'ACTIVE' + }); this.typeSchemaById.set(id, schema); + + const mockSchemaArchive = jest.fn(); + schema.related.archive = mockSchemaArchive; + + const mockSchemaUpdate = jest.fn(); + schema.related.update = mockSchemaUpdate; + + mockSchemaArchive.mockImplementation(() => { + if (this.failSchemaActions) throw new Error('Simulated network failure.'); + if (schema.status !== Status.ACTIVE) { + throw new Error('Cannot archive content that is already archived.'); + } + + this.metrics.typeSchemasArchived++; + + schema.status = Status.ARCHIVED; + + return Promise.resolve(schema); + }); + + mockSchemaUpdate.mockImplementation(newSchema => { + if (this.failSchemaActions) throw new Error('Simulated network failure.'); + this.metrics.typeSchemasUpdated++; + + schema.body = newSchema.body; + schema.version = (schema.version as number) + 1; + + return Promise.resolve(schema); + }); } if (!schemaOnly) { - const type = new ContentType({ id: id, contentTypeUri: schemaName, settings: { label: basename(schemaName) } }); + const type = new ContentType({ + id: id, + contentTypeUri: schemaName, + settings: { label: basename(schemaName) }, + status: 'ACTIVE' + }); this.typeById.set(id, type); const mockCached = jest.fn(); type.related.contentTypeSchema.get = mockCached; + const mockCachedUpdate = jest.fn(); + type.related.contentTypeSchema.update = mockCachedUpdate; + + const mockTypeArchive = jest.fn(); + type.related.archive = mockTypeArchive; + mockCached.mockImplementation(() => { const cached = new ContentTypeCachedSchema({ contentTypeUri: schemaName, @@ -384,6 +448,30 @@ export class MockContent { return Promise.resolve(cached); }); + mockCachedUpdate.mockImplementation(() => { + const cached = new ContentTypeCachedSchema({ + contentTypeUri: schemaName, + cachedSchema: { ...body, $id: schemaName } + }); + + this.metrics.typesSynced++; + + return Promise.resolve(cached); + }); + + mockTypeArchive.mockImplementation(() => { + if (this.failTypeActions) throw new Error('Simulated network failure.'); + if (type.status !== Status.ACTIVE) { + throw new Error('Cannot archive content that is already archived.'); + } + + this.metrics.typesArchived++; + + type.status = Status.ARCHIVED; + + return Promise.resolve(type); + }); + const repoArray = typeof repos === 'string' ? [repos] : repos; repoArray.forEach(repoName => { const typeAssignments = this.typeAssignmentsByRepoId.get(repoName) || []; diff --git a/src/common/dc-management-sdk-js/paginator.ts b/src/common/dc-management-sdk-js/paginator.ts index f1a9db3e..8cedb073 100644 --- a/src/common/dc-management-sdk-js/paginator.ts +++ b/src/common/dc-management-sdk-js/paginator.ts @@ -1,14 +1,14 @@ -import { HalResource, Page, Pageable, Sortable } from 'dc-management-sdk-js'; +import { HalResource, Page, Pageable, Sortable, Status } from 'dc-management-sdk-js'; export const DEFAULT_SIZE = 100; -interface StatusQuery { - status?: 'ARCHIVED' | 'ACTIVE' | 'DELETED'; +interface ResourceStatus { + status?: Status; } const paginator = async ( - pagableFn: (options?: Pageable & Sortable & StatusQuery) => Promise>, - options: Pageable & Sortable & StatusQuery = {} + pagableFn: (options?: Pageable & Sortable & ResourceStatus) => Promise>, + options: Pageable & Sortable & ResourceStatus = {} ): Promise => { const currentPage = await pagableFn({ ...options, size: DEFAULT_SIZE }); if ( diff --git a/src/common/dc-management-sdk-js/resource-status.ts b/src/common/dc-management-sdk-js/resource-status.ts new file mode 100644 index 00000000..e209d09e --- /dev/null +++ b/src/common/dc-management-sdk-js/resource-status.ts @@ -0,0 +1,9 @@ +export enum Status { + ACTIVE = 'ACTIVE', + ARCHIVED = 'ARCHIVED', + DELETED = 'DELETED' +} + +export interface ResourceStatus { + status?: Status; +} diff --git a/src/common/file-log.ts b/src/common/file-log.ts index a93f0e91..a209203c 100644 --- a/src/common/file-log.ts +++ b/src/common/file-log.ts @@ -13,8 +13,10 @@ export class FileLog extends ArchiveLog { } } - public appendLine(text?: string): void { - console.log(text); + public appendLine(text = 'undefined', silent = false): void { + if (!silent) { + process.stdout.write(text + '\n'); + } this.addComment(text as string); } diff --git a/src/common/log-helpers.ts b/src/common/log-helpers.ts index 971fc4e1..7a69e3b7 100644 --- a/src/common/log-helpers.ts +++ b/src/common/log-helpers.ts @@ -1,4 +1,5 @@ import { join } from 'path'; +import { LogErrorLevel } from './archive/archive-log'; import { FileLog } from './file-log'; export function getDefaultLogPath(type: string, action: string, platform: string = process.platform): string { @@ -20,3 +21,19 @@ export function createLog(logFile: string, title?: string): FileLog { return log; } + +export async function openRevertLog(filename: string): Promise { + if (filename == null) { + return undefined; + } + + const log = new FileLog(); + + try { + await log.loadFromFile(filename); + } catch { + log.errorLevel = LogErrorLevel.INVALID; + } + + return log; +} diff --git a/src/common/question-helpers.ts b/src/common/question-helpers.ts new file mode 100644 index 00000000..6e987afc --- /dev/null +++ b/src/common/question-helpers.ts @@ -0,0 +1,24 @@ +import readline, { ReadLine } from 'readline'; +import { FileLog } from './file-log'; + +function asyncQuestionInternal(rl: ReadLine, question: string): Promise { + return new Promise((resolve): void => { + rl.question(question, resolve); + }); +} + +export async function asyncQuestion(question: string, log?: FileLog): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + const answer = await asyncQuestionInternal(rl, question); + rl.close(); + + if (log != null) { + log.appendLine(question + answer, true); + } + return answer.length > 0 && answer[0].toLowerCase() === 'y'; +} diff --git a/src/interfaces/clone-hub-builder-options.ts b/src/interfaces/clone-hub-builder-options.ts new file mode 100644 index 00000000..3358b4e7 --- /dev/null +++ b/src/interfaces/clone-hub-builder-options.ts @@ -0,0 +1,28 @@ +import { CloneHubStepId } from '../commands/hub/model/clone-hub-step'; +import { CopyConfig } from '../common/content-item/copy-config'; +import { FileLog } from '../common/file-log'; + +export interface CloneHubBuilderOptions { + dir: string; + + dstHubId?: string; + dstClientId?: string; + dstSecret?: string; + + revertLog: Promise; + step?: CloneHubStepId; + + mapFile?: string; + force?: boolean; + validate?: boolean; + skipIncomplete?: boolean; + media?: boolean; + logFile: FileLog; + copyConfig?: string | CopyConfig; + + lastPublish?: boolean; + publish?: boolean; + republish?: boolean; + + excludeKeys?: boolean; +} diff --git a/src/interfaces/copy-item-builder-options.interface.ts b/src/interfaces/copy-item-builder-options.interface.ts index 6cbcb975..a123789e 100644 --- a/src/interfaces/copy-item-builder-options.interface.ts +++ b/src/interfaces/copy-item-builder-options.interface.ts @@ -23,7 +23,7 @@ export interface CopyItemBuilderOptions { logFile: FileLog; copyConfig?: string | CopyConfig; - revertLog?: string; + revertLog: Promise; lastPublish?: boolean; publish?: boolean; diff --git a/src/interfaces/export-builder-options.interface.ts b/src/interfaces/export-builder-options.interface.ts index c32adb75..547ddad9 100644 --- a/src/interfaces/export-builder-options.interface.ts +++ b/src/interfaces/export-builder-options.interface.ts @@ -1,5 +1,9 @@ +import { FileLog } from '../common/file-log'; + export interface ExportBuilderOptions { dir: string; schemaId?: string[]; archived?: boolean; + logFile: FileLog; + force?: boolean; } diff --git a/src/interfaces/export-item-builder-options.interface.ts b/src/interfaces/export-item-builder-options.interface.ts index 084fdb28..1fe0aeed 100644 --- a/src/interfaces/export-item-builder-options.interface.ts +++ b/src/interfaces/export-item-builder-options.interface.ts @@ -6,7 +6,7 @@ export interface ExportItemBuilderOptions { repoId?: string[] | string; schemaId?: string[] | string; name?: string[] | string; - logFile?: FileLog; + logFile: FileLog; publish?: boolean; exportedIds?: string[]; diff --git a/src/interfaces/import-builder-options.interface.ts b/src/interfaces/import-builder-options.interface.ts index 1b736e94..7694db2d 100644 --- a/src/interfaces/import-builder-options.interface.ts +++ b/src/interfaces/import-builder-options.interface.ts @@ -1,3 +1,6 @@ +import { FileLog } from '../common/file-log'; + export interface ImportBuilderOptions { dir: string; + logFile: FileLog; } diff --git a/src/interfaces/import-item-builder-options.interface.ts b/src/interfaces/import-item-builder-options.interface.ts index afbd49dc..2a387937 100644 --- a/src/interfaces/import-item-builder-options.interface.ts +++ b/src/interfaces/import-item-builder-options.interface.ts @@ -12,7 +12,7 @@ export interface ImportItemBuilderOptions { skipIncomplete?: boolean; excludeKeys?: boolean; media?: boolean; - logFile?: FileLog; + logFile: FileLog; - revertLog?: string; + revertLog: Promise; } diff --git a/src/interfaces/import-settings-builder-options.interface.ts b/src/interfaces/import-settings-builder-options.interface.ts index 8101c4b4..e8cd2418 100644 --- a/src/interfaces/import-settings-builder-options.interface.ts +++ b/src/interfaces/import-settings-builder-options.interface.ts @@ -1,6 +1,8 @@ +import { FileLog } from '../common/file-log'; + export interface ImportSettingsBuilderOptions { filePath: string; mapFile?: string; - logFile?: string; + logFile: FileLog; force?: boolean; } diff --git a/src/services/export.service.spec.ts b/src/services/export.service.spec.ts index 53ab5437..7b88b980 100644 --- a/src/services/export.service.spec.ts +++ b/src/services/export.service.spec.ts @@ -4,6 +4,7 @@ import { uniqueFilename } from './export.service'; import { ContentType } from 'dc-management-sdk-js'; import * as readline from 'readline'; import { table } from 'table'; +import { FileLog } from '../common/file-log'; const mockQuestion = jest.fn(); const mockClose = jest.fn(); @@ -95,7 +96,7 @@ describe('export service tests', () => { }); const updatedExportsMap = [{ filename: 'my-export-filename', schemaId: 'my-content-type-uri' }]; - const res = await promptToOverwriteExports(updatedExportsMap); + const res = await promptToOverwriteExports(updatedExportsMap, new FileLog()); expect(res).toBeTruthy(); expect(createInterfaceSpy).toHaveBeenCalledTimes(1); @@ -112,7 +113,7 @@ describe('export service tests', () => { }); const updatedExportsMap = [{ filename: 'my-export-filename', schemaId: 'my-content-type-uri' }]; - const res = await promptToOverwriteExports(updatedExportsMap); + const res = await promptToOverwriteExports(updatedExportsMap, new FileLog()); expect(res).toBeFalsy(); expect(createInterfaceSpy).toHaveBeenCalledTimes(1); @@ -129,7 +130,7 @@ describe('export service tests', () => { }); const updatedExportsMap = [{ filename: 'my-export-filename', schemaId: 'my-content-type-uri' }]; - const res = await promptToOverwriteExports(updatedExportsMap); + const res = await promptToOverwriteExports(updatedExportsMap, new FileLog()); expect(res).toBeFalsy(); expect(createInterfaceSpy).toHaveBeenCalledTimes(1); @@ -144,15 +145,10 @@ describe('export service tests', () => { describe('nothingExportedExit', () => { it('should exit with an export message', () => { const writeSpy = jest.spyOn(process.stdout, 'write'); - const exitSpy = jest.spyOn(process, 'exit'); - const exitError = new Error('PROCESS EXIT INVOKED FOR TEST'); writeSpy.mockImplementation(); - exitSpy.mockImplementation(() => { - throw exitError; - }); - expect(nothingExportedExit).toThrowError(exitError); + nothingExportedExit(new FileLog()); expect(writeSpy.mock.calls).toMatchSnapshot(); }); }); diff --git a/src/services/export.service.ts b/src/services/export.service.ts index 943cc3cd..d464f703 100644 --- a/src/services/export.service.ts +++ b/src/services/export.service.ts @@ -2,7 +2,8 @@ import fs from 'fs'; import * as path from 'path'; import { URL } from 'url'; import DataPresenter from '../view/data-presenter'; -import readline from 'readline'; +import { asyncQuestion } from '../common/question-helpers'; +import { FileLog } from '../common/file-log'; export type ExportResult = 'CREATED' | 'UPDATED' | 'UP-TO-DATE'; @@ -38,43 +39,25 @@ export const writeJsonToFile = (filename: string, resource: T): vo } }; -export const promptToOverwriteExports = (updatedExportsMap: { [key: string]: string }[]): Promise => { - return new Promise((resolve): void => { - process.stdout.write('The following files will be overwritten:\n'); - // display updatedExportsMap as a table of uri x filename - const itemMapFn = ({ filename, schemaId }: { filename: string; schemaId: string }): object => ({ - File: filename, - 'Schema ID': schemaId - }); - new DataPresenter(updatedExportsMap).render({ itemMapFn }); - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - - rl.question('Do you want to continue (y/n)?: ', answer => { - rl.close(); - return resolve(answer === 'y'); - }); +export const promptToOverwriteExports = ( + updatedExportsMap: { [key: string]: string }[], + log: FileLog +): Promise => { + log.appendLine('The following files will be overwritten:'); + // display updatedExportsMap as a table of uri x filename + const itemMapFn = ({ filename, schemaId }: { filename: string; schemaId: string }): object => ({ + File: filename, + 'Schema ID': schemaId }); -}; + new DataPresenter(updatedExportsMap).render({ itemMapFn, printFn: log.appendLine.bind(log) }); -export const promptToExportSettings = (filename: string): Promise => { - return new Promise((resolve): void => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); + return asyncQuestion('Do you want to continue (y/n)?: ', log); +}; - rl.question(`Do you want to export setting to ${filename} (y/n)?: `, answer => { - rl.close(); - return resolve(answer === 'y'); - }); - }); +export const promptToExportSettings = (filename: string, log: FileLog): Promise => { + return asyncQuestion(`Do you want to export setting to ${filename} (y/n)?: `, log); }; -export const nothingExportedExit = (msg = 'Nothing was exported, exiting.\n'): void => { - process.stdout.write(msg); - process.exit(1); +export const nothingExportedExit = (log: FileLog, msg = 'Nothing was exported, exiting.'): void => { + log.appendLine(msg); }; diff --git a/src/view/data-presenter.ts b/src/view/data-presenter.ts index 266dac15..f8a365cd 100644 --- a/src/view/data-presenter.ts +++ b/src/view/data-presenter.ts @@ -16,11 +16,13 @@ export const RenderingOptions: CommandOptions = { }; type MapFn = (data: object) => object; +type PrintFn = (message: string) => void; interface RenderOptions { json?: boolean; tableUserConfig?: TableUserConfig; itemMapFn?: MapFn; + printFn?: PrintFn; } export default class DataPresenter { @@ -53,8 +55,13 @@ export default class DataPresenter { output = Array.isArray(this.data) ? this.generateHorizontalTable(this.data.map(itemMapFn), renderOptions.tableUserConfig) : this.generateVerticalTable(itemMapFn(this.data), renderOptions.tableUserConfig); - output += '\n'; } - process.stdout.write(output); + + if (renderOptions.printFn) { + renderOptions.printFn(output); + } else { + if (!renderOptions.json) output += '\n'; + process.stdout.write(output); + } } } From 65a04f7d83099cbd0e96f29a54be2b8fb43756eb Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 23 Mar 2021 09:42:59 +0000 Subject: [PATCH 02/12] feat(content-item): dependency tree printing command (tree) --- src/commands/content-item/tree.ts | 185 ++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/commands/content-item/tree.ts diff --git a/src/commands/content-item/tree.ts b/src/commands/content-item/tree.ts new file mode 100644 index 00000000..f2858476 --- /dev/null +++ b/src/commands/content-item/tree.ts @@ -0,0 +1,185 @@ +import { getDefaultLogPath } from '../../common/log-helpers'; +import { Argv, Arguments } from 'yargs'; +import { join, extname, resolve } from 'path'; +import { ConfigurationParameters } from '../configure'; +import { lstat, readdir, readFile } from 'fs'; +import { promisify } from 'util'; + +import { ContentItem, ContentRepository } from 'dc-management-sdk-js'; +import { + ContentDependancyTree, + ItemContentDependancies, + RepositoryContentItem +} from '../../common/content-item/content-dependancy-tree'; +import { ContentMapping } from '../../common/content-item/content-mapping'; + +export function getTempFolder(name: string, platform: string = process.platform): string { + return join(process.env[platform == 'win32' ? 'USERPROFILE' : 'HOME'] || __dirname, '.amplience', `copy-${name}/`); +} + +export const command = 'tree '; + +export const desc = 'Print a content dependency tree from content in the given folder.'; + +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('item', 'tree', platform); + +export const builder = (yargs: Argv): void => { + yargs.positional('dir', { + type: 'string', + describe: 'Path to the content items to build a tree from.. Should be in the same format as an export.' + }); +}; + +interface TreeOptions { + dir: string; +} + +const traverseRecursive = async (path: string, action: (path: string) => Promise): Promise => { + const dir = await promisify(readdir)(path); + + await Promise.all( + dir.map(async (contained: string) => { + contained = join(path, contained); + const stat = await promisify(lstat)(contained); + return await (stat.isDirectory() ? traverseRecursive(contained, action) : action(contained)); + }) + ); +}; + +const prepareContentForTree = async ( + repos: { basePath: string; repo: ContentRepository }[], + argv: Arguments +): Promise => { + const contentItems: RepositoryContentItem[] = []; + const schemaNames = new Set(); + + for (let i = 0; i < repos.length; i++) { + const repo = repos[i].repo; + + await traverseRecursive(resolve(repos[i].basePath), async path => { + // Is this valid content? Must have extension .json to be considered, for a start. + if (extname(path) !== '.json') { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let contentJSON: any; + try { + const contentText = await promisify(readFile)(path, { encoding: 'utf8' }); + contentJSON = JSON.parse(contentText); + } catch (e) { + console.error(`Couldn't read content item at '${path}': ${e.toString()}`); + return; + } + + // Get the folder id via the mapping. + + // Only filter relevant information - for example status and previous content repo are not useful. + const filteredContent = { + id: contentJSON.id, + label: contentJSON.label, + locale: contentJSON.locale, + body: contentJSON.body, + deliveryId: contentJSON.deliveryId == contentJSON.Id || argv.excludeKeys ? undefined : contentJSON.deliveryId, + folderId: null, + publish: contentJSON.lastPublishedVersion != null + }; + + if (argv.excludeKeys) { + delete filteredContent.body._meta.deliveryKey; + } + + schemaNames.add(contentJSON.body._meta.schema); + + contentItems.push({ repo: repo, content: new ContentItem(filteredContent) }); + }); + } + + return new ContentDependancyTree(contentItems, new ContentMapping()); +}; + +export const handler = async (argv: Arguments): Promise => { + const dir = argv.dir; + + const baseDirContents = await promisify(readdir)(dir); + const importRepos: { basePath: string; repo: ContentRepository }[] = []; + for (let i = 0; i < baseDirContents.length; i++) { + const name = baseDirContents[i]; + const path = join(dir, name); + const status = await promisify(lstat)(path); + if (status.isDirectory()) { + importRepos.push({ basePath: path, repo: new ContentRepository() }); + } + } + + const tree = await prepareContentForTree(importRepos, argv); + + // Print the items in the tree. + // Keep a set of all items that have already been printed. + // Starting at the highest level, print all dependencies on the tree. + + if (tree == null) return; + + const evaluated = new Set(); + const firstSecondThird = (index: number, total: number): number => { + return index === 0 ? 0 : index == total - 1 ? 2 : 1; + }; + + const fstPipes = ['├', '├', '└']; + + const printDependency = ( + item: ItemContentDependancies, + depth: number, + evalThis: ItemContentDependancies[], + fst: number, + prefix: string + ): boolean => { + const pipe = depth < 0 ? '' : fstPipes[fst] + '─ '; + + if (evalThis.indexOf(item) !== -1) { + console.log(`${prefix}${pipe}*** (${item.owner.content.label})`); + return false; + } else if (evaluated.has(item)) { + if (depth > 0) { + console.log(`${prefix}${pipe}(${item.owner.content.label})`); + } + return false; + } else { + console.log(`${prefix}${pipe}${item.owner.content.label}`); + } + + evalThis.push(item); + evaluated.add(item); + + item.dependancies.forEach((dep, index) => { + if (dep.resolved) { + const subFst = firstSecondThird(index, item.dependancies.length); + const subPrefix = depth == -1 ? '' : fst === 2 ? ' ' : '│ '; + printDependency(dep.resolved, depth + 1, [...evalThis], subFst, prefix + subPrefix); + } + }); + return true; + }; + + for (let i = tree.levels.length - 1; i >= 0; i--) { + const level = tree.levels[i]; + console.log(`=== LEVEL ${i + 1} (${level.items.length}) ===`); + + level.items.forEach(item => { + printDependency(item, -1, [], 0, ''); + console.log(''); + }); + } + + console.log(`=== CIRCULAR (${tree.circularLinks.length}) ===`); + let topLevelPrints = 0; + tree.circularLinks.forEach(item => { + if (printDependency(item, -1, [], 0, '')) { + topLevelPrints++; + console.log(''); + } + }); + + console.log(`Finished. Circular Dependencies printed: ${topLevelPrints}`); +}; From c395cb9dff7bc608b1ba5970b916b78b28e47111 Mon Sep 17 00:00:00 2001 From: Rhys Date: Wed, 31 Mar 2021 09:15:48 +0100 Subject: [PATCH 03/12] feat(content-item): improve circular import, tree printing --- src/commands/content-item/import.ts | 21 ++- src/commands/content-item/tree.ts | 170 +++++++++++++----- .../content-item/content-dependancy-tree.ts | 136 +++++++++++++- 3 files changed, 270 insertions(+), 57 deletions(-) diff --git a/src/commands/content-item/import.ts b/src/commands/content-item/import.ts index ba8dbdda..acb23b09 100644 --- a/src/commands/content-item/import.ts +++ b/src/commands/content-item/import.ts @@ -675,12 +675,22 @@ const prepareContentForImport = async ( return tree; }; -const rewriteDependancy = (dep: ContentDependancyInfo, mapping: ContentMapping): void => { - const id = mapping.getContentItem(dep.dependancy.id) || dep.dependancy.id; +const rewriteDependancy = (dep: ContentDependancyInfo, mapping: ContentMapping, allowNull: boolean): void => { + let id = mapping.getContentItem(dep.dependancy.id); + + if (id == null && !allowNull) { + id = dep.dependancy.id; + } + if (dep.dependancy._meta.schema === '_hierarchy') { dep.owner.content.body._meta.hierarchy.parentId = id; } else { - dep.dependancy.id = id; + if (id == null) { + delete dep.parent[dep.index]; + } else { + dep.parent[dep.index] = dep.dependancy; + dep.dependancy.id = id; + } } }; @@ -706,7 +716,7 @@ const importTree = async ( // Replace any dependancies with the existing mapping. item.dependancies.forEach(dep => { - rewriteDependancy(dep, mapping); + rewriteDependancy(dep, mapping, false); }); const originalId = content.id; @@ -781,7 +791,7 @@ const importTree = async ( const content = item.owner.content; item.dependancies.forEach(dep => { - rewriteDependancy(dep, mapping); + rewriteDependancy(dep, mapping, true); }); const originalId = content.id; @@ -815,6 +825,7 @@ const importTree = async ( newDependants[i] = newItem; mapping.registerContentItem(originalId as string, newItem.id as string); + mapping.registerContentItem(newItem.id as string, newItem.id as string); } else { if (itemShouldPublish(content) && (newItem.version != oldVersion || argv.republish)) { publishable.push({ item: newItem, node: item }); diff --git a/src/commands/content-item/tree.ts b/src/commands/content-item/tree.ts index f2858476..a3b006f1 100644 --- a/src/commands/content-item/tree.ts +++ b/src/commands/content-item/tree.ts @@ -99,6 +99,131 @@ const prepareContentForTree = async ( return new ContentDependancyTree(contentItems, new ContentMapping()); }; +type CircularLink = [number, number]; +interface ParentReference { + item: ItemContentDependancies; + line: number; +} + +const firstSecondThird = (index: number, total: number): number => { + return index === 0 ? 0 : index == total - 1 ? 2 : 1; +}; + +const fstPipes = ['├', '├', '└']; +const circularPipes = ['╗', '║', '╝']; +const circularLine = '═'; + +const printDependency = ( + item: ItemContentDependancies, + evaluated: Set, + lines: string[], + circularLinks: CircularLink[], + evalThis: ParentReference[], + fst: number, + prefix: string +): boolean => { + const depth = evalThis.length - 1; + const pipe = depth < 0 ? '' : fstPipes[fst] + '─ '; + + const circularMatch = evalThis.find(parent => parent.item == item); + if (circularMatch) { + lines.push(`${prefix}${pipe}*** (${item.owner.content.label})`); + circularLinks.push([circularMatch.line, lines.length - 1]); + return false; + } else if (evaluated.has(item)) { + if (depth > -1) { + lines.push(`${prefix}${pipe}(${item.owner.content.label})`); + } + return false; + } else { + lines.push(`${prefix}${pipe}${item.owner.content.label}`); + } + + evalThis.push({ item, line: lines.length - 1 }); + evaluated.add(item); + + const filteredItems = item.dependancies.filter(dep => dep.resolved); + filteredItems.forEach((dep, index) => { + if (dep.resolved) { + const subFst = firstSecondThird(index, filteredItems.length); + const subPrefix = depth == -1 ? '' : fst === 2 ? ' ' : '│ '; + printDependency(dep.resolved, evaluated, lines, circularLinks, [...evalThis], subFst, prefix + subPrefix); + } + }); + return true; +}; + +const fillWhitespace = (original: string, current: string, char: string, targetLength: number): string => { + if (current.length < original.length + 1) { + current += ' '; + } + + let position = original.length + 1; + let repeats = targetLength - (original.length + 1); + + // Replace existing whitespace characters + while (position < current.length && repeats > 0) { + if (current[position] != char && current[position] == ' ') { + current = current.slice(0, position) + char + current.slice(position + 1); + } + + position++; + repeats--; + } + + if (repeats > 0) { + current += char.repeat(repeats); + } + + return current; +}; + +const printTree = (item: ItemContentDependancies, evaluated: Set): boolean => { + const lines: string[] = []; + const circularLinks: CircularLink[] = []; + + const result = printDependency(item, evaluated, lines, circularLinks, [], 0, ''); + + if (!result) return false; + + const modifiedLines = [...lines]; + + // Render circular references. + // These are drawn as pipes on the right hand side, from a start line to an end line. + + const maxWidth = Math.max(...lines.map(x => x.length)); + + for (let i = 0; i < circularLinks.length; i++) { + const link = circularLinks[i]; + let linkDist = maxWidth + 2; + + // Find overlapping circular links. Push the link out further if a previously drawn line is there. + for (let j = 0; j < i; j++) { + const link2 = circularLinks[j]; + if (link[0] <= link2[1] && link[1] >= link2[0]) { + linkDist += 2; + } + } + + // Write the circular dependency lines into the tree. + + for (let ln = link[0]; ln <= link[1]; ln++) { + const end = ln == link[0] || ln == link[1]; + const original = lines[ln]; + let current = modifiedLines[ln]; + + current = fillWhitespace(original, current, end ? circularLine : ' ', linkDist); + current += circularPipes[firstSecondThird(ln - link[0], link[1] - link[0] + 1)]; + + modifiedLines[ln] = current; + } + } + + modifiedLines.forEach(line => console.log(line)); + console.log(''); + return true; +}; + export const handler = async (argv: Arguments): Promise => { const dir = argv.dir; @@ -122,62 +247,21 @@ export const handler = async (argv: Arguments(); - const firstSecondThird = (index: number, total: number): number => { - return index === 0 ? 0 : index == total - 1 ? 2 : 1; - }; - - const fstPipes = ['├', '├', '└']; - - const printDependency = ( - item: ItemContentDependancies, - depth: number, - evalThis: ItemContentDependancies[], - fst: number, - prefix: string - ): boolean => { - const pipe = depth < 0 ? '' : fstPipes[fst] + '─ '; - - if (evalThis.indexOf(item) !== -1) { - console.log(`${prefix}${pipe}*** (${item.owner.content.label})`); - return false; - } else if (evaluated.has(item)) { - if (depth > 0) { - console.log(`${prefix}${pipe}(${item.owner.content.label})`); - } - return false; - } else { - console.log(`${prefix}${pipe}${item.owner.content.label}`); - } - - evalThis.push(item); - evaluated.add(item); - - item.dependancies.forEach((dep, index) => { - if (dep.resolved) { - const subFst = firstSecondThird(index, item.dependancies.length); - const subPrefix = depth == -1 ? '' : fst === 2 ? ' ' : '│ '; - printDependency(dep.resolved, depth + 1, [...evalThis], subFst, prefix + subPrefix); - } - }); - return true; - }; for (let i = tree.levels.length - 1; i >= 0; i--) { const level = tree.levels[i]; console.log(`=== LEVEL ${i + 1} (${level.items.length}) ===`); level.items.forEach(item => { - printDependency(item, -1, [], 0, ''); - console.log(''); + printTree(item, evaluated); }); } console.log(`=== CIRCULAR (${tree.circularLinks.length}) ===`); let topLevelPrints = 0; tree.circularLinks.forEach(item => { - if (printDependency(item, -1, [], 0, '')) { + if (printTree(item, evaluated)) { topLevelPrints++; - console.log(''); } }); diff --git a/src/common/content-item/content-dependancy-tree.ts b/src/common/content-item/content-dependancy-tree.ts index 62ee583b..0f7afac2 100644 --- a/src/common/content-item/content-dependancy-tree.ts +++ b/src/common/content-item/content-dependancy-tree.ts @@ -22,6 +22,10 @@ export interface ContentDependancyInfo { resolved?: ItemContentDependancies; dependancy: ContentDependancy; owner: RepositoryContentItem; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parent: any; + index: string | number; } export interface ItemContentDependancies { @@ -39,6 +43,12 @@ export const referenceTypes = [ 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-reference' ]; +enum CircularDependencyStage { + Standalone = 0, + Intertwined, + Parent +} + type RecursiveSearchStep = Body | ContentDependancy | Array; export class ContentDependancyTree { @@ -96,8 +106,40 @@ export class ContentDependancyTree { // Remaining items in the info array are connected to circular dependancies, so must be resolved via rewriting. + // Create dependency layers for circular dependencies + + const circularStages: ItemContentDependancies[][] = []; + while (unresolvedCount > 0) { + const stage: ItemContentDependancies[] = []; + + // To be in this stage, the circular dependency must contain no other circular dependencies (before self-loop). + // The circular dependencies that appear before self loop are + const lastUnresolvedCount = unresolvedCount; + const circularLevels = info.map(item => this.topLevelCircular(item, info)); + + const chosenLevel = Math.min(...circularLevels) as CircularDependencyStage; + + for (let i = 0; i < info.length; i++) { + const item = info[i]; + if (circularLevels[i] === chosenLevel) { + stage.push(item); + circularLevels.splice(i, 1); + info.splice(i--, 1); + } + } + + unresolvedCount = info.length; + if (unresolvedCount === lastUnresolvedCount) { + break; + } + + circularStages.push(stage); + } + this.levels = stages; - this.circularLinks = info; + this.circularLinks = []; + circularStages.forEach(stage => this.circularLinks.push(...stage)); + this.all = allInfo; this.byId = new Map(allInfo.map(info => [info.owner.content.id as string, info])); this.requiredSchema = Array.from(requiredSchema); @@ -106,11 +148,13 @@ export class ContentDependancyTree { private searchObjectForContentDependancies( item: RepositoryContentItem, body: RecursiveSearchStep, - result: ContentDependancyInfo[] + result: ContentDependancyInfo[], + parent: RecursiveSearchStep | null, + index: string | number ): void { if (Array.isArray(body)) { - body.forEach(contained => { - this.searchObjectForContentDependancies(item, contained, result); + body.forEach((contained, index) => { + this.searchObjectForContentDependancies(item, contained, result, body, index); }); } else if (body != null) { const allPropertyNames = Object.getOwnPropertyNames(body); @@ -121,14 +165,14 @@ export class ContentDependancyTree { typeof body.contentType === 'string' && typeof body.id === 'string' ) { - result.push({ dependancy: body as ContentDependancy, owner: item }); + result.push({ dependancy: body as ContentDependancy, owner: item, parent, index }); return; } allPropertyNames.forEach(propName => { const prop = (body as Body)[propName]; if (typeof prop === 'object') { - this.searchObjectForContentDependancies(item, prop, result); + this.searchObjectForContentDependancies(item, prop, result, body, propName); } }); } @@ -161,10 +205,76 @@ export class ContentDependancyTree { } } + private topLevelCircular( + top: ItemContentDependancies, + unresolved: ItemContentDependancies[] + ): CircularDependencyStage { + let selfLoop = false; + let intertwinedLoop = false; + const seenBefore = new Set(); + + const traverse = ( + top: ItemContentDependancies, + item: ItemContentDependancies | undefined, + depth: number, + unresolved: ItemContentDependancies[], + seenBefore: Set, + intertwined: boolean + ): boolean => { + let hasCircular = false; + + if (item == null) { + return false; + } else if (top === item && depth > 0) { + selfLoop = true; + return false; + } else if (top !== item && unresolved.indexOf(item) !== -1) { + // Contains a circular dependency. + + if (!intertwined) { + // Does it loop back to the parent? + const storedSelfLoop = selfLoop; + intertwinedLoop = traverse(item, item, 0, [top], new Set(), true); + selfLoop = storedSelfLoop; + } + + hasCircular = true; + } + + if (seenBefore.has(item)) { + return false; + } + + seenBefore.add(item); + + item.dependancies.forEach(dep => { + hasCircular = traverse(top, dep.resolved, depth++, unresolved, seenBefore, intertwined) || hasCircular; + }); + + return hasCircular; + }; + + const hasCircular = traverse(top, top, 0, unresolved, seenBefore, false); + + if (hasCircular) { + if (intertwinedLoop) { + if (selfLoop) { + return CircularDependencyStage.Intertwined; + } else { + return CircularDependencyStage.Parent; + } + } else { + return CircularDependencyStage.Parent; + } + } else { + return CircularDependencyStage.Standalone; + } + } + private identifyContentDependancies(items: RepositoryContentItem[]): ItemContentDependancies[] { return items.map(item => { const result: ContentDependancyInfo[] = []; - this.searchObjectForContentDependancies(item, item.content.body, result); + this.searchObjectForContentDependancies(item, item.content.body, result, null, 0); // Hierarchy parent is also a dependancy. if (item.content.body._meta.hierarchy && item.content.body._meta.hierarchy.parentId) { @@ -176,7 +286,9 @@ export class ContentDependancyTree { id: item.content.body._meta.hierarchy.parentId, contentType: '' }, - owner: item + owner: item, + parent: null, + index: 0 }); } @@ -198,7 +310,13 @@ export class ContentDependancyTree { const target = idMap.get(dep.dependancy.id as string); dep.resolved = target; if (target) { - target.dependants.push({ owner: target.owner, resolved: item, dependancy: dep.dependancy }); + target.dependants.push({ + owner: target.owner, + resolved: item, + dependancy: dep.dependancy, + parent: dep.parent, + index: dep.index + }); resolve(target); } }); From a54f7f257dc6819218a5d02810fe3eb47262dd8b Mon Sep 17 00:00:00 2001 From: Rhys Date: Thu, 8 Apr 2021 23:42:48 +0100 Subject: [PATCH 04/12] test(content-item): add tree command tests, works on any folder, fix circular bugs --- .../__snapshots__/tree.spec.ts.snap | 90 +++++ src/commands/content-item/tree.spec.ts | 309 ++++++++++++++++++ src/commands/content-item/tree.ts | 139 ++++---- .../content-item/content-dependancy-tree.ts | 22 +- 4 files changed, 473 insertions(+), 87 deletions(-) create mode 100644 src/commands/content-item/__snapshots__/tree.spec.ts.snap create mode 100644 src/commands/content-item/tree.spec.ts diff --git a/src/commands/content-item/__snapshots__/tree.spec.ts.snap b/src/commands/content-item/__snapshots__/tree.spec.ts.snap new file mode 100644 index 00000000..eec692aa --- /dev/null +++ b/src/commands/content-item/__snapshots__/tree.spec.ts.snap @@ -0,0 +1,90 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`content-item tree command handler tests should detect and print circular dependencies with a double line indicator 1`] = ` +"=== LEVEL 2 (1) === +item6 +└─ item5 + +=== LEVEL 1 (3) === +item3 + +item7 + +=== CIRCULAR (3) === +item1 ═════════════════╗ +├─ item2 ║ +│ └─ item4 ║ +│ └─ *** (item1) ══╝ +└─ (item3) + +Finished. Circular Dependencies printed: 1" +`; + +exports[`content-item tree command handler tests should detect intertwined circular dependencies with multiple lines with different position 1`] = ` +"test +test +=== CIRCULAR (6) === +item5 ══════════════╗ +└─ item6 ║ + └─ *** (item5) ══╝ + +item1 ══════════════════════╗ +└─ item2 ═════════════════╗ ║ + └─ item3 ║ ║ + ├─ *** (item2) ═════╝ ║ + └─ item4 ║ + ├─ *** (item1) ════╝ + └─ (item5) + +Finished. Circular Dependencies printed: 2" +`; + +exports[`content-item tree command handler tests should print a single content item by itself 1`] = ` +"=== LEVEL 1 (1) === +item1 + +Finished. Circular Dependencies printed: 0" +`; + +exports[`content-item tree command handler tests should print a tree of content items 1`] = ` +"=== LEVEL 4 (1) === +item1 +├─ item2 +│ ├─ item4 +│ └─ item6 +│ └─ item5 +└─ item3 + +=== LEVEL 3 (1) === +=== LEVEL 2 (1) === +=== LEVEL 1 (3) === +Finished. Circular Dependencies printed: 0" +`; + +exports[`content-item tree command handler tests should print an error when invalid json is found 1`] = ` +"=== LEVEL 1 (1) === +item1 + +Finished. Circular Dependencies printed: 0" +`; + +exports[`content-item tree command handler tests should print an error when invalid json is found 2`] = `"Couldn't read content item at '/Users/rhys/Documents/amplience/dc-cli/temp/tree/invalud/repo1/badfile.json': SyntaxError: Unexpected token o in JSON at position 1"`; + +exports[`content-item tree command handler tests should print multiple disjoint trees of content items 1`] = ` +"=== LEVEL 3 (1) === +item1 +├─ item2 +│ └─ item4 +└─ item3 + +=== LEVEL 2 (2) === +item6 +└─ item5 + +=== LEVEL 1 (4) === +item7 + +Finished. Circular Dependencies printed: 0" +`; + +exports[`content-item tree command handler tests should print nothing if no content is present 1`] = `"Finished. Circular Dependencies printed: 0"`; diff --git a/src/commands/content-item/tree.spec.ts b/src/commands/content-item/tree.spec.ts new file mode 100644 index 00000000..62e85e8d --- /dev/null +++ b/src/commands/content-item/tree.spec.ts @@ -0,0 +1,309 @@ +// Copy tests are rather simple since they most of the work is done by import/export. +// Unique features are revert, throwing when parameters are wrong/missing, +// and forwarding input parameters to both import and export. + +import { builder, command, handler, firstSecondThird, fillWhitespace, LOG_FILENAME } from './tree'; +import Yargs from 'yargs/yargs'; + +import { writeFile } from 'fs'; +import { join } from 'path'; +import { promisify } from 'util'; + +import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import rmdir from 'rimraf'; +import { getDefaultLogPath } from '../../common/log-helpers'; + +import { ItemTemplate } from '../../common/dc-management-sdk-js/mock-content'; +import { dependsOn } from '../../commands/content-item/__mocks__/dependant-content-helper'; +import { ContentItem, Status } from 'dc-management-sdk-js'; + +jest.mock('../../services/dynamic-content-client-factory'); +jest.mock('../../common/log-helpers'); + +const consoleLogSpy = jest.spyOn(console, 'log'); +const consoleErrorSpy = jest.spyOn(console, 'error'); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +describe('content-item tree command', () => { + afterEach((): void => { + jest.resetAllMocks(); + }); + + it('should command should defined', function() { + expect(command).toEqual('tree '); + }); + + describe('builder tests', function() { + it('should configure yargs', function() { + const argv = Yargs(process.argv.slice(2)); + const spyPositional = jest.spyOn(argv, 'positional').mockReturnThis(); + + builder(argv); + + expect(spyPositional).toHaveBeenCalledWith('dir', { + type: 'string', + describe: 'Path to the content items to build a tree from. Should be in the same format as an export.' + }); + }); + }); + + describe('firstSecondThird tests', function() { + it('should return 0 for the first item in a list, above size 1', () => { + expect(firstSecondThird(0, 2)).toEqual(0); + expect(firstSecondThird(0, 3)).toEqual(0); + expect(firstSecondThird(0, 4)).toEqual(0); + }); + + it('should return 2 for the last item in a list', () => { + expect(firstSecondThird(0, 1)).toEqual(2); + expect(firstSecondThird(1, 2)).toEqual(2); + expect(firstSecondThird(2, 3)).toEqual(2); + expect(firstSecondThird(3, 4)).toEqual(2); + }); + + it('should return 1 for any middle item in a list, above size 2', () => { + expect(firstSecondThird(1, 3)).toEqual(1); + expect(firstSecondThird(1, 4)).toEqual(1); + expect(firstSecondThird(2, 4)).toEqual(1); + }); + }); + + describe('fillWhitespace tests', function() { + it('should fill space characters only after the original string with the given character up to the length', () => { + expect(fillWhitespace(' ', ' ', '-', 4)).toEqual(' '); + expect(fillWhitespace(' ', ' ', '-', 8)).toEqual(' ----'); + }); + + it('should inherit non-space characters from the current string', () => { + expect(fillWhitespace(' ', ' char', '-', 4)).toEqual(' char'); + expect(fillWhitespace(' ', ' char', '-', 8)).toEqual(' char'); + expect(fillWhitespace(' ', ' c a ', '-', 8)).toEqual(' c-a-'); + expect(fillWhitespace(' ', ' h r', '-', 8)).toEqual(' -h-r'); + expect(fillWhitespace(' ', ' ', '-', 8)).toEqual(' ----'); + }); + }); + + describe('handler tests', function() { + const yargArgs = { + $0: 'test', + _: ['test'], + json: true + }; + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + beforeAll(async () => { + await rimraf('temp/tree/'); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const itemFromTemplate = (template: ItemTemplate): ContentItem => { + const item = new ContentItem({ + label: template.label, + status: template.status || Status.ACTIVE, + id: template.id || template.label, + folderId: null, + version: template.version, + lastPublishedVersion: template.lastPublishedVersion, + locale: template.locale, + body: { + ...template.body, + _meta: { + schema: template.typeSchemaUri + } + }, + + // Not meant to be here, but used later for sorting by repository + repoId: template.repoId + }); + + return item; + }; + + const createContent = async (basePath: string, template: ItemTemplate): Promise => { + await promisify(writeFile)( + join(basePath, template.label + '.json'), + JSON.stringify(itemFromTemplate(template).toJSON()) + ); + }; + + it('should use getDefaultLogPath for LOG_FILENAME with process.platform as default', function() { + LOG_FILENAME(); + + expect(getDefaultLogPath).toHaveBeenCalledWith('item', 'tree', process.platform); + }); + + it('should print nothing if no content is present', async () => { + await ensureDirectoryExists('temp/tree/empty'); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/empty' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should print a single content item by itself', async () => { + const basePath = 'temp/tree/single/repo1'; + await ensureDirectoryExists(basePath); + + await promisify(writeFile)(join(basePath, 'dummyFile.txt'), 'ignored'); + + await createContent(basePath, { + label: 'item1', + id: 'id1', + repoId: 'repo1', + body: {}, + typeSchemaUri: 'http://type.com' + }); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/single' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should print a tree of content items', async () => { + const basePath = 'temp/tree/multiple/repo1'; + await ensureDirectoryExists(basePath); + + const shared = { typeSchemaUri: 'http://type.com', repoId: 'repo1' }; + + await createContent(basePath, { label: 'item1', id: 'id1', body: dependsOn(['id2', 'id3']), ...shared }); + await createContent(basePath, { label: 'item2', id: 'id2', body: dependsOn(['id4', 'id6']), ...shared }); + await createContent(basePath, { label: 'item3', id: 'id3', body: {}, ...shared }); + await createContent(basePath, { label: 'item4', id: 'id4', body: {}, ...shared }); + await createContent(basePath, { label: 'item5', id: 'id5', body: {}, ...shared }); + await createContent(basePath, { label: 'item6', id: 'id6', body: dependsOn(['id5']), ...shared }); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/multiple' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should print multiple disjoint trees of content items', async () => { + const basePath = 'temp/tree/disjoint/repo1'; + await ensureDirectoryExists(basePath); + + const shared = { typeSchemaUri: 'http://type.com', repoId: 'repo1' }; + + await createContent(basePath, { label: 'item1', id: 'id1', body: dependsOn(['id2', 'id3']), ...shared }); + await createContent(basePath, { label: 'item2', id: 'id2', body: dependsOn(['id4']), ...shared }); + await createContent(basePath, { label: 'item3', id: 'id3', body: {}, ...shared }); + await createContent(basePath, { label: 'item4', id: 'id4', body: {}, ...shared }); + + await createContent(basePath, { label: 'item5', id: 'id5', body: {}, ...shared }); + await createContent(basePath, { label: 'item6', id: 'id6', body: dependsOn(['id5']), ...shared }); + + await createContent(basePath, { label: 'item7', id: 'id7', body: {}, ...shared }); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/disjoint' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should detect and print circular dependencies with a double line indicator', async () => { + const basePath = 'temp/tree/disjoint/repo1'; + await ensureDirectoryExists(basePath); + + const shared = { typeSchemaUri: 'http://type.com', repoId: 'repo1' }; + + await createContent(basePath, { label: 'item1', id: 'id1', body: dependsOn(['id2', 'id3']), ...shared }); + await createContent(basePath, { label: 'item2', id: 'id2', body: dependsOn(['id4']), ...shared }); + await createContent(basePath, { label: 'item3', id: 'id3', body: {}, ...shared }); + await createContent(basePath, { label: 'item4', id: 'id4', body: dependsOn(['id1']), ...shared }); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/disjoint' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should detect intertwined circular dependencies with multiple lines with different position', async () => { + const basePath = 'temp/tree/intertwine/repo1'; + await ensureDirectoryExists(basePath); + + const shared = { typeSchemaUri: 'http://type.com', repoId: 'repo1' }; + + await createContent(basePath, { label: 'item1', id: 'id1', body: dependsOn(['id2']), ...shared }); + await createContent(basePath, { label: 'item2', id: 'id2', body: dependsOn(['id3']), ...shared }); + await createContent(basePath, { label: 'item3', id: 'id3', body: dependsOn(['id2', 'id4']), ...shared }); + await createContent(basePath, { label: 'item4', id: 'id4', body: dependsOn(['id1', 'id5']), ...shared }); + + await createContent(basePath, { label: 'item5', id: 'id5', body: dependsOn(['id6']), ...shared }); + await createContent(basePath, { label: 'item6', id: 'id6', body: dependsOn(['id5']), ...shared }); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/intertwine' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should print an error when invalid json is found', async () => { + const basePath = 'temp/tree/invalud/repo1'; + await ensureDirectoryExists(basePath); + + await createContent(basePath, { + label: 'item1', + id: 'id1', + repoId: 'repo1', + body: {}, + typeSchemaUri: 'http://type.com' + }); + await promisify(writeFile)(join(basePath, 'badfile.json'), 'not json'); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/invalud' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + expect(consoleErrorSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + }); +}); diff --git a/src/commands/content-item/tree.ts b/src/commands/content-item/tree.ts index a3b006f1..85bad30a 100644 --- a/src/commands/content-item/tree.ts +++ b/src/commands/content-item/tree.ts @@ -13,10 +13,6 @@ import { } from '../../common/content-item/content-dependancy-tree'; import { ContentMapping } from '../../common/content-item/content-mapping'; -export function getTempFolder(name: string, platform: string = process.platform): string { - return join(process.env[platform == 'win32' ? 'USERPROFILE' : 'HOME'] || __dirname, '.amplience', `copy-${name}/`); -} - export const command = 'tree '; export const desc = 'Print a content dependency tree from content in the given folder.'; @@ -27,7 +23,7 @@ export const LOG_FILENAME = (platform: string = process.platform): string => export const builder = (yargs: Argv): void => { yargs.positional('dir', { type: 'string', - describe: 'Path to the content items to build a tree from.. Should be in the same format as an export.' + describe: 'Path to the content items to build a tree from. Should be in the same format as an export.' }); }; @@ -35,9 +31,11 @@ interface TreeOptions { dir: string; } -const traverseRecursive = async (path: string, action: (path: string) => Promise): Promise => { +export const traverseRecursive = async (path: string, action: (path: string) => Promise): Promise => { const dir = await promisify(readdir)(path); + dir.sort(); + await Promise.all( dir.map(async (contained: string) => { contained = join(path, contained); @@ -47,54 +45,33 @@ const traverseRecursive = async (path: string, action: (path: string) => Promise ); }; -const prepareContentForTree = async ( - repos: { basePath: string; repo: ContentRepository }[], +export const prepareContentForTree = async ( + repo: { basePath: string; repo: ContentRepository }, argv: Arguments -): Promise => { +): Promise => { const contentItems: RepositoryContentItem[] = []; const schemaNames = new Set(); - for (let i = 0; i < repos.length; i++) { - const repo = repos[i].repo; - - await traverseRecursive(resolve(repos[i].basePath), async path => { - // Is this valid content? Must have extension .json to be considered, for a start. - if (extname(path) !== '.json') { - return; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let contentJSON: any; - try { - const contentText = await promisify(readFile)(path, { encoding: 'utf8' }); - contentJSON = JSON.parse(contentText); - } catch (e) { - console.error(`Couldn't read content item at '${path}': ${e.toString()}`); - return; - } + await traverseRecursive(resolve(repo.basePath), async path => { + // Is this valid content? Must have extension .json to be considered, for a start. + if (extname(path) !== '.json') { + return; + } - // Get the folder id via the mapping. - - // Only filter relevant information - for example status and previous content repo are not useful. - const filteredContent = { - id: contentJSON.id, - label: contentJSON.label, - locale: contentJSON.locale, - body: contentJSON.body, - deliveryId: contentJSON.deliveryId == contentJSON.Id || argv.excludeKeys ? undefined : contentJSON.deliveryId, - folderId: null, - publish: contentJSON.lastPublishedVersion != null - }; - - if (argv.excludeKeys) { - delete filteredContent.body._meta.deliveryKey; - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let contentJSON: any; + try { + const contentText = await promisify(readFile)(path, { encoding: 'utf8' }); + contentJSON = JSON.parse(contentText); + } catch (e) { + console.error(`Couldn't read content item at '${path}': ${e.toString()}`); + return; + } - schemaNames.add(contentJSON.body._meta.schema); + schemaNames.add(contentJSON.body._meta.schema); - contentItems.push({ repo: repo, content: new ContentItem(filteredContent) }); - }); - } + contentItems.push({ repo: repo.repo, content: new ContentItem(contentJSON) }); + }); return new ContentDependancyTree(contentItems, new ContentMapping()); }; @@ -105,15 +82,15 @@ interface ParentReference { line: number; } -const firstSecondThird = (index: number, total: number): number => { - return index === 0 ? 0 : index == total - 1 ? 2 : 1; +export const firstSecondThird = (index: number, total: number): number => { + return index == total - 1 ? 2 : index === 0 ? 0 : 1; }; const fstPipes = ['├', '├', '└']; const circularPipes = ['╗', '║', '╝']; const circularLine = '═'; -const printDependency = ( +export const printDependency = ( item: ItemContentDependancies, evaluated: Set, lines: string[], @@ -144,22 +121,24 @@ const printDependency = ( const filteredItems = item.dependancies.filter(dep => dep.resolved); filteredItems.forEach((dep, index) => { - if (dep.resolved) { - const subFst = firstSecondThird(index, filteredItems.length); - const subPrefix = depth == -1 ? '' : fst === 2 ? ' ' : '│ '; - printDependency(dep.resolved, evaluated, lines, circularLinks, [...evalThis], subFst, prefix + subPrefix); - } + const subFst = firstSecondThird(index, filteredItems.length); + const subPrefix = depth == -1 ? '' : fst === 2 ? ' ' : '│ '; + printDependency( + dep.resolved as ItemContentDependancies, + evaluated, + lines, + circularLinks, + [...evalThis], + subFst, + prefix + subPrefix + ); }); return true; }; -const fillWhitespace = (original: string, current: string, char: string, targetLength: number): string => { - if (current.length < original.length + 1) { - current += ' '; - } - - let position = original.length + 1; - let repeats = targetLength - (original.length + 1); +export const fillWhitespace = (original: string, current: string, char: string, targetLength: number): string => { + let position = original.length; + let repeats = targetLength - original.length; // Replace existing whitespace characters while (position < current.length && repeats > 0) { @@ -178,14 +157,15 @@ const fillWhitespace = (original: string, current: string, char: string, targetL return current; }; -const printTree = (item: ItemContentDependancies, evaluated: Set): boolean => { - const lines: string[] = []; +export const printTree = (item: ItemContentDependancies, evaluated: Set): boolean => { + let lines: string[] = []; const circularLinks: CircularLink[] = []; const result = printDependency(item, evaluated, lines, circularLinks, [], 0, ''); if (!result) return false; + lines = lines.map(line => line + ' '); const modifiedLines = [...lines]; // Render circular references. @@ -227,25 +207,12 @@ const printTree = (item: ItemContentDependancies, evaluated: Set): Promise => { const dir = argv.dir; - const baseDirContents = await promisify(readdir)(dir); - const importRepos: { basePath: string; repo: ContentRepository }[] = []; - for (let i = 0; i < baseDirContents.length; i++) { - const name = baseDirContents[i]; - const path = join(dir, name); - const status = await promisify(lstat)(path); - if (status.isDirectory()) { - importRepos.push({ basePath: path, repo: new ContentRepository() }); - } - } - - const tree = await prepareContentForTree(importRepos, argv); + const tree = await prepareContentForTree({ basePath: dir, repo: new ContentRepository() }, argv); // Print the items in the tree. // Keep a set of all items that have already been printed. // Starting at the highest level, print all dependencies on the tree. - if (tree == null) return; - const evaluated = new Set(); for (let i = tree.levels.length - 1; i >= 0; i--) { @@ -257,13 +224,17 @@ export const handler = async (argv: Arguments { - if (printTree(item, evaluated)) { - topLevelPrints++; - } - }); + + if (tree.circularLinks.length > 0) { + console.log(`=== CIRCULAR (${tree.circularLinks.length}) ===`); + + tree.circularLinks.forEach(item => { + if (printTree(item, evaluated)) { + topLevelPrints++; + } + }); + } console.log(`Finished. Circular Dependencies printed: ${topLevelPrints}`); }; diff --git a/src/common/content-item/content-dependancy-tree.ts b/src/common/content-item/content-dependancy-tree.ts index 0f7afac2..fda52e3d 100644 --- a/src/common/content-item/content-dependancy-tree.ts +++ b/src/common/content-item/content-dependancy-tree.ts @@ -211,8 +211,13 @@ export class ContentDependancyTree { ): CircularDependencyStage { let selfLoop = false; let intertwinedLoop = false; + let isParent = false; const seenBefore = new Set(); + if (top.owner.content.label == 'item5') { + console.log('test'); + } + const traverse = ( top: ItemContentDependancies, item: ItemContentDependancies | undefined, @@ -234,8 +239,15 @@ export class ContentDependancyTree { if (!intertwined) { // Does it loop back to the parent? const storedSelfLoop = selfLoop; - intertwinedLoop = traverse(item, item, 0, [top], new Set(), true); + const childIntertwined = traverse(item, item, 0, [top], new Set(), true); selfLoop = storedSelfLoop; + + if (childIntertwined) { + intertwinedLoop = true; + } else { + // We're the parent of a non-intertwined circular loop. + isParent = true; + } } hasCircular = true; @@ -248,7 +260,7 @@ export class ContentDependancyTree { seenBefore.add(item); item.dependancies.forEach(dep => { - hasCircular = traverse(top, dep.resolved, depth++, unresolved, seenBefore, intertwined) || hasCircular; + hasCircular = traverse(top, dep.resolved, depth + 1, unresolved, seenBefore, intertwined) || hasCircular; }); return hasCircular; @@ -256,9 +268,13 @@ export class ContentDependancyTree { const hasCircular = traverse(top, top, 0, unresolved, seenBefore, false); + if (top.owner.content.label == 'item5') { + console.log('test'); + } + if (hasCircular) { if (intertwinedLoop) { - if (selfLoop) { + if (selfLoop && !isParent) { return CircularDependencyStage.Intertwined; } else { return CircularDependencyStage.Parent; From 05cad094104b0d335f0d68172f69f20a86490bf4 Mon Sep 17 00:00:00 2001 From: Rhys Date: Fri, 9 Apr 2021 11:49:23 +0100 Subject: [PATCH 05/12] fix(content-item): fix load order for tree --- src/commands/content-item/tree.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/commands/content-item/tree.ts b/src/commands/content-item/tree.ts index 85bad30a..1fcc4a1d 100644 --- a/src/commands/content-item/tree.ts +++ b/src/commands/content-item/tree.ts @@ -36,13 +36,16 @@ export const traverseRecursive = async (path: string, action: (path: string) => dir.sort(); - await Promise.all( - dir.map(async (contained: string) => { - contained = join(path, contained); - const stat = await promisify(lstat)(contained); - return await (stat.isDirectory() ? traverseRecursive(contained, action) : action(contained)); - }) - ); + for (let i = 0; i < dir.length; i++) { + let contained = dir[i]; + contained = join(path, contained); + const stat = await promisify(lstat)(contained); + if (stat.isDirectory()) { + await traverseRecursive(contained, action); + } else { + await action(contained); + } + } }; export const prepareContentForTree = async ( From fbf1584141ae55c6dee356b5a96dd56987b1793a Mon Sep 17 00:00:00 2001 From: Rhys Date: Fri, 9 Apr 2021 16:12:05 +0100 Subject: [PATCH 06/12] fix(content-item): remove testing logs --- src/commands/content-item/__snapshots__/tree.spec.ts.snap | 4 +--- src/common/content-item/content-dependancy-tree.ts | 8 -------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/commands/content-item/__snapshots__/tree.spec.ts.snap b/src/commands/content-item/__snapshots__/tree.spec.ts.snap index eec692aa..302c916c 100644 --- a/src/commands/content-item/__snapshots__/tree.spec.ts.snap +++ b/src/commands/content-item/__snapshots__/tree.spec.ts.snap @@ -21,9 +21,7 @@ Finished. Circular Dependencies printed: 1" `; exports[`content-item tree command handler tests should detect intertwined circular dependencies with multiple lines with different position 1`] = ` -"test -test -=== CIRCULAR (6) === +"=== CIRCULAR (6) === item5 ══════════════╗ └─ item6 ║ └─ *** (item5) ══╝ diff --git a/src/common/content-item/content-dependancy-tree.ts b/src/common/content-item/content-dependancy-tree.ts index fda52e3d..73a90e74 100644 --- a/src/common/content-item/content-dependancy-tree.ts +++ b/src/common/content-item/content-dependancy-tree.ts @@ -214,10 +214,6 @@ export class ContentDependancyTree { let isParent = false; const seenBefore = new Set(); - if (top.owner.content.label == 'item5') { - console.log('test'); - } - const traverse = ( top: ItemContentDependancies, item: ItemContentDependancies | undefined, @@ -268,10 +264,6 @@ export class ContentDependancyTree { const hasCircular = traverse(top, top, 0, unresolved, seenBefore, false); - if (top.owner.content.label == 'item5') { - console.log('test'); - } - if (hasCircular) { if (intertwinedLoop) { if (selfLoop && !isParent) { From b31e54ca4303ffec6b1a05eb63c69add5b3e68e8 Mon Sep 17 00:00:00 2001 From: Rhys Date: Mon, 12 Apr 2021 02:18:25 +0100 Subject: [PATCH 07/12] feat(config): allow using configure command with a custom file --- src/commands/configure.spec.ts | 28 +++++++++++++++++++++++----- src/commands/configure.ts | 10 +++++++--- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/commands/configure.spec.ts b/src/commands/configure.spec.ts index 2591c163..ffac620d 100644 --- a/src/commands/configure.spec.ts +++ b/src/commands/configure.spec.ts @@ -30,7 +30,7 @@ describe('configure command', function() { jest.spyOn(fs, 'mkdirSync').mockReturnValueOnce(undefined); jest.spyOn(fs, 'writeFileSync').mockReturnValueOnce(undefined); - handler({ ...yargArgs, ...configFixture }); + handler({ ...yargArgs, ...configFixture, config: CONFIG_FILENAME() }); expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/\.amplience$/)); expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringMatching(/\.amplience$/), { recursive: true }); @@ -48,7 +48,7 @@ describe('configure command', function() { jest.spyOn(fs, 'mkdirSync'); jest.spyOn(fs, 'writeFileSync').mockReturnValueOnce(undefined); - handler({ ...yargArgs, ...configFixture }); + handler({ ...yargArgs, ...configFixture, config: CONFIG_FILENAME() }); expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/\.amplience$/)); expect(fs.mkdirSync).not.toHaveBeenCalled(); @@ -58,6 +58,24 @@ describe('configure command', function() { ); }); + it('should write a config file and use the specified file', () => { + jest + .spyOn(fs, 'existsSync') + .mockReturnValueOnce(false) + .mockReturnValueOnce(false); + jest.spyOn(fs, 'mkdirSync').mockReturnValueOnce(undefined); + jest.spyOn(fs, 'writeFileSync').mockReturnValueOnce(undefined); + + handler({ ...yargArgs, ...configFixture, config: 'subdirectory/custom-config.json' }); + + expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/subdirectory$/)); + expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringMatching(/subdirectory$/), { recursive: true }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringMatching(new RegExp('subdirectory/custom-config.json$')), + JSON.stringify(configFixture) + ); + }); + it('should report an error if its not possible to create the .amplience dir', () => { jest .spyOn(fs, 'existsSync') @@ -69,7 +87,7 @@ describe('configure command', function() { jest.spyOn(fs, 'writeFileSync').mockReturnValueOnce(undefined); expect(() => { - handler({ ...yargArgs, ...configFixture }); + handler({ ...yargArgs, ...configFixture, config: CONFIG_FILENAME() }); }).toThrowError(/^Unable to create dir ".*". Reason: .*/); expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/\.amplience$/)); @@ -88,7 +106,7 @@ describe('configure command', function() { }); expect(() => { - handler({ ...yargArgs, ...configFixture }); + handler({ ...yargArgs, ...configFixture, config: CONFIG_FILENAME() }); }).toThrowError(/^Unable to write config file ".*". Reason: .*/); expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/\.amplience$/)); @@ -104,7 +122,7 @@ describe('configure command', function() { jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(JSON.stringify(configFixture)); jest.spyOn(fs, 'writeFileSync'); - handler({ ...yargArgs, ...configFixture }); + handler({ ...yargArgs, ...configFixture, config: CONFIG_FILENAME() }); expect(fs.writeFileSync).not.toHaveBeenCalled(); }); diff --git a/src/commands/configure.ts b/src/commands/configure.ts index f8546ba8..b59749aa 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -17,6 +17,10 @@ export type ConfigurationParameters = { hubId: string; }; +type ConfigArgument = { + config: string; +}; + export const configureCommandOptions: CommandOptions = { clientId: { type: 'string', demandOption: true }, clientSecret: { type: 'string', demandOption: true }, @@ -43,14 +47,14 @@ const writeConfigFile = (configFile: string, parameters: ConfigurationParameters export const readConfigFile = (configFile: string): object => fs.existsSync(configFile) ? JSON.parse(fs.readFileSync(configFile, 'utf-8')) : {}; -export const handler = (argv: Arguments): void => { +export const handler = (argv: Arguments): void => { const { clientId, clientSecret, hubId } = argv; - const storedConfig = readConfigFile(CONFIG_FILENAME()); + const storedConfig = readConfigFile(argv.config); if (isEqual(storedConfig, { clientId, clientSecret, hubId })) { console.log('Config file up-to-date. Please use `--help` for command usage.'); return; } - writeConfigFile(CONFIG_FILENAME(), { clientId, clientSecret, hubId }); + writeConfigFile(argv.config, { clientId, clientSecret, hubId }); console.log('Config file updated.'); }; From 81d12e0da376a5c790d5e658ce00c295c0c0cbe7 Mon Sep 17 00:00:00 2001 From: Rhys Date: Wed, 21 Apr 2021 15:18:12 +0100 Subject: [PATCH 08/12] fix: fix references setting null when already imported --- src/commands/content-item/import.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/content-item/import.ts b/src/commands/content-item/import.ts index acb23b09..5bfdf193 100644 --- a/src/commands/content-item/import.ts +++ b/src/commands/content-item/import.ts @@ -791,7 +791,7 @@ const importTree = async ( const content = item.owner.content; item.dependancies.forEach(dep => { - rewriteDependancy(dep, mapping, true); + rewriteDependancy(dep, mapping, pass === 0); }); const originalId = content.id; From 5d4f519c6a1e4b9d9dd6fec005a19660e35cdc53 Mon Sep 17 00:00:00 2001 From: Rhys Date: Wed, 2 Jun 2021 00:12:59 +0100 Subject: [PATCH 09/12] refactor: address feedback 1 --- src/commands/content-item/import.ts | 8 +++++--- src/commands/content-item/tree.ts | 11 +++++------ src/common/content-item/content-dependancy-tree.ts | 14 +++++++------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/commands/content-item/import.ts b/src/commands/content-item/import.ts index 5bfdf193..6e37d4da 100644 --- a/src/commands/content-item/import.ts +++ b/src/commands/content-item/import.ts @@ -26,6 +26,7 @@ import { ItemContentDependancies, ContentDependancyInfo } from '../../common/content-item/content-dependancy-tree'; +import { Body } from '../../common/content-item/body'; import { AmplienceSchemaValidator, defaultSchemaLookup } from '../../common/content-item/amplience-schema-validator'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; @@ -684,11 +685,12 @@ const rewriteDependancy = (dep: ContentDependancyInfo, mapping: ContentMapping, if (dep.dependancy._meta.schema === '_hierarchy') { dep.owner.content.body._meta.hierarchy.parentId = id; - } else { + } else if (dep.parent) { + const parent = dep.parent as Body; if (id == null) { - delete dep.parent[dep.index]; + delete parent[dep.index]; } else { - dep.parent[dep.index] = dep.dependancy; + parent[dep.index] = dep.dependancy; dep.dependancy.id = id; } } diff --git a/src/commands/content-item/tree.ts b/src/commands/content-item/tree.ts index 1fcc4a1d..ef6f5a47 100644 --- a/src/commands/content-item/tree.ts +++ b/src/commands/content-item/tree.ts @@ -49,8 +49,7 @@ export const traverseRecursive = async (path: string, action: (path: string) => }; export const prepareContentForTree = async ( - repo: { basePath: string; repo: ContentRepository }, - argv: Arguments + repo: { basePath: string; repo: ContentRepository } ): Promise => { const contentItems: RepositoryContentItem[] = []; const schemaNames = new Set(); @@ -93,7 +92,7 @@ const fstPipes = ['├', '├', '└']; const circularPipes = ['╗', '║', '╝']; const circularLine = '═'; -export const printDependency = ( +export const addDependency = ( item: ItemContentDependancies, evaluated: Set, lines: string[], @@ -126,7 +125,7 @@ export const printDependency = ( filteredItems.forEach((dep, index) => { const subFst = firstSecondThird(index, filteredItems.length); const subPrefix = depth == -1 ? '' : fst === 2 ? ' ' : '│ '; - printDependency( + addDependency( dep.resolved as ItemContentDependancies, evaluated, lines, @@ -164,7 +163,7 @@ export const printTree = (item: ItemContentDependancies, evaluated: Set): Promise => { const dir = argv.dir; - const tree = await prepareContentForTree({ basePath: dir, repo: new ContentRepository() }, argv); + const tree = await prepareContentForTree({ basePath: dir, repo: new ContentRepository() }); // Print the items in the tree. // Keep a set of all items that have already been printed. diff --git a/src/common/content-item/content-dependancy-tree.ts b/src/common/content-item/content-dependancy-tree.ts index 73a90e74..4ddb825a 100644 --- a/src/common/content-item/content-dependancy-tree.ts +++ b/src/common/content-item/content-dependancy-tree.ts @@ -13,7 +13,7 @@ export interface RepositoryContentItem { } export interface ContentDependancy { - _meta: { schema: DependancyContentTypeSchema }; + _meta: { schema: DependancyContentTypeSchema; name: string }; contentType: string; id: string | undefined; } @@ -23,8 +23,7 @@ export interface ContentDependancyInfo { dependancy: ContentDependancy; owner: RepositoryContentItem; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - parent: any; + parent?: RecursiveSearchStep; index: string | number; } @@ -149,7 +148,7 @@ export class ContentDependancyTree { item: RepositoryContentItem, body: RecursiveSearchStep, result: ContentDependancyInfo[], - parent: RecursiveSearchStep | null, + parent: RecursiveSearchStep | undefined, index: string | number ): void { if (Array.isArray(body)) { @@ -282,20 +281,21 @@ export class ContentDependancyTree { private identifyContentDependancies(items: RepositoryContentItem[]): ItemContentDependancies[] { return items.map(item => { const result: ContentDependancyInfo[] = []; - this.searchObjectForContentDependancies(item, item.content.body, result, null, 0); + this.searchObjectForContentDependancies(item, item.content.body, result, undefined, 0); // Hierarchy parent is also a dependancy. if (item.content.body._meta.hierarchy && item.content.body._meta.hierarchy.parentId) { result.push({ dependancy: { _meta: { - schema: '_hierarchy' + schema: '_hierarchy', + name: '_hierarchy' }, id: item.content.body._meta.hierarchy.parentId, contentType: '' }, owner: item, - parent: null, + parent: undefined, index: 0 }); } From bffd2bf795c9d27bb4506552956af326cd5b0df3 Mon Sep 17 00:00:00 2001 From: Rhys Date: Wed, 2 Jun 2021 09:56:04 +0100 Subject: [PATCH 10/12] style: use tree builder class for managing state --- .../__mocks__/dependant-content-helper.ts | 3 +- src/commands/content-item/tree.ts | 92 +++++++++---------- 2 files changed, 44 insertions(+), 51 deletions(-) diff --git a/src/commands/content-item/__mocks__/dependant-content-helper.ts b/src/commands/content-item/__mocks__/dependant-content-helper.ts index 23b1979e..c1dd3277 100644 --- a/src/commands/content-item/__mocks__/dependant-content-helper.ts +++ b/src/commands/content-item/__mocks__/dependant-content-helper.ts @@ -3,7 +3,8 @@ import { ContentDependancy } from '../../../common/content-item/content-dependan function dependancy(id: string): ContentDependancy { return { _meta: { - schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-link' + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-link', + name: 'content-link' }, contentType: 'https://dev-solutions.s3.amazonaws.com/DynamicContentTypes/Accelerators/blog.json', id: id diff --git a/src/commands/content-item/tree.ts b/src/commands/content-item/tree.ts index ef6f5a47..a252a97d 100644 --- a/src/commands/content-item/tree.ts +++ b/src/commands/content-item/tree.ts @@ -48,9 +48,10 @@ export const traverseRecursive = async (path: string, action: (path: string) => } }; -export const prepareContentForTree = async ( - repo: { basePath: string; repo: ContentRepository } -): Promise => { +export const prepareContentForTree = async (repo: { + basePath: string; + repo: ContentRepository; +}): Promise => { const contentItems: RepositoryContentItem[] = []; const schemaNames = new Set(); @@ -92,51 +93,42 @@ const fstPipes = ['├', '├', '└']; const circularPipes = ['╗', '║', '╝']; const circularLine = '═'; -export const addDependency = ( - item: ItemContentDependancies, - evaluated: Set, - lines: string[], - circularLinks: CircularLink[], - evalThis: ParentReference[], - fst: number, - prefix: string -): boolean => { - const depth = evalThis.length - 1; - const pipe = depth < 0 ? '' : fstPipes[fst] + '─ '; - - const circularMatch = evalThis.find(parent => parent.item == item); - if (circularMatch) { - lines.push(`${prefix}${pipe}*** (${item.owner.content.label})`); - circularLinks.push([circularMatch.line, lines.length - 1]); - return false; - } else if (evaluated.has(item)) { - if (depth > -1) { - lines.push(`${prefix}${pipe}(${item.owner.content.label})`); +export class TreeBuilder { + lines: string[] = []; + circularLinks: CircularLink[] = []; + + constructor(public evaluated: Set) {} + + addDependency(item: ItemContentDependancies, evalThis: ParentReference[], fst: number, prefix: string): boolean { + const depth = evalThis.length - 1; + const pipe = depth < 0 ? '' : fstPipes[fst] + '─ '; + + const circularMatch = evalThis.find(parent => parent.item == item); + if (circularMatch) { + this.lines.push(`${prefix}${pipe}*** (${item.owner.content.label})`); + this.circularLinks.push([circularMatch.line, this.lines.length - 1]); + return false; + } else if (this.evaluated.has(item)) { + if (depth > -1) { + this.lines.push(`${prefix}${pipe}(${item.owner.content.label})`); + } + return false; + } else { + this.lines.push(`${prefix}${pipe}${item.owner.content.label}`); } - return false; - } else { - lines.push(`${prefix}${pipe}${item.owner.content.label}`); - } - evalThis.push({ item, line: lines.length - 1 }); - evaluated.add(item); - - const filteredItems = item.dependancies.filter(dep => dep.resolved); - filteredItems.forEach((dep, index) => { - const subFst = firstSecondThird(index, filteredItems.length); - const subPrefix = depth == -1 ? '' : fst === 2 ? ' ' : '│ '; - addDependency( - dep.resolved as ItemContentDependancies, - evaluated, - lines, - circularLinks, - [...evalThis], - subFst, - prefix + subPrefix - ); - }); - return true; -}; + evalThis.push({ item, line: this.lines.length - 1 }); + this.evaluated.add(item); + + const filteredItems = item.dependancies.filter(dep => dep.resolved); + filteredItems.forEach((dep, index) => { + const subFst = firstSecondThird(index, filteredItems.length); + const subPrefix = depth == -1 ? '' : fst === 2 ? ' ' : '│ '; + this.addDependency(dep.resolved as ItemContentDependancies, [...evalThis], subFst, prefix + subPrefix); + }); + return true; + } +} export const fillWhitespace = (original: string, current: string, char: string, targetLength: number): string => { let position = original.length; @@ -160,14 +152,14 @@ export const fillWhitespace = (original: string, current: string, char: string, }; export const printTree = (item: ItemContentDependancies, evaluated: Set): boolean => { - let lines: string[] = []; - const circularLinks: CircularLink[] = []; + const builder = new TreeBuilder(evaluated); - const result = addDependency(item, evaluated, lines, circularLinks, [], 0, ''); + const result = builder.addDependency(item, [], 0, ''); if (!result) return false; - lines = lines.map(line => line + ' '); + const circularLinks = builder.circularLinks; + const lines = builder.lines.map(line => line + ' '); const modifiedLines = [...lines]; // Render circular references. From 60f96162149058b061644c3a19d592b628d35884 Mon Sep 17 00:00:00 2001 From: Rhys Date: Wed, 16 Jun 2021 16:27:19 +0100 Subject: [PATCH 11/12] refactor: improve circular link type definition --- src/commands/content-item/tree.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/content-item/tree.ts b/src/commands/content-item/tree.ts index a252a97d..79380bc9 100644 --- a/src/commands/content-item/tree.ts +++ b/src/commands/content-item/tree.ts @@ -79,7 +79,9 @@ export const prepareContentForTree = async (repo: { return new ContentDependancyTree(contentItems, new ContentMapping()); }; -type CircularLink = [number, number]; +type LineIndexFrom = number; +type LineIndexTo = number; +type CircularLink = [LineIndexFrom, LineIndexTo]; interface ParentReference { item: ItemContentDependancies; line: number; From 48720d78b229d1144f4d68efd2bce379fb528f03 Mon Sep 17 00:00:00 2001 From: Rhys Date: Thu, 17 Jun 2021 12:25:00 +0100 Subject: [PATCH 12/12] test: fix broken snapshots in tree tests --- src/commands/content-item/__snapshots__/tree.spec.ts.snap | 2 -- src/commands/content-item/tree.spec.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/commands/content-item/__snapshots__/tree.spec.ts.snap b/src/commands/content-item/__snapshots__/tree.spec.ts.snap index 302c916c..0357a9bc 100644 --- a/src/commands/content-item/__snapshots__/tree.spec.ts.snap +++ b/src/commands/content-item/__snapshots__/tree.spec.ts.snap @@ -66,8 +66,6 @@ item1 Finished. Circular Dependencies printed: 0" `; -exports[`content-item tree command handler tests should print an error when invalid json is found 2`] = `"Couldn't read content item at '/Users/rhys/Documents/amplience/dc-cli/temp/tree/invalud/repo1/badfile.json': SyntaxError: Unexpected token o in JSON at position 1"`; - exports[`content-item tree command handler tests should print multiple disjoint trees of content items 1`] = ` "=== LEVEL 3 (1) === item1 diff --git a/src/commands/content-item/tree.spec.ts b/src/commands/content-item/tree.spec.ts index 62e85e8d..3afccf61 100644 --- a/src/commands/content-item/tree.spec.ts +++ b/src/commands/content-item/tree.spec.ts @@ -303,7 +303,7 @@ describe('content-item tree command', () => { await handler(argv); expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); - expect(consoleErrorSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); }); }); });