diff --git a/packages/cli/package-lock.json b/packages/cli/package-lock.json index 64d0a22194..248d88f688 100644 --- a/packages/cli/package-lock.json +++ b/packages/cli/package-lock.json @@ -5190,6 +5190,14 @@ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.10.tgz", "integrity": "sha512-C7srjHiVG3Ey1nR6d511dtDkCEjxuN9W1HWAEjGq8kpcwmNM6JJkpC0xvabM7BXTG2wDq8Eu33iH9aQKa7IvLQ==" }, + "@types/decompress": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@types/decompress/-/decompress-4.2.3.tgz", + "integrity": "sha512-W24e3Ycz1UZPgr1ZEDHlK4XnvOr+CpJH3qNsFeqXwwlW/9END9gxn3oJSsp7gYdiQxrXUHwUUd3xuzVz37MrZQ==", + "requires": { + "@types/node": "*" + } + }, "@types/ejs": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-2.7.0.tgz", diff --git a/packages/cli/package.json b/packages/cli/package.json index 95f320fb8c..96f5b91abf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,13 +16,16 @@ "@oclif/plugin-help": "^3", "@oclif/plugin-update": "^1.3.10", "@openid/appauth": "^1.3.0", + "@types/decompress": "^4.2.3", "@vue/cli": "^4.5.11", "abortcontroller-polyfill": "^1.7.1", "archiver": "^5.3.0", + "async-retry": "^1.3.1", "chalk": "^4.1.1", "cli-ux": "^5.5.1", "coveo.analytics": "^2.18.4", "create-react-app": "^4.0.3", + "decompress": "^4.2.1", "exponential-backoff": "^3.1.0", "extract-zip": "^2.0.1", "fs-extra": "^10.0.0", diff --git a/packages/cli/src/__test__/fsUtils.ts b/packages/cli/src/__test__/fsUtils.ts new file mode 100644 index 0000000000..b4eabe09c4 --- /dev/null +++ b/packages/cli/src/__test__/fsUtils.ts @@ -0,0 +1,15 @@ +import {Dirent} from 'fs'; + +export const getFile = (name: string) => getDirent(name, 'file'); +export const getDirectory = (name: string) => getDirent(name, 'dir'); + +const getDirent = (name: string, type: 'file' | 'dir') => { + const dirent = new Dirent(); + const isFile = type === 'file'; + dirent.isDirectory = isFile ? () => false : () => true; + dirent.isFile = isFile ? () => true : () => false; + if (name) { + dirent.name = name; + } + return dirent; +}; diff --git a/packages/cli/src/commands/org/config/monitor.ts b/packages/cli/src/commands/org/config/monitor.ts index 80e1228573..28af6a1d23 100644 --- a/packages/cli/src/commands/org/config/monitor.ts +++ b/packages/cli/src/commands/org/config/monitor.ts @@ -10,7 +10,7 @@ import { IsAuthenticated, Preconditions, } from '../../../lib/decorators/preconditions'; -import {ReportViewerStyles} from '../../../lib/snapshot/reportViewer/reportViewerStyles'; +import {ReportViewerStyles} from '../../../lib/snapshot/reportPreviewer/reportPreviewerStyles'; import {Snapshot, waitUntilDoneOptions} from '../../../lib/snapshot/snapshot'; import { getTargetOrg, diff --git a/packages/cli/src/commands/org/config/preview.ts b/packages/cli/src/commands/org/config/preview.ts index 4f35a2515a..649c8e77e6 100644 --- a/packages/cli/src/commands/org/config/preview.ts +++ b/packages/cli/src/commands/org/config/preview.ts @@ -12,6 +12,7 @@ import { IsAuthenticated, Preconditions, } from '../../../lib/decorators/preconditions'; +import {IsGitInstalled} from '../../../lib/decorators/preconditions/git'; import {SnapshotOperationTimeoutError} from '../../../lib/errors'; import {Snapshot} from '../../../lib/snapshot/snapshot'; import { @@ -36,7 +37,7 @@ export default class Preview extends Command { }), showMissingResources: flags.boolean({ char: 'd', - description: 'Whether or not preview missing resources', + description: 'Preview resources deletion when enabled', default: false, required: false, }), @@ -50,7 +51,7 @@ export default class Preview extends Command { public static hidden = true; - @Preconditions(IsAuthenticated()) + @Preconditions(IsAuthenticated(), IsGitInstalled()) public async run() { const {flags} = this.parse(Preview); const target = await getTargetOrg(this.configuration, flags.target); @@ -64,7 +65,7 @@ export default class Preview extends Command { options ); - await snapshot.preview(); + await snapshot.preview(project, options.deleteMissingResources); if (reporter.isSuccessReport()) { await snapshot.delete(); diff --git a/packages/cli/src/commands/org/config/push.ts b/packages/cli/src/commands/org/config/push.ts index 380152a6e4..51812c9345 100644 --- a/packages/cli/src/commands/org/config/push.ts +++ b/packages/cli/src/commands/org/config/push.ts @@ -36,7 +36,7 @@ export default class Push extends Command { }), deleteMissingResources: flags.boolean({ char: 'd', - description: 'Whether or not to delete missing resources', + description: 'Delete missing resources when enabled', default: false, required: false, }), @@ -65,7 +65,7 @@ export default class Push extends Command { ); if (!flags.skipPreview) { - await snapshot.preview(); + await snapshot.preview(project, options.deleteMissingResources); } if (reporter.isSuccessReport()) { diff --git a/packages/cli/src/lib/project/project.ts b/packages/cli/src/lib/project/project.ts index d98d12f147..392d84bfe2 100644 --- a/packages/cli/src/lib/project/project.ts +++ b/packages/cli/src/lib/project/project.ts @@ -8,7 +8,7 @@ import {DotFolder, DotFolderConfig} from './dotFolder'; export class Project { private static readonly resourceFolderName = 'resources'; - public constructor(private pathToProject: string) { + public constructor(private _pathToProject: string) { if (!this.isCoveoProject) { this.makeCoveoProject(); } @@ -17,25 +17,25 @@ export class Project { public async refresh(projectContent: Blob) { const buffer = await projectContent.arrayBuffer(); const view = new DataView(buffer); - writeFileSync(this.pathToTemporaryZip, view); - await extract(this.pathToTemporaryZip, {dir: this.resourcePath}); + writeFileSync(this.temporaryZipPath, view); + await extract(this.temporaryZipPath, {dir: this.resourcePath}); this.deleteTemporaryZipFile(); } public deleteTemporaryZipFile() { - unlinkSync(this.pathToTemporaryZip); + unlinkSync(this.temporaryZipPath); } private ensureProjectCompliance() { if (!this.isResourcesProject) { throw new InvalidProjectError( - this.pathToProject, + this._pathToProject, 'Does not contain any resources folder' ); } if (!this.isCoveoProject) { throw new InvalidProjectError( - this.pathToProject, + this._pathToProject, 'Does not contain any .coveo folder' ); } @@ -45,8 +45,7 @@ export class Project { try { this.ensureProjectCompliance(); await new Promise((resolve, reject) => { - const pathToTemporaryZip = this.pathToTemporaryZip; - const outputStream = createWriteStream(pathToTemporaryZip); + const outputStream = createWriteStream(this.temporaryZipPath); const archive = archiver('zip'); outputStream.on('close', () => resolve()); @@ -56,22 +55,25 @@ export class Project { archive.directory(this.resourcePath, false); archive.finalize(); }); - return this.pathToTemporaryZip; + return this.temporaryZipPath; } catch (error) { cli.error(error); } } - public contains(fileName: string) { - return existsSync(join(this.pathToProject, fileName)); + public get pathToProject() { + return this._pathToProject; } - private get pathToTemporaryZip() { - return join(this.pathToProject, 'snapshot.zip'); + private get temporaryZipPath() { + return join(this._pathToProject, 'snapshot.zip'); } - private get resourcePath() { - return join(this.pathToProject, Project.resourceFolderName); + public get resourcePath() { + return join(this._pathToProject, Project.resourceFolderName); + } + public contains(fileName: string) { + return existsSync(join(this.pathToProject, fileName)); } private get isCoveoProject() { diff --git a/packages/cli/src/lib/snapshot/expandedPreviewer/expandedPreviewer.spec.ts b/packages/cli/src/lib/snapshot/expandedPreviewer/expandedPreviewer.spec.ts new file mode 100644 index 0000000000..954d77b124 --- /dev/null +++ b/packages/cli/src/lib/snapshot/expandedPreviewer/expandedPreviewer.spec.ts @@ -0,0 +1,294 @@ +jest.mock('fs'); +jest.mock('fs-extra'); +jest.mock('../../project/project'); +jest.mock('../snapshotFactory'); +jest.mock('../../utils/process'); +jest.mock('./filesDiffProcessor'); +import { + ResourceSnapshotsReportModel, + ResourceSnapshotsReportType, +} from '@coveord/platform-client'; +import {Dirent, existsSync, mkdirSync, readdirSync, rmSync} from 'fs'; +import {join} from 'path'; + +import {mocked} from 'ts-jest/utils'; +import {getSuccessReport} from '../../../__stub__/resourceSnapshotsReportModel'; +import {ExpandedPreviewer} from './expandedPreviewer'; +import {Project} from '../../project/project'; +import {SnapshotFactory} from '../snapshotFactory'; +import {Snapshot} from '../snapshot'; +import {spawnProcess} from '../../utils/process'; +import {recursiveDirectoryDiff} from './filesDiffProcessor'; +import {getDirectory} from '../../../__test__/fsUtils'; +import {resolve} from 'path'; + +describe('ExpandedPreviewer', () => { + const Blob = jest.fn(); + const fakeBlob: Blob = new Blob(); + + const mockedRecursiveDirectoryDiff = mocked(recursiveDirectoryDiff); + const mockedExistsSync = mocked(existsSync); + const mockedReaddirSync = mocked(readdirSync); + const mockedRmSync = mocked(rmSync); + const mockedMkdirSync = mocked(mkdirSync); + const mockedSpawnProcess = mocked(spawnProcess); + const mockedProject = mocked(Project); + const mockedProjectRefresh = jest.fn(); + const mockedSnapshotFactory = mocked(SnapshotFactory, true); + const mockedSnapshotDownload = jest.fn().mockReturnValue(fakeBlob); + + let nbOfExistingPreview: number; + const mockExistingPreviews = () => { + const dirs = new Array(); + for (let i = 0; i < nbOfExistingPreview; i++) { + dirs.push(getDirectory(`someOrgId-${i}`)); + } + mockedReaddirSync.mockReturnValueOnce(dirs); + }; + + const mockExistsSync = () => { + mockedExistsSync.mockReturnValue(true); + }; + + const mockProject = () => { + mockedProject.mockImplementation( + (path: string) => + ({ + pathToProject: path, + resourcePath: resolve(join(path, 'resources')), + refresh: mockedProjectRefresh, + } as unknown as Project) + ); + }; + + const mockSnapshotFactory = async () => { + mockedSnapshotFactory.createFromOrg.mockReturnValue( + Promise.resolve({ + download: mockedSnapshotDownload, + } as unknown as Snapshot) + ); + }; + + const defaultMocks = () => { + mockExistsSync(); + mockExistingPreviews(); + mockProject(); + mockSnapshotFactory(); + jest.spyOn(Date, 'now').mockImplementation(() => 42); + }; + + beforeAll(() => { + nbOfExistingPreview = 4; + }); + + beforeEach(() => { + defaultMocks(); + }); + + afterEach(() => { + mockedReaddirSync.mockReset(); + mockedRmSync.mockReset(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('when there are 5 expanded preview stored or more', () => { + beforeAll(() => { + nbOfExistingPreview = 8; + }); + + afterAll(() => { + nbOfExistingPreview = 4; + }); + + it('should delete the exceeding preview directories', async () => { + const expandedPreviewer = new ExpandedPreviewer( + getSuccessReport('some-id', ResourceSnapshotsReportType.DryRun), + 'someorg', + new Project('my/awesome/path'), + false + ); + await expandedPreviewer.preview(); + + expect(mockedReaddirSync).toBeCalledWith(join('.coveo/preview'), { + withFileTypes: true, + }); + expect(mockedRmSync).toHaveBeenCalledTimes(4); + for (let index = 0; index < 4; index++) { + expect(mockedRmSync).toHaveBeenNthCalledWith( + index + 1, + join('.coveo/preview', `someOrgId-${index}`), + expect.anything() + ); + } + }); + }); + + describe('when no preview has been done yet', () => { + it('should not delete any preview directories', async () => { + mockedExistsSync.mockReturnValueOnce(false); + const expandedPreviewer = new ExpandedPreviewer( + getSuccessReport('some-id', ResourceSnapshotsReportType.DryRun), + 'someorg', + new Project('my/awesome/path'), + false + ); + await expandedPreviewer.preview(); + + expect(mockedExistsSync).toBeCalledWith(join('.coveo/preview')); + expect(mockedReaddirSync).not.toBeCalled(); + }); + }); + + describe('when there are less than 5 expanded preview stored', () => { + beforeAll(() => { + nbOfExistingPreview = 4; + }); + + afterAll(() => { + nbOfExistingPreview = 4; + }); + + it('should not delete any preview directories', async () => { + const expandedPreviewer = new ExpandedPreviewer( + getSuccessReport('some-id', ResourceSnapshotsReportType.DryRun), + 'someorg', + new Project('my/awesome/path'), + false + ); + await expandedPreviewer.preview(); + + expect(mockedReaddirSync).toBeCalledWith(join('.coveo/preview'), { + withFileTypes: true, + }); + expect(mockedRmSync).not.toHaveBeenCalled(); + }); + }); + + describe('when shouldDelete is false', () => { + it('should call the fillDiffProcessor with the proper options', async () => { + const expandedPreviewer = new ExpandedPreviewer( + getSuccessReport('some-id', ResourceSnapshotsReportType.DryRun), + 'someorg', + new Project('my/awesome/path'), + false + ); + await expandedPreviewer.preview(); + + expect(mockedRecursiveDirectoryDiff).toBeCalledWith( + expect.anything(), + expect.anything(), + false + ); + }); + }); + + describe('when shouldDelete is true', () => { + it('should call the fillDiffProcessor with the proper options', async () => { + const expandedPreviewer = new ExpandedPreviewer( + getSuccessReport('some-id', ResourceSnapshotsReportType.DryRun), + 'someorg', + new Project('my/awesome/path'), + true + ); + await expandedPreviewer.preview(); + + expect(mockedRecursiveDirectoryDiff).toBeCalledWith( + expect.anything(), + expect.anything(), + true + ); + }); + }); + + describe('when calling #preview', () => { + let fakeReport: ResourceSnapshotsReportModel; + let expandedPreviewer: ExpandedPreviewer; + let previewPath: string; + + beforeEach(async () => { + previewPath = join('.coveo/preview', 'someorg-42'); + fakeReport = getSuccessReport( + 'some-id', + ResourceSnapshotsReportType.DryRun + ); + expandedPreviewer = new ExpandedPreviewer( + fakeReport, + 'someorg', + new Project('my/awesome/path'), + false + ); + await expandedPreviewer.preview(); + }); + + it('should get a snapshot of the target org', async () => { + const previewPath = join('.coveo', 'preview', 'someorg-42'); + expect(mockedMkdirSync).toHaveBeenCalledWith(previewPath, { + recursive: true, + }); + + expect(mockedProject).toHaveBeenCalledWith(resolve(previewPath)); + expect(mockedSnapshotFactory.createFromOrg).toHaveBeenCalledWith( + Object.keys(fakeReport.resourceOperationResults), + 'someorg' + ); + expect(mockedProjectRefresh).toHaveBeenCalledWith(fakeBlob); + }); + + it('should commit the snapshot of the target org', async () => { + expect(mockedSpawnProcess).toHaveBeenNthCalledWith(1, 'git', ['init'], { + cwd: previewPath, + stdio: 'ignore', + }); + expect(mockedSpawnProcess).toHaveBeenNthCalledWith( + 2, + 'git', + ['add', '.'], + { + cwd: previewPath, + stdio: 'ignore', + } + ); + expect(mockedSpawnProcess).toHaveBeenNthCalledWith( + 3, + 'git', + ['commit', '--message=someorg currently'], + { + cwd: previewPath, + stdio: 'ignore', + } + ); + }); + + it('should write the diff between the snapshot of the target org and the snapshot on file', async () => { + expect(mockedRecursiveDirectoryDiff).toBeCalledWith( + join('.coveo/preview', 'someorg-42', 'resources'), + join('my/awesome/path', 'resources'), + expect.anything() + ); + }); + + it('should commit the diff', () => { + expect(mockedSpawnProcess).toHaveBeenNthCalledWith( + 4, + 'git', + ['add', '.'], + { + cwd: previewPath, + stdio: 'ignore', + } + ); + expect(mockedSpawnProcess).toHaveBeenNthCalledWith( + 5, + 'git', + ['commit', '--message=someorg after snapshot application'], + { + cwd: previewPath, + stdio: 'ignore', + } + ); + }); + }); +}); diff --git a/packages/cli/src/lib/snapshot/expandedPreviewer/expandedPreviewer.ts b/packages/cli/src/lib/snapshot/expandedPreviewer/expandedPreviewer.ts new file mode 100644 index 0000000000..73d806a39a --- /dev/null +++ b/packages/cli/src/lib/snapshot/expandedPreviewer/expandedPreviewer.ts @@ -0,0 +1,129 @@ +import { + ResourceSnapshotsReportModel, + ResourceSnapshotType, +} from '@coveord/platform-client'; +import {existsSync, mkdirSync, readdirSync, rmSync} from 'fs'; +import {join, relative, resolve} from 'path'; +import {cli} from 'cli-ux'; +import {Project} from '../../project/project'; +import {spawnProcess} from '../../utils/process'; +import {SnapshotFactory} from '../snapshotFactory'; +import dedent from 'ts-dedent'; +import {Dirent} from 'fs'; +import {recursiveDirectoryDiff} from './filesDiffProcessor'; +import {DotFolder} from '../../project/dotFolder'; +import {cwd} from 'process'; + +export class ExpandedPreviewer { + private static readonly previewDirectoryName = 'preview'; + + private resourcesToPreview: ResourceSnapshotType[]; + private static previewHistorySize = 5; + + public constructor( + report: ResourceSnapshotsReportModel, + private readonly orgId: string, + private readonly projectToPreview: Project, + private readonly shouldDelete: boolean + ) { + this.resourcesToPreview = Object.keys( + report.resourceOperationResults + ) as ResourceSnapshotType[]; + } + + private static get previewDirectory() { + return join( + DotFolder.hiddenFolderName, + ExpandedPreviewer.previewDirectoryName + ); + } + + public async preview() { + if (existsSync(ExpandedPreviewer.previewDirectory)) { + this.deleteOldestPreviews(); + } + const previewLocalSlug = `${this.orgId}-${Date.now()}`; + const dirPath = join(ExpandedPreviewer.previewDirectory, previewLocalSlug); + + mkdirSync(dirPath, { + recursive: true, + }); + const project = new Project(resolve(dirPath)); + await this.initPreviewDirectory(dirPath, project); + await this.applySnapshotToPreview(dirPath); + // TODO: Remove/move as tests progress. + if (Date.now() > 0) return; + cli.info(dedent` + + A Git repository representing the modification has been created here: + ${dirPath} + + `); + } + + private deleteOldestPreviews() { + const getFilePath = (fileDirent: Dirent) => + join(ExpandedPreviewer.previewDirectory, fileDirent.name); + + const getEpochFromSnapshotDir = (dir: Dirent): number => + parseInt(dir.name.match(/(?<=-)\d+$/)?.[0] ?? '0'); + + const allFiles = readdirSync(ExpandedPreviewer.previewDirectory, { + withFileTypes: true, + }); + const dirs = allFiles + .filter((potentialDir) => potentialDir.isDirectory()) + .sort( + (dirA, dirB) => + getEpochFromSnapshotDir(dirA) - getEpochFromSnapshotDir(dirB) + ); + + while (dirs.length >= ExpandedPreviewer.previewHistorySize) { + rmSync(getFilePath(dirs.shift()!), { + recursive: true, + force: true, + }); + } + } + + private async initPreviewDirectory(dirPath: string, project: Project) { + const beforeSnapshot = await this.getBeforeSnapshot(); + await project.refresh(beforeSnapshot); + await this.initialPreviewCommit(dirPath); + } + + private async initialPreviewCommit(dirPath: string) { + await spawnProcess('git', ['init'], {cwd: dirPath, stdio: 'ignore'}); + await spawnProcess('git', ['add', '.'], {cwd: dirPath, stdio: 'ignore'}); + await spawnProcess('git', ['commit', `--message=${this.orgId} currently`], { + cwd: dirPath, + stdio: 'ignore', + }); + } + + private async applySnapshotToPreview(dirPath: string) { + recursiveDirectoryDiff( + join(dirPath, 'resources'), + relative(cwd(), this.projectToPreview.resourcePath), + this.shouldDelete + ); + await spawnProcess('git', ['add', '.'], {cwd: dirPath, stdio: 'ignore'}); + await spawnProcess( + 'git', + ['commit', `--message=${this.orgId} after snapshot application`], + { + cwd: dirPath, + stdio: 'ignore', + } + ); + } + + private async getBeforeSnapshot() { + const snapshot = await SnapshotFactory.createFromOrg( + this.resourcesToPreview, + this.orgId + ); + + return snapshot.download(); + } +} diff --git a/packages/cli/src/lib/snapshot/expandedPreviewer/filesDiffProcessor.spec.ts b/packages/cli/src/lib/snapshot/expandedPreviewer/filesDiffProcessor.spec.ts new file mode 100644 index 0000000000..bbcb5fd38a --- /dev/null +++ b/packages/cli/src/lib/snapshot/expandedPreviewer/filesDiffProcessor.spec.ts @@ -0,0 +1,169 @@ +jest.mock('fs'); +jest.mock('fs-extra'); + +import {mocked} from 'ts-jest/utils'; + +import {readdirSync, rmSync} from 'fs'; +import {readJSONSync, writeJSONSync} from 'fs-extra'; +import {getDirectory, getFile} from '../../../__test__/fsUtils'; +import {recursiveDirectoryDiff} from './filesDiffProcessor'; +import {join} from 'path'; + +const mockedReadDir = mocked(readdirSync); +const mockedRm = mocked(rmSync); +const mockedReadJson = mocked(readJSONSync); +const mockedWriteJSON = mocked(writeJSONSync); + +const resourceA = { + resources: { + EXTENSION: [ + { + resourceName: 'resourceA', + someProp: 'someValue', + }, + ], + }, +}; +const resourceAModified = { + resources: { + EXTENSION: [ + { + resourceName: 'resourceA', + someProp: 'otherValue', + }, + ], + }, +}; +const resourcesAB = { + resources: { + EXTENSION: [ + { + resourceName: 'resourceA', + someProp: 'someValue', + }, + { + resourceName: 'resourceB', + }, + ], + }, +}; + +describe('#recursiveDirectoryDiff', () => { + beforeEach(() => { + mockedReadDir.mockReturnValue([]); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('when deleteMissingFile is true', () => { + it('should delete files present in the currentDir but not in the nextDir', () => { + mockedReadDir.mockReturnValueOnce([getFile('someFile.json')]); + mockedReadDir.mockReturnValueOnce([]); + + recursiveDirectoryDiff('currentDir', 'nextDir', true); + + expect(mockedRm).toHaveBeenCalledWith( + join('currentDir', 'someFile.json') + ); + }); + + it('should delete resources present in the currentDir but not in the nextDir', () => { + mockedReadDir.mockReturnValueOnce([getFile('someFile.json')]); + mockedReadDir.mockReturnValueOnce([getFile('someFile.json')]); + mockedReadJson.mockReturnValueOnce(resourceA); + mockedReadJson.mockReturnValueOnce(resourcesAB); + + recursiveDirectoryDiff('currentDir', 'nextDir', true); + + expect(mockedWriteJSON).toHaveBeenCalledWith( + join('currentDir', 'someFile.json'), + resourceA, + {spaces: 2} + ); + }); + }); + + describe('when deleteMissingFile is false', () => { + it('should preserve files present in the currentDir but not in the nextDir', () => { + mockedReadDir.mockReturnValueOnce([getFile('someFile.json')]); + mockedReadDir.mockReturnValueOnce([]); + + recursiveDirectoryDiff('currentDir', 'nextDir', false); + + expect(mockedRm).not.toHaveBeenCalled(); + }); + + it('should preserve keys present in the currentDir but not in the nextDir', () => { + mockedReadDir.mockReturnValueOnce([getFile('someFile.json')]); + mockedReadDir.mockReturnValueOnce([getFile('someFile.json')]); + mockedReadJson.mockReturnValueOnce(resourceA); + mockedReadJson.mockReturnValueOnce(resourcesAB); + + recursiveDirectoryDiff('currentDir', 'nextDir', false); + + expect(mockedWriteJSON).toHaveBeenCalledWith( + join('currentDir', 'someFile.json'), + resourcesAB, + {spaces: 2} + ); + }); + }); + + it('should check files in sub-directories', () => { + mockedReadDir.mockReturnValue([]); + mockedReadDir.mockReturnValueOnce([getDirectory('someDir')]); + + recursiveDirectoryDiff('currentDir', 'nextDir', false); + + expect(mockedReadDir).toHaveBeenNthCalledWith( + 2, + join('currentDir', 'someDir'), + expect.anything() + ); + }); + + it('should create files present in the nextDir but not in the currentDir', () => { + mockedReadDir.mockReturnValueOnce([]); + mockedReadDir.mockReturnValueOnce([getFile('someFile.json')]); + mockedReadJson.mockReturnValueOnce(resourceA); + + recursiveDirectoryDiff('currentDir', 'nextDir', false); + + expect(mockedWriteJSON).toHaveBeenCalledWith( + join('currentDir', 'someFile.json'), + resourceA, + {spaces: 2} + ); + }); + + it('should create keys present in the nextDir but not in the currentDir', () => { + mockedReadDir.mockReturnValueOnce([]); + mockedReadDir.mockReturnValueOnce([getFile('someFile.json')]); + mockedReadJson.mockReturnValueOnce(resourcesAB); + mockedReadJson.mockReturnValueOnce(resourceA); + + recursiveDirectoryDiff('currentDir', 'nextDir', false); + + expect(mockedWriteJSON).toHaveBeenCalledWith( + join('currentDir', 'someFile.json'), + resourcesAB, + {spaces: 2} + ); + }); + + it('should replace the value of keys present in the nextDir and in the currentDir', () => { + mockedReadDir.mockReturnValueOnce([]); + mockedReadDir.mockReturnValueOnce([getFile('someFile.json')]); + mockedReadJson.mockReturnValueOnce(resourceAModified); + mockedReadJson.mockReturnValueOnce(resourceA); + + recursiveDirectoryDiff('currentDir', 'nextDir', false); + + expect(mockedWriteJSON).toHaveBeenCalledWith( + join('currentDir', 'someFile.json'), + resourceAModified, + {spaces: 2} + ); + }); +}); diff --git a/packages/cli/src/lib/snapshot/expandedPreviewer/filesDiffProcessor.ts b/packages/cli/src/lib/snapshot/expandedPreviewer/filesDiffProcessor.ts new file mode 100644 index 0000000000..df9b8b171a --- /dev/null +++ b/packages/cli/src/lib/snapshot/expandedPreviewer/filesDiffProcessor.ts @@ -0,0 +1,122 @@ +import type {ResourceSnapshotType} from '@coveord/platform-client'; +import {readdirSync, rmSync} from 'fs'; +import { + readJsonSync, + readJSONSync, + writeJSONSync, + WriteOptions, +} from 'fs-extra'; +import {join} from 'path'; + +type ResourcesJSON = Object & {resourceName: string}; + +type SnapshotFileJSON = Object & { + resources: Partial<{[key in ResourceSnapshotType]: ResourcesJSON[]}>; +}; + +const firstDirOfPath = + process.platform === 'win32' ? /^[^\\]*(?=\\)/m : /^[^/]*\//m; +const defaultWriteOptions: WriteOptions = {spaces: 2}; + +export function recursiveDirectoryDiff( + currentDir: string, + nextDir: string, + deleteMissingResources: boolean +) { + const currentFilePaths = getAllFilesPath(currentDir); + const nextFilePaths = getAllFilesPath(nextDir); + + nextFilePaths.forEach((filePath) => { + const nextFileJson = readJsonSync(join(nextDir, filePath)); + let dataToWrite = nextFileJson; + if (currentFilePaths.has(filePath)) { + currentFilePaths.delete(filePath); + const currentFileJSON = readJSONSync(join(currentDir, filePath)); + dataToWrite = buildDiffedJson( + currentFileJSON, + nextFileJson, + deleteMissingResources + ); + } + writeJSONSync(join(currentDir, filePath), dataToWrite, defaultWriteOptions); + }); + + if (deleteMissingResources) { + currentFilePaths.forEach((filePath) => rmSync(join(currentDir, filePath))); + } +} + +function getAllFilesPath( + currentDir: string, + filePaths: Set = new Set() +) { + const files = readdirSync(currentDir, {withFileTypes: true}); + files.forEach((file) => { + if (file.isDirectory()) { + getAllFilesPath(join(currentDir, file.name), filePaths); + } else { + filePaths.add(join(currentDir, file.name).replace(firstDirOfPath, '')); + } + }); + return filePaths; +} + +function buildDiffedJson( + currentFile: SnapshotFileJSON, + nextFile: SnapshotFileJSON, + deleteMissingResources: boolean +) { + const currentResources = getResourceDictionnaryFromObject(currentFile); + const nextResources = getResourceDictionnaryFromObject(nextFile); + const diffedDictionnary = getDiffedDictionnary( + currentResources, + nextResources, + deleteMissingResources + ); + + const diffedResources: ResourcesJSON[] = []; + diffedDictionnary.forEach((resource) => diffedResources.push(resource)); + diffedResources.sort(); + + const resourceType = Object.keys(currentFile.resources)[0]; + const diffedJSON: SnapshotFileJSON = { + ...currentFile, + resources: {[resourceType]: diffedResources}, + }; + return diffedJSON; +} + +function getDiffedDictionnary( + currentResources: Map, + nextResources: Map, + shouldDelete: boolean +) { + if (shouldDelete) { + return nextResources; + } + const diffedResources = new Map(currentResources); + const iterator = nextResources.keys(); + for ( + let resource = iterator.next(); + !resource.done; + resource = iterator.next() + ) { + const nextResource = nextResources.get(resource.value); + if (nextResource) { + diffedResources.set(resource.value, nextResource); + } + } + return diffedResources; +} + +function getResourceDictionnaryFromObject(snapshotFile: SnapshotFileJSON) { + const dictionnary = new Map(); + const resourcesSection = snapshotFile.resources; + for (const resourceType in resourcesSection) { + const resources = resourcesSection[resourceType as ResourceSnapshotType]; + resources?.forEach((resource) => { + dictionnary.set(resource.resourceName, resource); + }); + } + return dictionnary; +} diff --git a/packages/cli/src/lib/snapshot/reportViewer/reportViewer.spec.ts b/packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewer.spec.ts similarity index 98% rename from packages/cli/src/lib/snapshot/reportViewer/reportViewer.spec.ts rename to packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewer.spec.ts index 79f6930e79..db59b89ab3 100644 --- a/packages/cli/src/lib/snapshot/reportViewer/reportViewer.spec.ts +++ b/packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewer.spec.ts @@ -1,7 +1,7 @@ import {test} from '@oclif/test'; import {ResourceSnapshotsReportType} from '@coveord/platform-client'; -import {ReportViewer} from './reportViewer'; +import {ReportViewer} from './reportPreviewer'; import dedent from 'ts-dedent'; import {SnapshotReporter} from '../snapshotReporter'; import { diff --git a/packages/cli/src/lib/snapshot/reportViewer/reportViewer.ts b/packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewer.ts similarity index 95% rename from packages/cli/src/lib/snapshot/reportViewer/reportViewer.ts rename to packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewer.ts index ee7ccfc072..8ffdba045f 100644 --- a/packages/cli/src/lib/snapshot/reportViewer/reportViewer.ts +++ b/packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewer.ts @@ -1,12 +1,12 @@ import {cli} from 'cli-ux'; import {red, italic, green} from 'chalk'; -import {ReportViewerSection} from './reportViewerSection'; -import {ReportViewerStyles} from './reportViewerStyles'; +import {ReportViewerSection} from './reportPreviewerSection'; +import {ReportViewerStyles} from './reportPreviewerStyles'; import {SnapshotReporter} from '../snapshotReporter'; import { ReportViewerOperationName, ReportViewerResourceReportModel, -} from './reportViewerDataModels'; +} from './reportPreviewerDataModels'; import dedent from 'ts-dedent'; export class ReportViewer { diff --git a/packages/cli/src/lib/snapshot/reportViewer/reportViewerDataModels.ts b/packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewerDataModels.ts similarity index 100% rename from packages/cli/src/lib/snapshot/reportViewer/reportViewerDataModels.ts rename to packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewerDataModels.ts diff --git a/packages/cli/src/lib/snapshot/reportViewer/reportViewerSection.spec.ts b/packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewerSection.spec.ts similarity index 95% rename from packages/cli/src/lib/snapshot/reportViewer/reportViewerSection.spec.ts rename to packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewerSection.spec.ts index cc511acb72..32dfb5e31c 100644 --- a/packages/cli/src/lib/snapshot/reportViewer/reportViewerSection.spec.ts +++ b/packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewerSection.spec.ts @@ -3,8 +3,8 @@ import dedent from 'ts-dedent'; import { ReportViewerOperationName, ReportViewerResourceReportModel, -} from './reportViewerDataModels'; -import {ReportViewerSection} from './reportViewerSection'; +} from './reportPreviewerDataModels'; +import {ReportViewerSection} from './reportPreviewerSection'; describe('ReportViewerSection', () => { const resourceWithChanges: ReportViewerResourceReportModel = { diff --git a/packages/cli/src/lib/snapshot/reportViewer/reportViewerSection.ts b/packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewerSection.ts similarity index 96% rename from packages/cli/src/lib/snapshot/reportViewer/reportViewerSection.ts rename to packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewerSection.ts index 080a0ced04..2826836aa6 100644 --- a/packages/cli/src/lib/snapshot/reportViewer/reportViewerSection.ts +++ b/packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewerSection.ts @@ -1,8 +1,8 @@ -import {ReportViewerStyles} from './reportViewerStyles'; +import {ReportViewerStyles} from './reportPreviewerStyles'; import { ReportViewerOperationName, ReportViewerResourceReportModel, -} from './reportViewerDataModels'; +} from './reportPreviewerDataModels'; import {ResourceSnapshotsReportOperationModel} from '@coveord/platform-client'; class ReportViewerOperationLogFactory { diff --git a/packages/cli/src/lib/snapshot/reportViewer/reportViewerStyles.ts b/packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewerStyles.ts similarity index 100% rename from packages/cli/src/lib/snapshot/reportViewer/reportViewerStyles.ts rename to packages/cli/src/lib/snapshot/reportPreviewer/reportPreviewerStyles.ts diff --git a/packages/cli/src/lib/snapshot/snapshot.ts b/packages/cli/src/lib/snapshot/snapshot.ts index 8c3837f76c..729ce478fc 100644 --- a/packages/cli/src/lib/snapshot/snapshot.ts +++ b/packages/cli/src/lib/snapshot/snapshot.ts @@ -8,12 +8,14 @@ import { SnapshotExportContentFormat, } from '@coveord/platform-client'; import {backOff, IBackOffOptions} from 'exponential-backoff'; -import {ReportViewer} from './reportViewer/reportViewer'; +import {ReportViewer} from './reportPreviewer/reportPreviewer'; import {ensureFileSync, writeJsonSync} from 'fs-extra'; import {join} from 'path'; import dedent from 'ts-dedent'; import {SnapshotReporter} from './snapshotReporter'; import {SnapshotOperationTimeoutError} from '../errors'; +import {ExpandedPreviewer} from './expandedPreviewer/expandedPreviewer'; +import {Project} from '../project/project'; export interface waitUntilDoneOptions { /** * The operation to wait for. If not specified, the method will wait for any operation to complete. @@ -47,9 +49,12 @@ export class Snapshot { return new SnapshotReporter(this.latestReport); } - public async preview() { + public async preview( + projectToPreview: Project, + deleteMissingResources = false + ) { this.displayLightPreview(); - this.displayExpandedPreview(); + await this.displayExpandedPreview(projectToPreview, deleteMissingResources); } public async apply(deleteMissingResources = false) { @@ -120,8 +125,17 @@ export class Snapshot { viewer.display(); } - private displayExpandedPreview() { - // TODO: CDX-347 Display Expanded preview + private async displayExpandedPreview( + projectToPreview: Project, + shouldDelete: boolean + ) { + const previewer = new ExpandedPreviewer( + this.latestReport, + this.targetId!, + projectToPreview, + shouldDelete + ); + await previewer.preview(); } private async refreshSnapshotData() { diff --git a/packages/cli/src/lib/snapshot/snapshotReporter.spec.ts b/packages/cli/src/lib/snapshot/snapshotReporter.spec.ts index 16cbe4764b..6804a77265 100644 --- a/packages/cli/src/lib/snapshot/snapshotReporter.spec.ts +++ b/packages/cli/src/lib/snapshot/snapshotReporter.spec.ts @@ -3,7 +3,7 @@ import { getReportWithoutChanges, getSuccessReport, } from '../../__stub__/resourceSnapshotsReportModel'; -import {ReportViewerResourceReportModel} from './reportViewer/reportViewerDataModels'; +import {ReportViewerResourceReportModel} from './reportPreviewer/reportPreviewerDataModels'; import {SnapshotReporter} from './snapshotReporter'; describe('SnapshotReporter', () => { diff --git a/packages/cli/src/lib/snapshot/snapshotReporter.ts b/packages/cli/src/lib/snapshot/snapshotReporter.ts index 99d7cb5642..89c9272b83 100644 --- a/packages/cli/src/lib/snapshot/snapshotReporter.ts +++ b/packages/cli/src/lib/snapshot/snapshotReporter.ts @@ -7,7 +7,7 @@ import { import { ReportViewerOperationName, ReportViewerResourceReportModel, -} from './reportViewer/reportViewerDataModels'; +} from './reportPreviewer/reportPreviewerDataModels'; type ResourceEntries = [string, ResourceSnapshotsReportOperationModel]; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 2d6c458691..cfcdf14164 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -5,6 +5,7 @@ "declaration": true, "importHelpers": true, "module": "commonjs", + "lib": ["ESNext"], "outDir": "lib", "rootDir": "src", "strict": true,