From 919c1b92a76952791c16152bb4d8cf2ae4c33f79 Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Fri, 15 Sep 2023 09:09:53 +0900 Subject: [PATCH 01/31] Add tests: getExistingSheetsFiles, getCsvFolderId --- test/convert.test.ts | 261 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) diff --git a/test/convert.test.ts b/test/convert.test.ts index 5db5f5f..0e2d42b 100644 --- a/test/convert.test.ts +++ b/test/convert.test.ts @@ -9,7 +9,9 @@ import { readConfigFileSync, validateConfig, getLocalCsvFilePaths, + getExistingSheetsFiles, getExistingSheetsFileId, + getCsvFolderId, } from '../src/commands/convert'; import { C2gError } from '../src/c2g-error'; @@ -148,6 +150,128 @@ describe('getLocalCsvFilePaths', () => { }); }); +describe('getExistingSheetsFiles', () => { + jest.mock('googleapis'); + afterEach(() => { + jest.restoreAllMocks(); + }); + + const baseConfig: Config = { + sourceDir: '/path/to/source', + targetDriveFolderId: '12345', + targetIsSharedDrive: true, + updateExistingGoogleSheets: true, + saveOriginalFilesToDrive: false, + }; + + it('should return an array of existing Google Sheets files without nextPageToken', async () => { + const mockDrive = { + files: { + list: jest.fn().mockImplementation(() => { + return { + data: { + files: [ + { + id: '12345', + name: 'file1', + } as drive_v3.Schema$File, + { + id: '67890', + name: 'file2', + } as drive_v3.Schema$File, + ], + } as drive_v3.Schema$FileList, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await getExistingSheetsFiles(mockDrive, mockConfig)).toEqual([ + { + id: '12345', + name: 'file1', + }, + { + id: '67890', + name: 'file2', + }, + ]); + }); + + it('should return an array of existing Google Sheets files with recursive calls using nextPageToken', async () => { + const mockDrive = { + files: { + list: jest + .fn() + .mockImplementationOnce(() => { + return { + data: { + files: [ + { + id: '12345', + name: 'file1', + } as drive_v3.Schema$File, + { + id: '67890', + name: 'file2', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + nextPageToken: 'nextPageToken123', + }, + }; + }) + .mockImplementationOnce(() => { + return { + data: { + files: [ + { + id: 'abcde', + name: 'file3', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + }, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await getExistingSheetsFiles(mockDrive, mockConfig)).toEqual([ + { + id: '12345', + name: 'file1', + }, + { + id: '67890', + name: 'file2', + }, + { + id: 'abcde', + name: 'file3', + }, + ]); + }); + + it('should return the original fileList if config.updateExistingGoogleSheets is false', async () => { + const mockDrive = {} as unknown as drive_v3.Drive; + const mockConfig = { + ...baseConfig, + updateExistingGoogleSheets: false, + }; + const mockFileList = [ + { + id: '12345', + name: 'file1', + }, + { + name: 'file2', + }, + ] as unknown as drive_v3.Schema$File[]; + expect( + await getExistingSheetsFiles(mockDrive, mockConfig, mockFileList), + ).toEqual(mockFileList); + }); +}); + describe('getExistingSheetsFileId', () => { const mockExistingSheetsFiles = [ { @@ -184,3 +308,140 @@ describe('getExistingSheetsFileId', () => { ).toBeNull(); }); }); + +describe('getCsvFolderId', () => { + jest.mock('googleapis'); + afterEach(() => { + jest.restoreAllMocks(); + }); + + const baseConfig: Config = { + sourceDir: '/path/to/source', + targetDriveFolderId: 'TargetDriveFolderId12345', + targetIsSharedDrive: true, + updateExistingGoogleSheets: true, + saveOriginalFilesToDrive: true, + }; + + it('should return the ID of the csv folder if config.saveOriginalFilesToDrive is false and it exists', async () => { + const mockDrive = { + files: { + list: jest.fn().mockImplementation(() => { + return { + data: { + files: [ + { + id: 'CsvFolderId12345', + name: 'csv', + } as drive_v3.Schema$File, + { + id: 'OtherFolderId67890', + name: 'csv', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + }, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + 'CsvFolderId12345', + ); + }); + + it('should create a new folder in the target Google Drive folder and return its ID', async () => { + const mockDrive = { + files: { + list: jest + .fn() + .mockImplementationOnce(() => { + return { + data: { + files: [] as drive_v3.Schema$FileList, + }, + }; + }) + .mockImplementationOnce(() => { + return {}; + }) + .mockImplementationOnce(() => { + return { + data: { + files: [ + { + noid: 'no-id', + name: 'csv', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + }, + }; + }), + create: jest.fn().mockImplementation(() => { + return { + data: { + id: 'NewlyCreatedCsvFolderId12345', + }, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + 'NewlyCreatedCsvFolderId12345', + ); + expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + 'NewlyCreatedCsvFolderId12345', + ); + expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + 'NewlyCreatedCsvFolderId12345', + ); + }); + + it('should throw an error if the csv folder could not be created', () => { + const mockDrive = { + files: { + list: jest + .fn() + .mockImplementationOnce(() => { + return { + data: { + files: [] as drive_v3.Schema$FileList, + }, + }; + }) + .mockImplementationOnce(() => { + return {}; + }) + .mockImplementationOnce(() => { + return { + data: { + files: [ + { + noid: 'no-id', + name: 'csv', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + }, + }; + }), + create: jest.fn().mockImplementation(() => { + return { + data: {}, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(() => getCsvFolderId(mockDrive, mockConfig)).toThrow(C2gError); + }); + + it('should return null if config.saveOriginalFilesToDrive is false', async () => { + const mockDrive = {} as unknown as drive_v3.Drive; + const mockConfig = { + ...baseConfig, + saveOriginalFilesToDrive: false, + }; + expect(await getCsvFolderId(mockDrive, mockConfig)).toBeNull(); + }); +}); From b5504e580b877fbc544b4984e7c9c3b9057975ff Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Fri, 15 Sep 2023 12:09:23 +0900 Subject: [PATCH 02/31] Switch to `??` nullish coalescing runtime operator from the logical `||` operator --- src/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth.ts b/src/auth.ts index 0e5d6ac..2a8b3cc 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -79,7 +79,7 @@ async function saveToken(client: OAuth2Client): Promise { const credentialsStr = await fs.promises.readFile(CREDENTIALS_PATH, 'utf8'); const parsedCredentials = JSON.parse(credentialsStr) as Credentials; if ('installed' in parsedCredentials || 'web' in parsedCredentials) { - const key = parsedCredentials.installed || parsedCredentials.web; + const key = parsedCredentials.installed ?? parsedCredentials.web; if (!key) { throw new C2gError(MESSAGES.error.c2gErrorInvalidCredentials); } From 73ec0cc39f78596d7d6323205e9755984c07fdf6 Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:19:49 +0900 Subject: [PATCH 03/31] Delete unnecessary line breaks --- src/messages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/messages.ts b/src/messages.ts index 19be6a3..9a7dda0 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -27,7 +27,7 @@ export const MESSAGES = { }, log: { convertingCsvWithFollowingSettings: (configStr: string) => - `Converting local CSV to Google Sheet with the following settings:\n${configStr}`, + `Converting local CSV to Google Sheet with the following settings:\n ${configStr}`, loggingIn: 'Logging in...', noChangesWereMade: 'No changes were made.', openingTargetDriveFolderOnBrowser: (url: string) => @@ -36,7 +36,7 @@ export const MESSAGES = { fileName: string, existingSheetsFileId: string | null, ) => - `\n${ + `${ existingSheetsFileId ? 'Updating' : 'Creating' } Google Sheets file from ${fileName}...`, processingCsvFileComplete: 'Done.', From 45769437ebc61c9ec776c06a7ba12a9d542dae1b Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:20:17 +0900 Subject: [PATCH 04/31] Add isRoot function --- src/commands/convert.ts | 45 ++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 6c7b2f6..9c7c294 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -10,13 +10,13 @@ import { C2gError } from '../c2g-error'; import { Config, CONFIG_FILE_NAME, DEFAULT_CONFIG } from '../constants'; import { MESSAGES } from '../messages'; -interface ConvertCommandOptions { +export interface ConvertCommandOptions { readonly browse?: boolean; readonly configFilePath?: string; readonly dryRun?: boolean; } -interface CsvFileObj { +export interface CsvFileObj { name: string; basename: string; path: string; @@ -94,6 +94,17 @@ export function validateConfig(configObj: Partial): Config { return config; } +/** + * Check if the given target Google Drive folder ID is "root" (case-insensitive). + * If it is, return true. Here, "root" is a special value that refers to the root folder + * in My Drive. + * @param targetDriveFolderId The target Google Drive folder ID + * @returns `true` if the target Google Drive folder ID is "root", or `false` if it isn't + */ +export function isRoot(targetDriveFolderId: string): boolean { + return targetDriveFolderId.toLowerCase() === 'root'; +} + /** * Get the file names of all Google Sheets files in the target Google Drive folder. * Iterate through all pages of the results. @@ -110,7 +121,7 @@ export async function getExistingSheetsFiles( const params: drive_v3.Params$Resource$Files$List = { supportsAllDrives: config.targetIsSharedDrive, q: `'${config.targetDriveFolderId}' in parents and mimeType = 'application/vnd.google-apps.spreadsheet' and trashed = false`, - fields: 'files(id, name)', + fields: 'nextPageToken, files(id, name)', }; if (nextPageToken) { params.pageToken = nextPageToken; @@ -207,13 +218,16 @@ export async function getCsvFolderId( csvFolderList.data.files.length === 0 || !csvFolderList.data.files[0].id ) { + const newCsvFolderRequestBody: drive_v3.Schema$File = { + name: 'csv', + mimeType: 'application/vnd.google-apps.folder', + }; + if (isRoot(config.targetDriveFolderId)) { + newCsvFolderRequestBody.parents = [config.targetDriveFolderId]; + } const newCsvFolder = await drive.files.create({ supportsAllDrives: config.targetIsSharedDrive, - requestBody: { - name: 'csv', - mimeType: 'application/vnd.google-apps.folder', - parents: [config.targetDriveFolderId], - }, + requestBody: newCsvFolderRequestBody, }); if (!newCsvFolder.data.id) { @@ -251,12 +265,12 @@ export default async function convert( MESSAGES.log.convertingCsvWithFollowingSettings( Object.keys(config) .map((key) => `${key}: ${config[key as keyof Config]}`) - .join('\n'), + .join('\n '), ); convertingCsvWithFollowingSettings = options.dryRun ? `${MESSAGES.log.runningOnDryRun}\n\n${convertingCsvWithFollowingSettings}` : convertingCsvWithFollowingSettings; - console.info(convertingCsvWithFollowingSettings + '\n'); + console.info(convertingCsvWithFollowingSettings); // Authorize the user const auth = await authorize(); @@ -294,7 +308,7 @@ export default async function convert( // If config.saveOriginalFilesToDrive is false, csvFolderId will be null if (csvFolderId) { - console.info('\n' + MESSAGES.log.uploadingOriginalCsvFilesTo(csvFolderId)); + console.info(MESSAGES.log.uploadingOriginalCsvFilesTo(csvFolderId)); } for (const csvFileObj of csvFilesObjArray) { @@ -367,11 +381,10 @@ export default async function convert( if (options.browse) { // Open the target Google Drive folder in the default browser if the --browse option is specified - const url = - config.targetDriveFolderId.toLowerCase() === 'root' - ? 'https://drive.google.com/drive/my-drive' - : `https://drive.google.com/drive/folders/${config.targetDriveFolderId}`; - console.info('\n' + MESSAGES.log.openingTargetDriveFolderOnBrowser(url)); + const url = isRoot(config.targetDriveFolderId) + ? 'https://drive.google.com/drive/my-drive' + : `https://drive.google.com/drive/folders/${config.targetDriveFolderId}`; + console.info(MESSAGES.log.openingTargetDriveFolderOnBrowser(url)); await open(url); } } From 9ad12f12e79aacd3119319ff4cad05e45c4a5c31 Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:21:14 +0900 Subject: [PATCH 05/31] Add tests for convert --- test/convert.test.ts | 342 ++++++++++++++++++++++++++++--------------- 1 file changed, 228 insertions(+), 114 deletions(-) diff --git a/test/convert.test.ts b/test/convert.test.ts index 0e2d42b..1147625 100644 --- a/test/convert.test.ts +++ b/test/convert.test.ts @@ -1,19 +1,17 @@ // Jest test for the convert command in ./src/commands/convert.ts import fs from 'fs'; +// import open from 'open'; import path from 'path'; -import { drive_v3 } from 'googleapis'; +// import { ChildProcess } from 'child_process'; +import { google, drive_v3 } from 'googleapis'; +import { OAuth2Client } from 'google-auth-library'; +import * as auth from '../src/auth'; import { Config, DEFAULT_CONFIG, HOME_DIR } from '../src/constants'; -import { - readConfigFileSync, - validateConfig, - getLocalCsvFilePaths, - getExistingSheetsFiles, - getExistingSheetsFileId, - getCsvFolderId, -} from '../src/commands/convert'; +import * as convert from '../src/commands/convert'; import { C2gError } from '../src/c2g-error'; +import { MESSAGES } from '../src/messages'; describe('readConfigFileSync', () => { const configFilePath = '/path/to/config.json'; @@ -32,18 +30,18 @@ describe('readConfigFileSync', () => { }; jest.spyOn(fs, 'existsSync').mockReturnValue(true); jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(config)); - expect(readConfigFileSync(configFilePath)).toEqual(config); + expect(convert.readConfigFileSync(configFilePath)).toEqual(config); }); it('should throw an error if the config file does not exist', () => { jest.spyOn(fs, 'existsSync').mockReturnValue(false); - expect(() => readConfigFileSync(configFilePath)).toThrow(C2gError); + expect(() => convert.readConfigFileSync(configFilePath)).toThrow(C2gError); }); it('should throw an error if the config file is not valid JSON', () => { jest.spyOn(fs, 'existsSync').mockReturnValue(true); jest.spyOn(fs, 'readFileSync').mockReturnValue('not valid JSON'); - expect(() => readConfigFileSync(configFilePath)).toThrow(); + expect(() => convert.readConfigFileSync(configFilePath)).toThrow(); }); }); @@ -56,12 +54,12 @@ describe('validateConfig', () => { updateExistingGoogleSheets: true, saveOriginalFilesToDrive: false, } as Partial; - expect(validateConfig(config)).toEqual(config); + expect(convert.validateConfig(config)).toEqual(config); }); it('should throw an error if sourceDir is not a string', () => { const config = { sourceDir: 123 } as unknown as Partial; - expect(() => validateConfig(config)).toThrow(TypeError); + expect(() => convert.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if sourceDir is not a valid path', () => { @@ -69,85 +67,80 @@ describe('validateConfig', () => { sourceDir: '/path/to/nonexistent/directory', } as Partial; jest.spyOn(fs, 'existsSync').mockReturnValue(false); - expect(() => validateConfig(config)).toThrow(C2gError); + expect(() => convert.validateConfig(config)).toThrow(C2gError); }); it('should throw an error if targetDriveFolderId is not a string', () => { const config = { targetDriveFolderId: 123 } as unknown as Partial; - expect(() => validateConfig(config)).toThrow(TypeError); + expect(() => convert.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if targetIsSharedDrive is not a boolean', () => { const config = { targetIsSharedDrive: 'true', } as unknown as Partial; - expect(() => validateConfig(config)).toThrow(TypeError); + expect(() => convert.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if updateExistingGoogleSheets is not a boolean', () => { const config = { updateExistingGoogleSheets: 'true', } as unknown as Partial; - expect(() => validateConfig(config)).toThrow(TypeError); + expect(() => convert.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if saveOriginalFilesToDrive is not a boolean', () => { const config = { saveOriginalFilesToDrive: 'false', } as unknown as Partial; - expect(() => validateConfig(config)).toThrow(TypeError); + expect(() => convert.validateConfig(config)).toThrow(TypeError); }); it('should add default values for missing config properties', () => { const config = {} as Partial; - expect(validateConfig(config)).toEqual(DEFAULT_CONFIG); + expect(convert.validateConfig(config)).toEqual(DEFAULT_CONFIG); }); }); -describe('getLocalCsvFilePaths', () => { - const testDir = path.join(__dirname, 'testDir'); - const testDir2 = path.join(__dirname, 'testDir2'); - - beforeAll(() => { - // Create a test directory with some CSV files - fs.mkdirSync(testDir); - fs.mkdirSync(testDir2); - fs.writeFileSync(path.join(testDir, 'file1.csv'), ''); - fs.writeFileSync(path.join(testDir, 'file2.CSV'), ''); - fs.writeFileSync(path.join(testDir, 'file3.txt'), ''); +describe('isRoot', () => { + it('should return true if targetDriveFolderId is "root" (case-insensitive)', () => { + expect(convert.isRoot('root')).toBe(true); + expect(convert.isRoot('ROOT')).toBe(true); + expect(convert.isRoot('Root')).toBe(true); }); - - afterAll(() => { - // Remove the test directory and its contents - fs.rmSync(testDir, { recursive: true }); - fs.rmSync(testDir2, { recursive: true }); + it('should return false if targetDriveFolderId is not "root" or "ROOT"', () => { + expect(convert.isRoot('12345')).toBe(false); }); +}); + +describe('getLocalCsvFilePaths', () => { + jest.mock('fs'); + const testDir = path.join(process.cwd(), 'testDir'); it('should return an array with the full path of a single CSV file', () => { - const csvFiles = getLocalCsvFilePaths(path.join(testDir, 'file1.csv')); - expect(csvFiles).toEqual([path.join(testDir, 'file1.csv')]); + const mockSingleCsvFilePath = path.join(testDir, 'file1.csv'); + const csvFiles = convert.getLocalCsvFilePaths(mockSingleCsvFilePath); + expect(csvFiles).toEqual([mockSingleCsvFilePath]); }); it('should return an array with the full path of all CSV files in a directory', () => { - const csvFiles = getLocalCsvFilePaths(testDir); - expect(csvFiles).toEqual([ - path.join(testDir, 'file1.csv'), - path.join(testDir, 'file2.CSV'), - ]); + const mockTestFiles = ['file1.csv', 'file2.CSV', 'file3.txt']; + const mockCsvFiles = ['file1.csv', 'file2.CSV']; + const mockCsvFilePaths = mockCsvFiles.map((file) => + path.join(testDir, file), + ); + jest + .spyOn(fs, 'readdirSync') + .mockReturnValue(mockTestFiles as unknown as fs.Dirent[]); + const csvFiles = convert.getLocalCsvFilePaths(testDir); + expect(csvFiles).toEqual(mockCsvFilePaths); }); it('should return an empty array if there are no CSV files in a directory', () => { - const csvFiles = getLocalCsvFilePaths(testDir2); + jest.spyOn(fs, 'readdirSync').mockReturnValue([]); + const csvFiles = convert.getLocalCsvFilePaths(testDir); expect(csvFiles).toEqual([]); }); - - it('should return an "ENOTDIR: not a directory" error if the given path is neither a CSV file path or a directory path', () => { - expect(() => { - getLocalCsvFilePaths(path.join(testDir, 'file3.txt')); - }).toThrowError( - `ENOTDIR: not a directory, scandir '${path.join(testDir, 'file3.txt')}'`, - ); - }); }); describe('getExistingSheetsFiles', () => { @@ -186,16 +179,18 @@ describe('getExistingSheetsFiles', () => { } as unknown as drive_v3.Resource$Files, } as unknown as drive_v3.Drive; const mockConfig = baseConfig; - expect(await getExistingSheetsFiles(mockDrive, mockConfig)).toEqual([ - { - id: '12345', - name: 'file1', - }, - { - id: '67890', - name: 'file2', - }, - ]); + expect(await convert.getExistingSheetsFiles(mockDrive, mockConfig)).toEqual( + [ + { + id: '12345', + name: 'file1', + }, + { + id: '67890', + name: 'file2', + }, + ], + ); }); it('should return an array of existing Google Sheets files with recursive calls using nextPageToken', async () => { @@ -235,20 +230,22 @@ describe('getExistingSheetsFiles', () => { } as unknown as drive_v3.Resource$Files, } as unknown as drive_v3.Drive; const mockConfig = baseConfig; - expect(await getExistingSheetsFiles(mockDrive, mockConfig)).toEqual([ - { - id: '12345', - name: 'file1', - }, - { - id: '67890', - name: 'file2', - }, - { - id: 'abcde', - name: 'file3', - }, - ]); + expect(await convert.getExistingSheetsFiles(mockDrive, mockConfig)).toEqual( + [ + { + id: '12345', + name: 'file1', + }, + { + id: '67890', + name: 'file2', + }, + { + id: 'abcde', + name: 'file3', + }, + ], + ); }); it('should return the original fileList if config.updateExistingGoogleSheets is false', async () => { @@ -267,7 +264,7 @@ describe('getExistingSheetsFiles', () => { }, ] as unknown as drive_v3.Schema$File[]; expect( - await getExistingSheetsFiles(mockDrive, mockConfig, mockFileList), + await convert.getExistingSheetsFiles(mockDrive, mockConfig, mockFileList), ).toEqual(mockFileList); }); }); @@ -285,26 +282,26 @@ describe('getExistingSheetsFileId', () => { const mockEmptyExistingSheetsFiles = [] as unknown as drive_v3.Schema$File[]; it('should return the file ID if the file exists', () => { - expect(getExistingSheetsFileId('file1', mockExistingSheetsFiles)).toBe( - '12345', - ); + expect( + convert.getExistingSheetsFileId('file1', mockExistingSheetsFiles), + ).toBe('12345'); }); it('should return null if the existing file does not have a valid ID', () => { expect( - getExistingSheetsFileId('file2', mockExistingSheetsFiles), + convert.getExistingSheetsFileId('file2', mockExistingSheetsFiles), ).toBeNull(); }); it('should return null if the file does not exist', () => { expect( - getExistingSheetsFileId('file99', mockExistingSheetsFiles), + convert.getExistingSheetsFileId('file99', mockExistingSheetsFiles), ).toBeNull(); }); it('should return null if the array existingSheetsFiles has the length of 0', () => { expect( - getExistingSheetsFileId('file1', mockEmptyExistingSheetsFiles), + convert.getExistingSheetsFileId('file1', mockEmptyExistingSheetsFiles), ).toBeNull(); }); }); @@ -345,7 +342,7 @@ describe('getCsvFolderId', () => { } as unknown as drive_v3.Resource$Files, } as unknown as drive_v3.Drive; const mockConfig = baseConfig; - expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( 'CsvFolderId12345', ); }); @@ -387,44 +384,27 @@ describe('getCsvFolderId', () => { } as unknown as drive_v3.Resource$Files, } as unknown as drive_v3.Drive; const mockConfig = baseConfig; - expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( 'NewlyCreatedCsvFolderId12345', ); - expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( 'NewlyCreatedCsvFolderId12345', ); - expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( 'NewlyCreatedCsvFolderId12345', ); }); - it('should throw an error if the csv folder could not be created', () => { + it('should throw an error if the csv folder could not be created', async () => { const mockDrive = { files: { - list: jest - .fn() - .mockImplementationOnce(() => { - return { - data: { - files: [] as drive_v3.Schema$FileList, - }, - }; - }) - .mockImplementationOnce(() => { - return {}; - }) - .mockImplementationOnce(() => { - return { - data: { - files: [ - { - noid: 'no-id', - name: 'csv', - } as drive_v3.Schema$File, - ] as drive_v3.Schema$FileList, - }, - }; - }), + list: jest.fn().mockImplementation(() => { + return { + data: { + files: [] as drive_v3.Schema$FileList, + }, + }; + }), create: jest.fn().mockImplementation(() => { return { data: {}, @@ -433,7 +413,9 @@ describe('getCsvFolderId', () => { } as unknown as drive_v3.Resource$Files, } as unknown as drive_v3.Drive; const mockConfig = baseConfig; - expect(() => getCsvFolderId(mockDrive, mockConfig)).toThrow(C2gError); + await expect(convert.getCsvFolderId(mockDrive, mockConfig)).rejects.toThrow( + C2gError, + ); }); it('should return null if config.saveOriginalFilesToDrive is false', async () => { @@ -442,6 +424,138 @@ describe('getCsvFolderId', () => { ...baseConfig, saveOriginalFilesToDrive: false, }; - expect(await getCsvFolderId(mockDrive, mockConfig)).toBeNull(); + expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBeNull(); + }); +}); + +describe('convert', () => { + jest.mock('googleapis'); + jest.mock('fs'); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const mockConfig: Config = { + sourceDir: path.join(process.cwd(), 'testCsvDir'), + targetDriveFolderId: 'TargetDriveFolderId12345', + targetIsSharedDrive: true, + updateExistingGoogleSheets: false, + saveOriginalFilesToDrive: false, + }; + + it('should throw an error if the user is not logged in', async () => { + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => false); + await expect(convert.default({})).rejects.toThrow( + new C2gError(MESSAGES.error.c2gErrorNotLoggedIn), + ); + }); + + it('should throw an error if there are no CSV files in the designated local directory', async () => { + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); + jest.spyOn(console, 'info').mockImplementation(); + jest.spyOn(fs, 'existsSync').mockReturnValue(true); // readConfigFileSync, validateConfig + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); // readConfigFileSync + jest.spyOn(fs, 'readdirSync').mockReturnValue([]); // getLocalCsvFilePaths + jest.spyOn(auth, 'authorize').mockImplementation(() => { + return Promise.resolve({} as unknown as OAuth2Client); + }); + jest.spyOn(google, 'drive').mockImplementation(() => { + return {} as unknown as drive_v3.Drive; + }); + await expect(convert.default({})).rejects.toThrow( + new C2gError(MESSAGES.error.c2gErrorNoCsvFilesFound), + ); + }); + + it('should show the complete conversion process on dry run', async () => { + // Arrange + const mockLocalCsvFiles = ['file1.csv', 'file2.CSV']; + const mockLocalCsvFilePaths = mockLocalCsvFiles.map((file) => + path.join(mockConfig.sourceDir, file), + ); + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); + jest.spyOn(console, 'info').mockImplementation(); + jest.spyOn(fs, 'existsSync').mockReturnValue(true); // readConfigFileSync, validateConfig + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); // readConfigFileSync + jest + .spyOn(fs, 'readdirSync') + .mockReturnValue(mockLocalCsvFilePaths as unknown as fs.Dirent[]); // getLocalCsvFilePaths + jest.spyOn(auth, 'authorize').mockImplementation(() => { + return Promise.resolve({} as unknown as OAuth2Client); + }); + jest.spyOn(google, 'drive').mockImplementation(() => { + return {} as unknown as drive_v3.Drive; + }); + // Act + await convert.default({ dryRun: true }); + // Assert + expect(console.info).toHaveBeenNthCalledWith( + 1, + `${ + MESSAGES.log.runningOnDryRun + }\n\n${MESSAGES.log.convertingCsvWithFollowingSettings( + Object.keys(mockConfig) + .map((key) => `${key}: ${mockConfig[key as keyof Config]}`) + .join('\n '), + )}`, + ); + expect(console.info).toHaveBeenNthCalledWith( + 2, + MESSAGES.log.processingCsvFile(mockLocalCsvFiles[0], null), + ); + expect(console.info).toHaveBeenNthCalledWith( + 3, + MESSAGES.log.processingCsvFileComplete, + ); + expect(console.info).toHaveBeenNthCalledWith( + 4, + MESSAGES.log.processingCsvFile(mockLocalCsvFiles[1], null), + ); + expect(console.info).toHaveBeenNthCalledWith( + 5, + MESSAGES.log.processingCsvFileComplete, + ); + }); + /* + it('should open the default web browser if --browse option is enabled', async () => { + // Arrange + //jest.mock('open', () => jest.fn()); + //const mockOpen = open as jest.MockedFunction; + // mockOpen.mockResolvedValue({ pid: 12345 } as unknown as ChildProcess); + // jest.spyOn(open, 'default'); + // Other arranged settings are the same as the previous test: + // 'should show the complete conversion process on dry run' + const mockLocalCsvFiles = ['file1.csv', 'file2.CSV']; + const mockLocalCsvFilePaths = mockLocalCsvFiles.map((file) => + path.join(mockConfig.sourceDir, file), + ); + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); + jest.spyOn(console, 'info').mockImplementation(); + jest.spyOn(fs, 'existsSync').mockReturnValue(true); // readConfigFileSync, validateConfig + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); // readConfigFileSync + jest + .spyOn(fs, 'readdirSync') + .mockReturnValue(mockLocalCsvFilePaths as unknown as fs.Dirent[]); // getLocalCsvFilePaths + jest.spyOn(auth, 'authorize').mockImplementation(() => { + return Promise.resolve({} as unknown as OAuth2Client); + }); + jest.spyOn(google, 'drive').mockImplementation(() => { + return {} as unknown as drive_v3.Drive; + }); + // Act + await convert.default({ dryRun: true, browse: true }); + // Assert + expect(mockOpen as jest.Mock).toHaveBeenCalled(); + expect(mockOpen).toHaveBeenCalledWith( + `https://drive.google.com/drive/folders/${mockConfig.targetDriveFolderId}`, + ); + expect(console.info).toHaveBeenNthCalledWith( + 6, + MESSAGES.log.openingTargetDriveFolderOnBrowser( + `https://drive.google.com/drive/folders/${mockConfig.targetDriveFolderId}`, + ), + ); }); + */ }); From 7a323d04ce48c8508b3a1b347144962ae364073d Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Fri, 15 Sep 2023 09:09:53 +0900 Subject: [PATCH 06/31] Add tests: getExistingSheetsFiles, getCsvFolderId --- test/convert.test.ts | 261 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) diff --git a/test/convert.test.ts b/test/convert.test.ts index 5db5f5f..0e2d42b 100644 --- a/test/convert.test.ts +++ b/test/convert.test.ts @@ -9,7 +9,9 @@ import { readConfigFileSync, validateConfig, getLocalCsvFilePaths, + getExistingSheetsFiles, getExistingSheetsFileId, + getCsvFolderId, } from '../src/commands/convert'; import { C2gError } from '../src/c2g-error'; @@ -148,6 +150,128 @@ describe('getLocalCsvFilePaths', () => { }); }); +describe('getExistingSheetsFiles', () => { + jest.mock('googleapis'); + afterEach(() => { + jest.restoreAllMocks(); + }); + + const baseConfig: Config = { + sourceDir: '/path/to/source', + targetDriveFolderId: '12345', + targetIsSharedDrive: true, + updateExistingGoogleSheets: true, + saveOriginalFilesToDrive: false, + }; + + it('should return an array of existing Google Sheets files without nextPageToken', async () => { + const mockDrive = { + files: { + list: jest.fn().mockImplementation(() => { + return { + data: { + files: [ + { + id: '12345', + name: 'file1', + } as drive_v3.Schema$File, + { + id: '67890', + name: 'file2', + } as drive_v3.Schema$File, + ], + } as drive_v3.Schema$FileList, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await getExistingSheetsFiles(mockDrive, mockConfig)).toEqual([ + { + id: '12345', + name: 'file1', + }, + { + id: '67890', + name: 'file2', + }, + ]); + }); + + it('should return an array of existing Google Sheets files with recursive calls using nextPageToken', async () => { + const mockDrive = { + files: { + list: jest + .fn() + .mockImplementationOnce(() => { + return { + data: { + files: [ + { + id: '12345', + name: 'file1', + } as drive_v3.Schema$File, + { + id: '67890', + name: 'file2', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + nextPageToken: 'nextPageToken123', + }, + }; + }) + .mockImplementationOnce(() => { + return { + data: { + files: [ + { + id: 'abcde', + name: 'file3', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + }, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await getExistingSheetsFiles(mockDrive, mockConfig)).toEqual([ + { + id: '12345', + name: 'file1', + }, + { + id: '67890', + name: 'file2', + }, + { + id: 'abcde', + name: 'file3', + }, + ]); + }); + + it('should return the original fileList if config.updateExistingGoogleSheets is false', async () => { + const mockDrive = {} as unknown as drive_v3.Drive; + const mockConfig = { + ...baseConfig, + updateExistingGoogleSheets: false, + }; + const mockFileList = [ + { + id: '12345', + name: 'file1', + }, + { + name: 'file2', + }, + ] as unknown as drive_v3.Schema$File[]; + expect( + await getExistingSheetsFiles(mockDrive, mockConfig, mockFileList), + ).toEqual(mockFileList); + }); +}); + describe('getExistingSheetsFileId', () => { const mockExistingSheetsFiles = [ { @@ -184,3 +308,140 @@ describe('getExistingSheetsFileId', () => { ).toBeNull(); }); }); + +describe('getCsvFolderId', () => { + jest.mock('googleapis'); + afterEach(() => { + jest.restoreAllMocks(); + }); + + const baseConfig: Config = { + sourceDir: '/path/to/source', + targetDriveFolderId: 'TargetDriveFolderId12345', + targetIsSharedDrive: true, + updateExistingGoogleSheets: true, + saveOriginalFilesToDrive: true, + }; + + it('should return the ID of the csv folder if config.saveOriginalFilesToDrive is false and it exists', async () => { + const mockDrive = { + files: { + list: jest.fn().mockImplementation(() => { + return { + data: { + files: [ + { + id: 'CsvFolderId12345', + name: 'csv', + } as drive_v3.Schema$File, + { + id: 'OtherFolderId67890', + name: 'csv', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + }, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + 'CsvFolderId12345', + ); + }); + + it('should create a new folder in the target Google Drive folder and return its ID', async () => { + const mockDrive = { + files: { + list: jest + .fn() + .mockImplementationOnce(() => { + return { + data: { + files: [] as drive_v3.Schema$FileList, + }, + }; + }) + .mockImplementationOnce(() => { + return {}; + }) + .mockImplementationOnce(() => { + return { + data: { + files: [ + { + noid: 'no-id', + name: 'csv', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + }, + }; + }), + create: jest.fn().mockImplementation(() => { + return { + data: { + id: 'NewlyCreatedCsvFolderId12345', + }, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + 'NewlyCreatedCsvFolderId12345', + ); + expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + 'NewlyCreatedCsvFolderId12345', + ); + expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + 'NewlyCreatedCsvFolderId12345', + ); + }); + + it('should throw an error if the csv folder could not be created', () => { + const mockDrive = { + files: { + list: jest + .fn() + .mockImplementationOnce(() => { + return { + data: { + files: [] as drive_v3.Schema$FileList, + }, + }; + }) + .mockImplementationOnce(() => { + return {}; + }) + .mockImplementationOnce(() => { + return { + data: { + files: [ + { + noid: 'no-id', + name: 'csv', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + }, + }; + }), + create: jest.fn().mockImplementation(() => { + return { + data: {}, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(() => getCsvFolderId(mockDrive, mockConfig)).toThrow(C2gError); + }); + + it('should return null if config.saveOriginalFilesToDrive is false', async () => { + const mockDrive = {} as unknown as drive_v3.Drive; + const mockConfig = { + ...baseConfig, + saveOriginalFilesToDrive: false, + }; + expect(await getCsvFolderId(mockDrive, mockConfig)).toBeNull(); + }); +}); From 89d59e9cd0a8a67c6d5dda267f26d79bc4d39d57 Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Fri, 15 Sep 2023 12:09:23 +0900 Subject: [PATCH 07/31] Switch to `??` nullish coalescing runtime operator from the logical `||` operator --- src/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth.ts b/src/auth.ts index 0e5d6ac..2a8b3cc 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -79,7 +79,7 @@ async function saveToken(client: OAuth2Client): Promise { const credentialsStr = await fs.promises.readFile(CREDENTIALS_PATH, 'utf8'); const parsedCredentials = JSON.parse(credentialsStr) as Credentials; if ('installed' in parsedCredentials || 'web' in parsedCredentials) { - const key = parsedCredentials.installed || parsedCredentials.web; + const key = parsedCredentials.installed ?? parsedCredentials.web; if (!key) { throw new C2gError(MESSAGES.error.c2gErrorInvalidCredentials); } From a5071ebb31ff389d76b2b274d3b1c17ea5b7cbd9 Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:19:49 +0900 Subject: [PATCH 08/31] Delete unnecessary line breaks --- src/messages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/messages.ts b/src/messages.ts index 19be6a3..9a7dda0 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -27,7 +27,7 @@ export const MESSAGES = { }, log: { convertingCsvWithFollowingSettings: (configStr: string) => - `Converting local CSV to Google Sheet with the following settings:\n${configStr}`, + `Converting local CSV to Google Sheet with the following settings:\n ${configStr}`, loggingIn: 'Logging in...', noChangesWereMade: 'No changes were made.', openingTargetDriveFolderOnBrowser: (url: string) => @@ -36,7 +36,7 @@ export const MESSAGES = { fileName: string, existingSheetsFileId: string | null, ) => - `\n${ + `${ existingSheetsFileId ? 'Updating' : 'Creating' } Google Sheets file from ${fileName}...`, processingCsvFileComplete: 'Done.', From c90f7a37f93d2f7234738443edad002f1057feda Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:20:17 +0900 Subject: [PATCH 09/31] Add isRoot function --- src/commands/convert.ts | 45 ++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 6c7b2f6..9c7c294 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -10,13 +10,13 @@ import { C2gError } from '../c2g-error'; import { Config, CONFIG_FILE_NAME, DEFAULT_CONFIG } from '../constants'; import { MESSAGES } from '../messages'; -interface ConvertCommandOptions { +export interface ConvertCommandOptions { readonly browse?: boolean; readonly configFilePath?: string; readonly dryRun?: boolean; } -interface CsvFileObj { +export interface CsvFileObj { name: string; basename: string; path: string; @@ -94,6 +94,17 @@ export function validateConfig(configObj: Partial): Config { return config; } +/** + * Check if the given target Google Drive folder ID is "root" (case-insensitive). + * If it is, return true. Here, "root" is a special value that refers to the root folder + * in My Drive. + * @param targetDriveFolderId The target Google Drive folder ID + * @returns `true` if the target Google Drive folder ID is "root", or `false` if it isn't + */ +export function isRoot(targetDriveFolderId: string): boolean { + return targetDriveFolderId.toLowerCase() === 'root'; +} + /** * Get the file names of all Google Sheets files in the target Google Drive folder. * Iterate through all pages of the results. @@ -110,7 +121,7 @@ export async function getExistingSheetsFiles( const params: drive_v3.Params$Resource$Files$List = { supportsAllDrives: config.targetIsSharedDrive, q: `'${config.targetDriveFolderId}' in parents and mimeType = 'application/vnd.google-apps.spreadsheet' and trashed = false`, - fields: 'files(id, name)', + fields: 'nextPageToken, files(id, name)', }; if (nextPageToken) { params.pageToken = nextPageToken; @@ -207,13 +218,16 @@ export async function getCsvFolderId( csvFolderList.data.files.length === 0 || !csvFolderList.data.files[0].id ) { + const newCsvFolderRequestBody: drive_v3.Schema$File = { + name: 'csv', + mimeType: 'application/vnd.google-apps.folder', + }; + if (isRoot(config.targetDriveFolderId)) { + newCsvFolderRequestBody.parents = [config.targetDriveFolderId]; + } const newCsvFolder = await drive.files.create({ supportsAllDrives: config.targetIsSharedDrive, - requestBody: { - name: 'csv', - mimeType: 'application/vnd.google-apps.folder', - parents: [config.targetDriveFolderId], - }, + requestBody: newCsvFolderRequestBody, }); if (!newCsvFolder.data.id) { @@ -251,12 +265,12 @@ export default async function convert( MESSAGES.log.convertingCsvWithFollowingSettings( Object.keys(config) .map((key) => `${key}: ${config[key as keyof Config]}`) - .join('\n'), + .join('\n '), ); convertingCsvWithFollowingSettings = options.dryRun ? `${MESSAGES.log.runningOnDryRun}\n\n${convertingCsvWithFollowingSettings}` : convertingCsvWithFollowingSettings; - console.info(convertingCsvWithFollowingSettings + '\n'); + console.info(convertingCsvWithFollowingSettings); // Authorize the user const auth = await authorize(); @@ -294,7 +308,7 @@ export default async function convert( // If config.saveOriginalFilesToDrive is false, csvFolderId will be null if (csvFolderId) { - console.info('\n' + MESSAGES.log.uploadingOriginalCsvFilesTo(csvFolderId)); + console.info(MESSAGES.log.uploadingOriginalCsvFilesTo(csvFolderId)); } for (const csvFileObj of csvFilesObjArray) { @@ -367,11 +381,10 @@ export default async function convert( if (options.browse) { // Open the target Google Drive folder in the default browser if the --browse option is specified - const url = - config.targetDriveFolderId.toLowerCase() === 'root' - ? 'https://drive.google.com/drive/my-drive' - : `https://drive.google.com/drive/folders/${config.targetDriveFolderId}`; - console.info('\n' + MESSAGES.log.openingTargetDriveFolderOnBrowser(url)); + const url = isRoot(config.targetDriveFolderId) + ? 'https://drive.google.com/drive/my-drive' + : `https://drive.google.com/drive/folders/${config.targetDriveFolderId}`; + console.info(MESSAGES.log.openingTargetDriveFolderOnBrowser(url)); await open(url); } } From 67e30908c1794f7476491401fccbe0ffc8b5ffe2 Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:21:14 +0900 Subject: [PATCH 10/31] Add tests for convert --- test/convert.test.ts | 342 ++++++++++++++++++++++++++++--------------- 1 file changed, 228 insertions(+), 114 deletions(-) diff --git a/test/convert.test.ts b/test/convert.test.ts index 0e2d42b..1147625 100644 --- a/test/convert.test.ts +++ b/test/convert.test.ts @@ -1,19 +1,17 @@ // Jest test for the convert command in ./src/commands/convert.ts import fs from 'fs'; +// import open from 'open'; import path from 'path'; -import { drive_v3 } from 'googleapis'; +// import { ChildProcess } from 'child_process'; +import { google, drive_v3 } from 'googleapis'; +import { OAuth2Client } from 'google-auth-library'; +import * as auth from '../src/auth'; import { Config, DEFAULT_CONFIG, HOME_DIR } from '../src/constants'; -import { - readConfigFileSync, - validateConfig, - getLocalCsvFilePaths, - getExistingSheetsFiles, - getExistingSheetsFileId, - getCsvFolderId, -} from '../src/commands/convert'; +import * as convert from '../src/commands/convert'; import { C2gError } from '../src/c2g-error'; +import { MESSAGES } from '../src/messages'; describe('readConfigFileSync', () => { const configFilePath = '/path/to/config.json'; @@ -32,18 +30,18 @@ describe('readConfigFileSync', () => { }; jest.spyOn(fs, 'existsSync').mockReturnValue(true); jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(config)); - expect(readConfigFileSync(configFilePath)).toEqual(config); + expect(convert.readConfigFileSync(configFilePath)).toEqual(config); }); it('should throw an error if the config file does not exist', () => { jest.spyOn(fs, 'existsSync').mockReturnValue(false); - expect(() => readConfigFileSync(configFilePath)).toThrow(C2gError); + expect(() => convert.readConfigFileSync(configFilePath)).toThrow(C2gError); }); it('should throw an error if the config file is not valid JSON', () => { jest.spyOn(fs, 'existsSync').mockReturnValue(true); jest.spyOn(fs, 'readFileSync').mockReturnValue('not valid JSON'); - expect(() => readConfigFileSync(configFilePath)).toThrow(); + expect(() => convert.readConfigFileSync(configFilePath)).toThrow(); }); }); @@ -56,12 +54,12 @@ describe('validateConfig', () => { updateExistingGoogleSheets: true, saveOriginalFilesToDrive: false, } as Partial; - expect(validateConfig(config)).toEqual(config); + expect(convert.validateConfig(config)).toEqual(config); }); it('should throw an error if sourceDir is not a string', () => { const config = { sourceDir: 123 } as unknown as Partial; - expect(() => validateConfig(config)).toThrow(TypeError); + expect(() => convert.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if sourceDir is not a valid path', () => { @@ -69,85 +67,80 @@ describe('validateConfig', () => { sourceDir: '/path/to/nonexistent/directory', } as Partial; jest.spyOn(fs, 'existsSync').mockReturnValue(false); - expect(() => validateConfig(config)).toThrow(C2gError); + expect(() => convert.validateConfig(config)).toThrow(C2gError); }); it('should throw an error if targetDriveFolderId is not a string', () => { const config = { targetDriveFolderId: 123 } as unknown as Partial; - expect(() => validateConfig(config)).toThrow(TypeError); + expect(() => convert.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if targetIsSharedDrive is not a boolean', () => { const config = { targetIsSharedDrive: 'true', } as unknown as Partial; - expect(() => validateConfig(config)).toThrow(TypeError); + expect(() => convert.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if updateExistingGoogleSheets is not a boolean', () => { const config = { updateExistingGoogleSheets: 'true', } as unknown as Partial; - expect(() => validateConfig(config)).toThrow(TypeError); + expect(() => convert.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if saveOriginalFilesToDrive is not a boolean', () => { const config = { saveOriginalFilesToDrive: 'false', } as unknown as Partial; - expect(() => validateConfig(config)).toThrow(TypeError); + expect(() => convert.validateConfig(config)).toThrow(TypeError); }); it('should add default values for missing config properties', () => { const config = {} as Partial; - expect(validateConfig(config)).toEqual(DEFAULT_CONFIG); + expect(convert.validateConfig(config)).toEqual(DEFAULT_CONFIG); }); }); -describe('getLocalCsvFilePaths', () => { - const testDir = path.join(__dirname, 'testDir'); - const testDir2 = path.join(__dirname, 'testDir2'); - - beforeAll(() => { - // Create a test directory with some CSV files - fs.mkdirSync(testDir); - fs.mkdirSync(testDir2); - fs.writeFileSync(path.join(testDir, 'file1.csv'), ''); - fs.writeFileSync(path.join(testDir, 'file2.CSV'), ''); - fs.writeFileSync(path.join(testDir, 'file3.txt'), ''); +describe('isRoot', () => { + it('should return true if targetDriveFolderId is "root" (case-insensitive)', () => { + expect(convert.isRoot('root')).toBe(true); + expect(convert.isRoot('ROOT')).toBe(true); + expect(convert.isRoot('Root')).toBe(true); }); - - afterAll(() => { - // Remove the test directory and its contents - fs.rmSync(testDir, { recursive: true }); - fs.rmSync(testDir2, { recursive: true }); + it('should return false if targetDriveFolderId is not "root" or "ROOT"', () => { + expect(convert.isRoot('12345')).toBe(false); }); +}); + +describe('getLocalCsvFilePaths', () => { + jest.mock('fs'); + const testDir = path.join(process.cwd(), 'testDir'); it('should return an array with the full path of a single CSV file', () => { - const csvFiles = getLocalCsvFilePaths(path.join(testDir, 'file1.csv')); - expect(csvFiles).toEqual([path.join(testDir, 'file1.csv')]); + const mockSingleCsvFilePath = path.join(testDir, 'file1.csv'); + const csvFiles = convert.getLocalCsvFilePaths(mockSingleCsvFilePath); + expect(csvFiles).toEqual([mockSingleCsvFilePath]); }); it('should return an array with the full path of all CSV files in a directory', () => { - const csvFiles = getLocalCsvFilePaths(testDir); - expect(csvFiles).toEqual([ - path.join(testDir, 'file1.csv'), - path.join(testDir, 'file2.CSV'), - ]); + const mockTestFiles = ['file1.csv', 'file2.CSV', 'file3.txt']; + const mockCsvFiles = ['file1.csv', 'file2.CSV']; + const mockCsvFilePaths = mockCsvFiles.map((file) => + path.join(testDir, file), + ); + jest + .spyOn(fs, 'readdirSync') + .mockReturnValue(mockTestFiles as unknown as fs.Dirent[]); + const csvFiles = convert.getLocalCsvFilePaths(testDir); + expect(csvFiles).toEqual(mockCsvFilePaths); }); it('should return an empty array if there are no CSV files in a directory', () => { - const csvFiles = getLocalCsvFilePaths(testDir2); + jest.spyOn(fs, 'readdirSync').mockReturnValue([]); + const csvFiles = convert.getLocalCsvFilePaths(testDir); expect(csvFiles).toEqual([]); }); - - it('should return an "ENOTDIR: not a directory" error if the given path is neither a CSV file path or a directory path', () => { - expect(() => { - getLocalCsvFilePaths(path.join(testDir, 'file3.txt')); - }).toThrowError( - `ENOTDIR: not a directory, scandir '${path.join(testDir, 'file3.txt')}'`, - ); - }); }); describe('getExistingSheetsFiles', () => { @@ -186,16 +179,18 @@ describe('getExistingSheetsFiles', () => { } as unknown as drive_v3.Resource$Files, } as unknown as drive_v3.Drive; const mockConfig = baseConfig; - expect(await getExistingSheetsFiles(mockDrive, mockConfig)).toEqual([ - { - id: '12345', - name: 'file1', - }, - { - id: '67890', - name: 'file2', - }, - ]); + expect(await convert.getExistingSheetsFiles(mockDrive, mockConfig)).toEqual( + [ + { + id: '12345', + name: 'file1', + }, + { + id: '67890', + name: 'file2', + }, + ], + ); }); it('should return an array of existing Google Sheets files with recursive calls using nextPageToken', async () => { @@ -235,20 +230,22 @@ describe('getExistingSheetsFiles', () => { } as unknown as drive_v3.Resource$Files, } as unknown as drive_v3.Drive; const mockConfig = baseConfig; - expect(await getExistingSheetsFiles(mockDrive, mockConfig)).toEqual([ - { - id: '12345', - name: 'file1', - }, - { - id: '67890', - name: 'file2', - }, - { - id: 'abcde', - name: 'file3', - }, - ]); + expect(await convert.getExistingSheetsFiles(mockDrive, mockConfig)).toEqual( + [ + { + id: '12345', + name: 'file1', + }, + { + id: '67890', + name: 'file2', + }, + { + id: 'abcde', + name: 'file3', + }, + ], + ); }); it('should return the original fileList if config.updateExistingGoogleSheets is false', async () => { @@ -267,7 +264,7 @@ describe('getExistingSheetsFiles', () => { }, ] as unknown as drive_v3.Schema$File[]; expect( - await getExistingSheetsFiles(mockDrive, mockConfig, mockFileList), + await convert.getExistingSheetsFiles(mockDrive, mockConfig, mockFileList), ).toEqual(mockFileList); }); }); @@ -285,26 +282,26 @@ describe('getExistingSheetsFileId', () => { const mockEmptyExistingSheetsFiles = [] as unknown as drive_v3.Schema$File[]; it('should return the file ID if the file exists', () => { - expect(getExistingSheetsFileId('file1', mockExistingSheetsFiles)).toBe( - '12345', - ); + expect( + convert.getExistingSheetsFileId('file1', mockExistingSheetsFiles), + ).toBe('12345'); }); it('should return null if the existing file does not have a valid ID', () => { expect( - getExistingSheetsFileId('file2', mockExistingSheetsFiles), + convert.getExistingSheetsFileId('file2', mockExistingSheetsFiles), ).toBeNull(); }); it('should return null if the file does not exist', () => { expect( - getExistingSheetsFileId('file99', mockExistingSheetsFiles), + convert.getExistingSheetsFileId('file99', mockExistingSheetsFiles), ).toBeNull(); }); it('should return null if the array existingSheetsFiles has the length of 0', () => { expect( - getExistingSheetsFileId('file1', mockEmptyExistingSheetsFiles), + convert.getExistingSheetsFileId('file1', mockEmptyExistingSheetsFiles), ).toBeNull(); }); }); @@ -345,7 +342,7 @@ describe('getCsvFolderId', () => { } as unknown as drive_v3.Resource$Files, } as unknown as drive_v3.Drive; const mockConfig = baseConfig; - expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( 'CsvFolderId12345', ); }); @@ -387,44 +384,27 @@ describe('getCsvFolderId', () => { } as unknown as drive_v3.Resource$Files, } as unknown as drive_v3.Drive; const mockConfig = baseConfig; - expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( 'NewlyCreatedCsvFolderId12345', ); - expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( 'NewlyCreatedCsvFolderId12345', ); - expect(await getCsvFolderId(mockDrive, mockConfig)).toBe( + expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( 'NewlyCreatedCsvFolderId12345', ); }); - it('should throw an error if the csv folder could not be created', () => { + it('should throw an error if the csv folder could not be created', async () => { const mockDrive = { files: { - list: jest - .fn() - .mockImplementationOnce(() => { - return { - data: { - files: [] as drive_v3.Schema$FileList, - }, - }; - }) - .mockImplementationOnce(() => { - return {}; - }) - .mockImplementationOnce(() => { - return { - data: { - files: [ - { - noid: 'no-id', - name: 'csv', - } as drive_v3.Schema$File, - ] as drive_v3.Schema$FileList, - }, - }; - }), + list: jest.fn().mockImplementation(() => { + return { + data: { + files: [] as drive_v3.Schema$FileList, + }, + }; + }), create: jest.fn().mockImplementation(() => { return { data: {}, @@ -433,7 +413,9 @@ describe('getCsvFolderId', () => { } as unknown as drive_v3.Resource$Files, } as unknown as drive_v3.Drive; const mockConfig = baseConfig; - expect(() => getCsvFolderId(mockDrive, mockConfig)).toThrow(C2gError); + await expect(convert.getCsvFolderId(mockDrive, mockConfig)).rejects.toThrow( + C2gError, + ); }); it('should return null if config.saveOriginalFilesToDrive is false', async () => { @@ -442,6 +424,138 @@ describe('getCsvFolderId', () => { ...baseConfig, saveOriginalFilesToDrive: false, }; - expect(await getCsvFolderId(mockDrive, mockConfig)).toBeNull(); + expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBeNull(); + }); +}); + +describe('convert', () => { + jest.mock('googleapis'); + jest.mock('fs'); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const mockConfig: Config = { + sourceDir: path.join(process.cwd(), 'testCsvDir'), + targetDriveFolderId: 'TargetDriveFolderId12345', + targetIsSharedDrive: true, + updateExistingGoogleSheets: false, + saveOriginalFilesToDrive: false, + }; + + it('should throw an error if the user is not logged in', async () => { + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => false); + await expect(convert.default({})).rejects.toThrow( + new C2gError(MESSAGES.error.c2gErrorNotLoggedIn), + ); + }); + + it('should throw an error if there are no CSV files in the designated local directory', async () => { + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); + jest.spyOn(console, 'info').mockImplementation(); + jest.spyOn(fs, 'existsSync').mockReturnValue(true); // readConfigFileSync, validateConfig + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); // readConfigFileSync + jest.spyOn(fs, 'readdirSync').mockReturnValue([]); // getLocalCsvFilePaths + jest.spyOn(auth, 'authorize').mockImplementation(() => { + return Promise.resolve({} as unknown as OAuth2Client); + }); + jest.spyOn(google, 'drive').mockImplementation(() => { + return {} as unknown as drive_v3.Drive; + }); + await expect(convert.default({})).rejects.toThrow( + new C2gError(MESSAGES.error.c2gErrorNoCsvFilesFound), + ); + }); + + it('should show the complete conversion process on dry run', async () => { + // Arrange + const mockLocalCsvFiles = ['file1.csv', 'file2.CSV']; + const mockLocalCsvFilePaths = mockLocalCsvFiles.map((file) => + path.join(mockConfig.sourceDir, file), + ); + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); + jest.spyOn(console, 'info').mockImplementation(); + jest.spyOn(fs, 'existsSync').mockReturnValue(true); // readConfigFileSync, validateConfig + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); // readConfigFileSync + jest + .spyOn(fs, 'readdirSync') + .mockReturnValue(mockLocalCsvFilePaths as unknown as fs.Dirent[]); // getLocalCsvFilePaths + jest.spyOn(auth, 'authorize').mockImplementation(() => { + return Promise.resolve({} as unknown as OAuth2Client); + }); + jest.spyOn(google, 'drive').mockImplementation(() => { + return {} as unknown as drive_v3.Drive; + }); + // Act + await convert.default({ dryRun: true }); + // Assert + expect(console.info).toHaveBeenNthCalledWith( + 1, + `${ + MESSAGES.log.runningOnDryRun + }\n\n${MESSAGES.log.convertingCsvWithFollowingSettings( + Object.keys(mockConfig) + .map((key) => `${key}: ${mockConfig[key as keyof Config]}`) + .join('\n '), + )}`, + ); + expect(console.info).toHaveBeenNthCalledWith( + 2, + MESSAGES.log.processingCsvFile(mockLocalCsvFiles[0], null), + ); + expect(console.info).toHaveBeenNthCalledWith( + 3, + MESSAGES.log.processingCsvFileComplete, + ); + expect(console.info).toHaveBeenNthCalledWith( + 4, + MESSAGES.log.processingCsvFile(mockLocalCsvFiles[1], null), + ); + expect(console.info).toHaveBeenNthCalledWith( + 5, + MESSAGES.log.processingCsvFileComplete, + ); + }); + /* + it('should open the default web browser if --browse option is enabled', async () => { + // Arrange + //jest.mock('open', () => jest.fn()); + //const mockOpen = open as jest.MockedFunction; + // mockOpen.mockResolvedValue({ pid: 12345 } as unknown as ChildProcess); + // jest.spyOn(open, 'default'); + // Other arranged settings are the same as the previous test: + // 'should show the complete conversion process on dry run' + const mockLocalCsvFiles = ['file1.csv', 'file2.CSV']; + const mockLocalCsvFilePaths = mockLocalCsvFiles.map((file) => + path.join(mockConfig.sourceDir, file), + ); + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); + jest.spyOn(console, 'info').mockImplementation(); + jest.spyOn(fs, 'existsSync').mockReturnValue(true); // readConfigFileSync, validateConfig + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); // readConfigFileSync + jest + .spyOn(fs, 'readdirSync') + .mockReturnValue(mockLocalCsvFilePaths as unknown as fs.Dirent[]); // getLocalCsvFilePaths + jest.spyOn(auth, 'authorize').mockImplementation(() => { + return Promise.resolve({} as unknown as OAuth2Client); + }); + jest.spyOn(google, 'drive').mockImplementation(() => { + return {} as unknown as drive_v3.Drive; + }); + // Act + await convert.default({ dryRun: true, browse: true }); + // Assert + expect(mockOpen as jest.Mock).toHaveBeenCalled(); + expect(mockOpen).toHaveBeenCalledWith( + `https://drive.google.com/drive/folders/${mockConfig.targetDriveFolderId}`, + ); + expect(console.info).toHaveBeenNthCalledWith( + 6, + MESSAGES.log.openingTargetDriveFolderOnBrowser( + `https://drive.google.com/drive/folders/${mockConfig.targetDriveFolderId}`, + ), + ); }); + */ }); From 7a5af532c6572a06b8a7eb8b18b0747d45f6b63b Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Mon, 18 Sep 2023 23:24:46 +0900 Subject: [PATCH 11/31] Fix typo in README csv2gheets -> csv2gsheets --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 851eeb4..bfbef77 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ is the equivalent to running csv2gsheets convert --dry-run ``` -#### Updating csv2gheets +#### Updating csv2gsheets New releases will be posted on [the GitHub repository](https://github.com/ttsukagoshi/csv2gsheets). To update your installed version, run: From 553f9c50098f59427e21799edf8282466f9c9fde Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Mon, 18 Sep 2023 23:30:28 +0900 Subject: [PATCH 12/31] Add eslint-plugin-jest as devDependencies --- package-lock.json | 957 ++++++++++++++++++++++++++++------------------ package.json | 1 + 2 files changed, 576 insertions(+), 382 deletions(-) diff --git a/package-lock.json b/package-lock.json index e25fb66..486fe42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@typescript-eslint/parser": "^6.6.0", "eslint": "^8.48.0", "eslint-config-prettier": "^9.0.0", + "eslint-plugin-jest": "^27.4.0", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.6.4", "prettier": "^3.0.3", @@ -139,30 +140,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.9.tgz", - "integrity": "sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.20.tgz", + "integrity": "sha512-BQYjKbpXjoXwFW5jGqiizJQQT/aC7pFm9Ok1OWssonuguICi264lbgMzRp2ZMmRSlfkX6DsWDDcsrctK8Rwfiw==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.15.tgz", - "integrity": "sha512-PtZqMmgRrvj8ruoEOIwVA3yoF91O+Hgw9o7DAUTNBA6Mo2jpu31clx9a7Nz/9JznqetTR6zwfC4L3LAjKQXUwA==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.20.tgz", + "integrity": "sha512-Y6jd1ahLubuYweD/zJH+vvOY141v4f9igNQAQ+MBgq9JlHS2iTsZKn1aMsb3vGccZsXI16VzTBw52Xx0DWmtnA==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.13", "@babel/generator": "^7.22.15", "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-module-transforms": "^7.22.15", + "@babel/helper-module-transforms": "^7.22.20", "@babel/helpers": "^7.22.15", - "@babel/parser": "^7.22.15", + "@babel/parser": "^7.22.16", "@babel/template": "^7.22.15", - "@babel/traverse": "^7.22.15", - "@babel/types": "^7.22.15", + "@babel/traverse": "^7.22.20", + "@babel/types": "^7.22.19", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -248,9 +249,9 @@ "dev": true }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", - "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true, "engines": { "node": ">=6.9.0" @@ -294,16 +295,16 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.15.tgz", - "integrity": "sha512-l1UiX4UyHSFsYt17iQ3Se5pQQZZHa22zyIXURmvkmLCD4t/aU+dvNWHatKac/D9Vm9UES7nvIqHs4jZqKviUmQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.20.tgz", + "integrity": "sha512-dLT7JVWIUUxKOs1UnJUBR3S70YK+pKX6AbJgB2vMIvEkZkrfJDbYDJesnPshtKV4LhDOR3Oc5YULeDizRek+5A==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-module-imports": "^7.22.15", "@babel/helper-simple-access": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.15" + "@babel/helper-validator-identifier": "^7.22.20" }, "engines": { "node": ">=6.9.0" @@ -355,9 +356,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.15.tgz", - "integrity": "sha512-4E/F9IIEi8WR94324mbDUMo074YTheJmd7eZF5vITTeYchqAi6sYXRLHUVsmkdmY4QjfKTcB2jB7dVP3NaBElQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "engines": { "node": ">=6.9.0" } @@ -386,11 +387,11 @@ } }, "node_modules/@babel/highlight": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.13.tgz", - "integrity": "sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "dependencies": { - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, @@ -666,19 +667,19 @@ } }, "node_modules/@babel/traverse": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.15.tgz", - "integrity": "sha512-DdHPwvJY0sEeN4xJU5uRLmZjgMMDIvMPniLuYzUVXj/GGzysPl0/fwt44JBkyUIzGJPV8QgHMcQdQ34XFuKTYQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.20.tgz", + "integrity": "sha512-eU260mPZbU7mZ0N+X10pxXhQFMGTeLb9eFS0mxehS8HZp9o1uSnFeWQuG1UPrlxgA7QoUzFhOnilHDp0AXCyHw==", "dev": true, "dependencies": { "@babel/code-frame": "^7.22.13", "@babel/generator": "^7.22.15", - "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.22.5", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15", + "@babel/parser": "^7.22.16", + "@babel/types": "^7.22.19", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -696,13 +697,13 @@ } }, "node_modules/@babel/types": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.15.tgz", - "integrity": "sha512-X+NLXr0N8XXmN5ZsaQdm9U2SSC3UbIYq/doL++sueHOTisgZHoKaQtZxGuV2cUPQHMfjKEfg/g6oy7Hm6SKFtA==", + "version": "7.22.19", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.19.tgz", + "integrity": "sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.15", + "@babel/helper-validator-identifier": "^7.22.19", "to-fast-properties": "^2.0.0" }, "engines": { @@ -753,9 +754,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.8.0.tgz", - "integrity": "sha512-JylOEEzDiOryeUnFbQz+oViCXS0KsvR1mvHkoMiu5+UiBvy+RYX7tzlIIIEstF/gVa2tj9AQXk3dgnxv6KxhFg==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.8.1.tgz", + "integrity": "sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -785,9 +786,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.48.0.tgz", - "integrity": "sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.49.0.tgz", + "integrity": "sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -965,16 +966,16 @@ } }, "node_modules/@jest/console": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.6.4.tgz", - "integrity": "sha512-wNK6gC0Ha9QeEPSkeJedQuTQqxZYnDPuDcDhVuVatRvMkL4D0VTvFVZj+Yuh6caG2aOfzkUZ36KtCmLNtR02hw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", "dev": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", - "jest-message-util": "^29.6.3", - "jest-util": "^29.6.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", "slash": "^3.0.0" }, "engines": { @@ -982,15 +983,15 @@ } }, "node_modules/@jest/core": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.6.4.tgz", - "integrity": "sha512-U/vq5ccNTSVgYH7mHnodHmCffGWHJnz/E1BEWlLuK5pM4FZmGfBn/nrJGLjUsSmyx3otCeqc1T31F4y08AMDLg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", "dev": true, "dependencies": { - "@jest/console": "^29.6.4", - "@jest/reporters": "^29.6.4", - "@jest/test-result": "^29.6.4", - "@jest/transform": "^29.6.4", + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "ansi-escapes": "^4.2.1", @@ -998,21 +999,21 @@ "ci-info": "^3.2.0", "exit": "^0.1.2", "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.6.3", - "jest-config": "^29.6.4", - "jest-haste-map": "^29.6.4", - "jest-message-util": "^29.6.3", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.6.4", - "jest-resolve-dependencies": "^29.6.4", - "jest-runner": "^29.6.4", - "jest-runtime": "^29.6.4", - "jest-snapshot": "^29.6.4", - "jest-util": "^29.6.3", - "jest-validate": "^29.6.3", - "jest-watcher": "^29.6.4", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", "micromatch": "^4.0.4", - "pretty-format": "^29.6.3", + "pretty-format": "^29.7.0", "slash": "^3.0.0", "strip-ansi": "^6.0.0" }, @@ -1029,37 +1030,37 @@ } }, "node_modules/@jest/environment": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", - "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "dev": true, "dependencies": { - "@jest/fake-timers": "^29.6.4", + "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", - "jest-mock": "^29.6.3" + "jest-mock": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/expect": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.6.4.tgz", - "integrity": "sha512-Warhsa7d23+3X5bLbrbYvaehcgX5TLYhI03JKoedTiI8uJU4IhqYBWF7OSSgUyz4IgLpUYPkK0AehA5/fRclAA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", "dev": true, "dependencies": { - "expect": "^29.6.4", - "jest-snapshot": "^29.6.4" + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.6.4.tgz", - "integrity": "sha512-FEhkJhqtvBwgSpiTrocquJCdXPsyvNKcl/n7A3u7X4pVoF4bswm11c9d4AV+kfq2Gpv/mM8x7E7DsRvH+djkrg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", "dev": true, "dependencies": { "jest-get-type": "^29.6.3" @@ -1069,47 +1070,47 @@ } }, "node_modules/@jest/fake-timers": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", - "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "dev": true, "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", - "jest-message-util": "^29.6.3", - "jest-mock": "^29.6.3", - "jest-util": "^29.6.3" + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/globals": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.6.4.tgz", - "integrity": "sha512-wVIn5bdtjlChhXAzVXavcY/3PEjf4VqM174BM3eGL5kMxLiZD5CLnbmkEyA1Dwh9q8XjP6E8RwjBsY/iCWrWsA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dev": true, "dependencies": { - "@jest/environment": "^29.6.4", - "@jest/expect": "^29.6.4", + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", "@jest/types": "^29.6.3", - "jest-mock": "^29.6.3" + "jest-mock": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/reporters": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.6.4.tgz", - "integrity": "sha512-sxUjWxm7QdchdrD3NfWKrL8FBsortZeibSJv4XLjESOOjSUOkjQcb0ZHJwfhEGIvBvTluTzfG2yZWZhkrXJu8g==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", "dev": true, "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.6.4", - "@jest/test-result": "^29.6.4", - "@jest/transform": "^29.6.4", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", "@jest/types": "^29.6.3", "@jridgewell/trace-mapping": "^0.3.18", "@types/node": "*", @@ -1123,9 +1124,9 @@ "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.6.3", - "jest-util": "^29.6.3", - "jest-worker": "^29.6.4", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", "slash": "^3.0.0", "string-length": "^4.0.1", "strip-ansi": "^6.0.0", @@ -1170,12 +1171,12 @@ } }, "node_modules/@jest/test-result": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.6.4.tgz", - "integrity": "sha512-uQ1C0AUEN90/dsyEirgMLlouROgSY+Wc/JanVVk0OiUKa5UFh7sJpMEM3aoUBAz2BRNvUJ8j3d294WFuRxSyOQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", "dev": true, "dependencies": { - "@jest/console": "^29.6.4", + "@jest/console": "^29.7.0", "@jest/types": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "collect-v8-coverage": "^1.0.0" @@ -1185,14 +1186,14 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.6.4.tgz", - "integrity": "sha512-E84M6LbpcRq3fT4ckfKs9ryVanwkaIB0Ws9bw3/yP4seRLg/VaCZ/LgW0MCq5wwk4/iP/qnilD41aj2fsw2RMg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", "dev": true, "dependencies": { - "@jest/test-result": "^29.6.4", + "@jest/test-result": "^29.7.0", "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.6.4", + "jest-haste-map": "^29.7.0", "slash": "^3.0.0" }, "engines": { @@ -1200,9 +1201,9 @@ } }, "node_modules/@jest/transform": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.6.4.tgz", - "integrity": "sha512-8thgRSiXUqtr/pPGY/OsyHuMjGyhVnWrFAwoxmIemlBuiMyU1WFs0tXoNxzcr4A4uErs/ABre76SGmrr5ab/AA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "dev": true, "dependencies": { "@babel/core": "^7.11.6", @@ -1213,9 +1214,9 @@ "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.6.4", + "jest-haste-map": "^29.7.0", "jest-regex-util": "^29.6.3", - "jest-util": "^29.6.3", + "jest-util": "^29.7.0", "micromatch": "^4.0.4", "pirates": "^4.0.4", "slash": "^3.0.0", @@ -1420,9 +1421,9 @@ "dev": true }, "node_modules/@types/babel__core": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", - "integrity": "sha512-aACu/U/omhdk15O4Nfb+fHgH/z3QsfQzpnvRZhYhThms83ZnAOZz7zZAWO7mn2yyNQaA4xTO8GLK3uqFU4bYYw==", + "version": "7.20.2", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.2.tgz", + "integrity": "sha512-pNpr1T1xLUc2l3xJKuPtsEky3ybxN3m4fJkknfIpTCTfIZCDW57oAg+EfCgIIp2rvCe0Wn++/FfodDS4YXxBwA==", "dev": true, "dependencies": { "@babel/parser": "^7.20.7", @@ -1433,18 +1434,18 @@ } }, "node_modules/@types/babel__generator": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", - "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.5.tgz", + "integrity": "sha512-h9yIuWbJKdOPLJTbmSpPzkF67e659PbQDba7ifWm5BJ8xTv+sDmS7rFmywkWOvXedGTivCdeGSIIX8WLcRTz8w==", "dev": true, "dependencies": { "@babel/types": "^7.0.0" } }, "node_modules/@types/babel__template": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", - "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.2.tgz", + "integrity": "sha512-/AVzPICMhMOMYoSx9MoKpGDKdBRsIXMNByh1PXSZoa+v6ZoLa8xxtsT/uLQ/NJm0XVAWl/BvId4MlDeXJaeIZQ==", "dev": true, "dependencies": { "@babel/parser": "^7.1.0", @@ -1452,9 +1453,9 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.1.tgz", - "integrity": "sha512-MitHFXnhtgwsGZWtT68URpOvLN4EREih1u3QtQiN4VdAxWKRVvGCSvw/Qth0M0Qq3pJpnGOu5JaM/ydK7OGbqg==", + "version": "7.20.2", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.2.tgz", + "integrity": "sha512-ojlGK1Hsfce93J0+kn3H5R73elidKUaZonirN33GSmgTUMpzI/MIFfSpF3haANe3G1bEBS9/9/QEqwTzwqFsKw==", "dev": true, "dependencies": { "@babel/types": "^7.20.7" @@ -1504,9 +1505,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.4", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.4.tgz", - "integrity": "sha512-PhglGmhWeD46FYOVLt3X7TiWjzwuVGW9wG/4qocPevXMjCmrIc5b6db9WjeGE4QYVpUAWMDv3v0IiBwObY289A==", + "version": "29.5.5", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.5.tgz", + "integrity": "sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg==", "dev": true, "dependencies": { "expect": "^29.0.0", @@ -1514,15 +1515,15 @@ } }, "node_modules/@types/json-schema": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", - "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz", + "integrity": "sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==", "dev": true }, "node_modules/@types/node": { - "version": "20.5.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.9.tgz", - "integrity": "sha512-PcGNd//40kHAS3sTlzKB9C9XL4K0sTup8nbG5lC14kzEteTNuAFh9u5nA0o5TWnSG2r/JNPRXFVcHJIIeRlmqQ==", + "version": "20.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.2.tgz", + "integrity": "sha512-Y+/1vGBHV/cYk6OI1Na/LHzwnlNCAfU3ZNGrc1LdRe/LAIbdDPTTv/HU3M7yXN448aTVDq3eKRm2cg7iKLb8gw==", "dev": true }, "node_modules/@types/normalize-package-data": { @@ -1531,9 +1532,9 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==" }, "node_modules/@types/semver": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.1.tgz", - "integrity": "sha512-cJRQXpObxfNKkFAZbJl2yjWtJCqELQIdShsogr1d2MilP8dKD9TE/nEKHkJgUNHdGKCQaf9HbIynuV2csLGVLg==", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.2.tgz", + "integrity": "sha512-7aqorHYgdNO4DM36stTiGO3DvKoex9TQRwsJU6vMaFGyqpBA1MNZkz+PG3gaNUPpTAOYhT1WR7M1JyA3fbS9Cw==", "dev": true }, "node_modules/@types/stack-utils": { @@ -1543,9 +1544,9 @@ "dev": true }, "node_modules/@types/through": { - "version": "0.0.30", - "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.30.tgz", - "integrity": "sha512-FvnCJljyxhPM3gkRgWmxmDZyAQSiBQQWLI0A0VFL0K7W1oRUrPJSqNO0NvTnLkBcotdlp3lKvaT0JrnyRDkzOg==", + "version": "0.0.31", + "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.31.tgz", + "integrity": "sha512-LpKpmb7FGevYgXnBXYs6HWnmiFyVG07Pt1cnbgM1IhEacITTiUaBXXvOR3Y50ksaJWGSfhbEvQFivQEFGCC55w==", "dev": true, "dependencies": { "@types/node": "*" @@ -1567,16 +1568,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.6.0.tgz", - "integrity": "sha512-CW9YDGTQnNYMIo5lMeuiIG08p4E0cXrXTbcZ2saT/ETE7dWUrNxlijsQeU04qAAKkILiLzdQz+cGFxCJjaZUmA==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.7.0.tgz", + "integrity": "sha512-gUqtknHm0TDs1LhY12K2NA3Rmlmp88jK9Tx8vGZMfHeNMLE3GH2e9TRub+y+SOjuYgtOmok+wt1AyDPZqxbNag==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.6.0", - "@typescript-eslint/type-utils": "6.6.0", - "@typescript-eslint/utils": "6.6.0", - "@typescript-eslint/visitor-keys": "6.6.0", + "@typescript-eslint/scope-manager": "6.7.0", + "@typescript-eslint/type-utils": "6.7.0", + "@typescript-eslint/utils": "6.7.0", + "@typescript-eslint/visitor-keys": "6.7.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -1602,15 +1603,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.6.0.tgz", - "integrity": "sha512-setq5aJgUwtzGrhW177/i+DMLqBaJbdwGj2CPIVFFLE0NCliy5ujIdLHd2D1ysmlmsjdL2GWW+hR85neEfc12w==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.7.0.tgz", + "integrity": "sha512-jZKYwqNpNm5kzPVP5z1JXAuxjtl2uG+5NpaMocFPTNC2EdYIgbXIPImObOkhbONxtFTTdoZstLZefbaK+wXZng==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.6.0", - "@typescript-eslint/types": "6.6.0", - "@typescript-eslint/typescript-estree": "6.6.0", - "@typescript-eslint/visitor-keys": "6.6.0", + "@typescript-eslint/scope-manager": "6.7.0", + "@typescript-eslint/types": "6.7.0", + "@typescript-eslint/typescript-estree": "6.7.0", + "@typescript-eslint/visitor-keys": "6.7.0", "debug": "^4.3.4" }, "engines": { @@ -1630,13 +1631,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.6.0.tgz", - "integrity": "sha512-pT08u5W/GT4KjPUmEtc2kSYvrH8x89cVzkA0Sy2aaOUIw6YxOIjA8ilwLr/1fLjOedX1QAuBpG9XggWqIIfERw==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.7.0.tgz", + "integrity": "sha512-lAT1Uau20lQyjoLUQ5FUMSX/dS07qux9rYd5FGzKz/Kf8W8ccuvMyldb8hadHdK/qOI7aikvQWqulnEq2nCEYA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.6.0", - "@typescript-eslint/visitor-keys": "6.6.0" + "@typescript-eslint/types": "6.7.0", + "@typescript-eslint/visitor-keys": "6.7.0" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1647,13 +1648,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.6.0.tgz", - "integrity": "sha512-8m16fwAcEnQc69IpeDyokNO+D5spo0w1jepWWY2Q6y5ZKNuj5EhVQXjtVAeDDqvW6Yg7dhclbsz6rTtOvcwpHg==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.7.0.tgz", + "integrity": "sha512-f/QabJgDAlpSz3qduCyQT0Fw7hHpmhOzY/Rv6zO3yO+HVIdPfIWhrQoAyG+uZVtWAIS85zAyzgAFfyEr+MgBpg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.6.0", - "@typescript-eslint/utils": "6.6.0", + "@typescript-eslint/typescript-estree": "6.7.0", + "@typescript-eslint/utils": "6.7.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -1674,9 +1675,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.6.0.tgz", - "integrity": "sha512-CB6QpJQ6BAHlJXdwUmiaXDBmTqIE2bzGTDLADgvqtHWuhfNP3rAOK7kAgRMAET5rDRr9Utt+qAzRBdu3AhR3sg==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.7.0.tgz", + "integrity": "sha512-ihPfvOp7pOcN/ysoj0RpBPOx3HQTJTrIN8UZK+WFd3/iDeFHHqeyYxa4hQk4rMhsz9H9mXpR61IzwlBVGXtl9Q==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1687,13 +1688,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.6.0.tgz", - "integrity": "sha512-hMcTQ6Al8MP2E6JKBAaSxSVw5bDhdmbCEhGW/V8QXkb9oNsFkA4SBuOMYVPxD3jbtQ4R/vSODBsr76R6fP3tbA==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.7.0.tgz", + "integrity": "sha512-dPvkXj3n6e9yd/0LfojNU8VMUGHWiLuBZvbM6V6QYD+2qxqInE7J+J/ieY2iGwR9ivf/R/haWGkIj04WVUeiSQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.6.0", - "@typescript-eslint/visitor-keys": "6.6.0", + "@typescript-eslint/types": "6.7.0", + "@typescript-eslint/visitor-keys": "6.7.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1714,17 +1715,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.6.0.tgz", - "integrity": "sha512-mPHFoNa2bPIWWglWYdR0QfY9GN0CfvvXX1Sv6DlSTive3jlMTUy+an67//Gysc+0Me9pjitrq0LJp0nGtLgftw==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.7.0.tgz", + "integrity": "sha512-MfCq3cM0vh2slSikQYqK2Gq52gvOhe57vD2RM3V4gQRZYX4rDPnKLu5p6cm89+LJiGlwEXU8hkYxhqqEC/V3qA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.6.0", - "@typescript-eslint/types": "6.6.0", - "@typescript-eslint/typescript-estree": "6.6.0", + "@typescript-eslint/scope-manager": "6.7.0", + "@typescript-eslint/types": "6.7.0", + "@typescript-eslint/typescript-estree": "6.7.0", "semver": "^7.5.4" }, "engines": { @@ -1739,12 +1740,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.6.0.tgz", - "integrity": "sha512-L61uJT26cMOfFQ+lMZKoJNbAEckLe539VhTxiGHrWl5XSKQgA0RTBZJW2HFPy5T0ZvPVSD93QsrTKDkfNwJGyQ==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.0.tgz", + "integrity": "sha512-/C1RVgKFDmGMcVGeD8HjKv2bd72oI1KxQDeY8uc66gw9R0OK0eMq48cA+jv9/2Ag6cdrsUGySm1yzYmfz0hxwQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.6.0", + "@typescript-eslint/types": "6.7.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -1910,12 +1911,12 @@ } }, "node_modules/babel-jest": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.6.4.tgz", - "integrity": "sha512-meLj23UlSLddj6PC+YTOFRgDAtjnZom8w/ACsrx0gtPtv5cJZk0A5Unk5bV4wixD7XaPCN1fQvpww8czkZURmw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "dev": true, "dependencies": { - "@jest/transform": "^29.6.4", + "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", @@ -2244,9 +2245,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001527", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001527.tgz", - "integrity": "sha512-YkJi7RwPgWtXVSgK4lG9AHH57nSzvvOp9MesgXmw4Q7n0C3H04L0foHqfxcmSAm5AcWb8dW9AYj2tR7/5GnddQ==", + "version": "1.0.30001535", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001535.tgz", + "integrity": "sha512-48jLyUkiWFfhm/afF7cQPqPjaUmSraEhK4j+FCTJpgnGGEZHqyLe3hmWH7lIooZdSzXL0ReMvHz0vKDoTBsrwg==", "dev": true, "funding": [ { @@ -2326,9 +2327,9 @@ } }, "node_modules/cli-spinners": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.0.tgz", - "integrity": "sha512-4/aL9X3Wh0yiMQlE+eeRhWP6vclO3QRtw1JHKIT0FFUs5FjpFmESqtMvYZ0+lbzBw900b95mS0hohy+qn2VK/g==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.1.tgz", + "integrity": "sha512-jHgecW0pxkonBJdrKsqxgRX9AcG+u/5k0Q7WPDfi8AogLAdwxEkyYYNWwZ5GvVFoFx2uiY1eNcSK00fh+1+FyQ==", "engines": { "node": ">=6" }, @@ -2435,6 +2436,27 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -2743,9 +2765,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.509", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.509.tgz", - "integrity": "sha512-G5KlSWY0zzhANtX15tkikHl4WB7zil2Y65oT52EZUL194abjUXBZym12Ht7Bhuwm/G3LJFEqMADyv2Cks56dmg==", + "version": "1.4.523", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.523.tgz", + "integrity": "sha512-9AreocSUWnzNtvLcbpng6N+GkXnCcBR80IQkxRC9Dfdyg4gaWNUPBujAHUpKkiUkoSoR9UlhA4zD/IgBklmhzg==", "dev": true }, "node_modules/emittery": { @@ -2795,16 +2817,16 @@ } }, "node_modules/eslint": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.48.0.tgz", - "integrity": "sha512-sb6DLeIuRXxeM1YljSe1KEx9/YYeZFQWcV8Rq9HfigmdDEugjLEVEa1ozDjL6YDjBpQHPJxJzze+alxi4T3OLg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.49.0.tgz", + "integrity": "sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.2", - "@eslint/js": "8.48.0", - "@humanwhocodes/config-array": "^0.11.10", + "@eslint/js": "8.49.0", + "@humanwhocodes/config-array": "^0.11.11", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "ajv": "^6.12.4", @@ -2860,6 +2882,153 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-jest": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.4.0.tgz", + "integrity": "sha512-ukVeKmMPAUA5SWjHenvyyXnirKfHKMdOsTZdn5tZx5EW05HGVQwBohigjFZGGj3zuv1cV6hc82FvWv6LdIbkgg==", + "dev": true, + "dependencies": { + "@typescript-eslint/utils": "^5.10.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^5.0.0 || ^6.0.0", + "eslint": "^7.0.0 || ^8.0.0", + "jest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/scope-manager": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/types": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-jest/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-plugin-jest/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/eslint-plugin-prettier": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.0.tgz", @@ -3022,16 +3191,16 @@ } }, "node_modules/expect": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.6.4.tgz", - "integrity": "sha512-F2W2UyQ8XYyftHT57dtfg8Ue3X5qLgm2sSug0ivvLRH/VKNRL/pDxg/TH7zVzbQB0tu80clNFy6LU7OS/VSEKA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dev": true, "dependencies": { - "@jest/expect-utils": "^29.6.4", + "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.6.4", - "jest-message-util": "^29.6.3", - "jest-util": "^29.6.3" + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -3206,9 +3375,9 @@ } }, "node_modules/flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, "node_modules/fs.realpath": { @@ -3493,9 +3662,9 @@ } }, "node_modules/hosted-git-info": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.0.tgz", - "integrity": "sha512-ICclEpTLhHj+zCuSb2/usoNXSVkxUSIopre+b1w8NDY9Dntp9LO4vLdHYI336TH8sAqwrRgnSfdkBG2/YpisHA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz", + "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==", "dependencies": { "lru-cache": "^10.0.1" }, @@ -3637,9 +3806,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/inquirer": { - "version": "9.2.10", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.10.tgz", - "integrity": "sha512-tVVNFIXU8qNHoULiazz612GFl+yqNfjMTbLuViNJE/d860Qxrd3NMrse8dm40VUQLOQeULvaQF8lpAhvysjeyA==", + "version": "9.2.11", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.11.tgz", + "integrity": "sha512-B2LafrnnhbRzCWfAdOXisUzL89Kg8cVJlYmhqoi3flSiV/TveO+nsXwgKr9h9PIo+J1hz7nBSk6gegRIMBBf7g==", "dependencies": { "@ljharb/through": "^2.3.9", "ansi-escapes": "^4.3.2", @@ -4027,15 +4196,15 @@ } }, "node_modules/jest": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.6.4.tgz", - "integrity": "sha512-tEFhVQFF/bzoYV1YuGyzLPZ6vlPrdfvDmmAxudA1dLEuiztqg2Rkx20vkKY32xiDROcD2KXlgZ7Cu8RPeEHRKw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "dependencies": { - "@jest/core": "^29.6.4", + "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", "import-local": "^3.0.2", - "jest-cli": "^29.6.4" + "jest-cli": "^29.7.0" }, "bin": { "jest": "bin/jest.js" @@ -4053,13 +4222,13 @@ } }, "node_modules/jest-changed-files": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.6.3.tgz", - "integrity": "sha512-G5wDnElqLa4/c66ma5PG9eRjE342lIbF6SUnTJi26C3J28Fv2TVY2rOyKB9YGbSA5ogwevgmxc4j4aVjrEK6Yg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", "dev": true, "dependencies": { "execa": "^5.0.0", - "jest-util": "^29.6.3", + "jest-util": "^29.7.0", "p-limit": "^3.1.0" }, "engines": { @@ -4067,28 +4236,28 @@ } }, "node_modules/jest-circus": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.6.4.tgz", - "integrity": "sha512-YXNrRyntVUgDfZbjXWBMPslX1mQ8MrSG0oM/Y06j9EYubODIyHWP8hMUbjbZ19M3M+zamqEur7O80HODwACoJw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", "dev": true, "dependencies": { - "@jest/environment": "^29.6.4", - "@jest/expect": "^29.6.4", - "@jest/test-result": "^29.6.4", + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "co": "^4.6.0", "dedent": "^1.0.0", "is-generator-fn": "^2.0.0", - "jest-each": "^29.6.3", - "jest-matcher-utils": "^29.6.4", - "jest-message-util": "^29.6.3", - "jest-runtime": "^29.6.4", - "jest-snapshot": "^29.6.4", - "jest-util": "^29.6.3", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", "p-limit": "^3.1.0", - "pretty-format": "^29.6.3", + "pretty-format": "^29.7.0", "pure-rand": "^6.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" @@ -4098,22 +4267,21 @@ } }, "node_modules/jest-cli": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.6.4.tgz", - "integrity": "sha512-+uMCQ7oizMmh8ZwRfZzKIEszFY9ksjjEQnTEMTaL7fYiL3Kw4XhqT9bYh+A4DQKUb67hZn2KbtEnDuHvcgK4pQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", "dev": true, "dependencies": { - "@jest/core": "^29.6.4", - "@jest/test-result": "^29.6.4", + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", "@jest/types": "^29.6.3", "chalk": "^4.0.0", + "create-jest": "^29.7.0", "exit": "^0.1.2", - "graceful-fs": "^4.2.9", "import-local": "^3.0.2", - "jest-config": "^29.6.4", - "jest-util": "^29.6.3", - "jest-validate": "^29.6.3", - "prompts": "^2.0.1", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", "yargs": "^17.3.1" }, "bin": { @@ -4132,31 +4300,31 @@ } }, "node_modules/jest-config": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.6.4.tgz", - "integrity": "sha512-JWohr3i9m2cVpBumQFv2akMEnFEPVOh+9L2xIBJhJ0zOaci2ZXuKJj0tgMKQCBZAKA09H049IR4HVS/43Qb19A==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", "dev": true, "dependencies": { "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.6.4", + "@jest/test-sequencer": "^29.7.0", "@jest/types": "^29.6.3", - "babel-jest": "^29.6.4", + "babel-jest": "^29.7.0", "chalk": "^4.0.0", "ci-info": "^3.2.0", "deepmerge": "^4.2.2", "glob": "^7.1.3", "graceful-fs": "^4.2.9", - "jest-circus": "^29.6.4", - "jest-environment-node": "^29.6.4", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", "jest-get-type": "^29.6.3", "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.6.4", - "jest-runner": "^29.6.4", - "jest-util": "^29.6.3", - "jest-validate": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", "micromatch": "^4.0.4", "parse-json": "^5.2.0", - "pretty-format": "^29.6.3", + "pretty-format": "^29.7.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -4177,24 +4345,24 @@ } }, "node_modules/jest-diff": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.6.4.tgz", - "integrity": "sha512-9F48UxR9e4XOEZvoUXEHSWY4qC4zERJaOfrbBg9JpbJOO43R1vN76REt/aMGZoY6GD5g84nnJiBIVlscegefpw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dev": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", - "pretty-format": "^29.6.3" + "pretty-format": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-docblock": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.6.3.tgz", - "integrity": "sha512-2+H+GOTQBEm2+qFSQ7Ma+BvyV+waiIFxmZF5LdpBsAEjWX8QYjSCa4FrkIYtbfXUJJJnFCYrOtt6TZ+IAiTjBQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", "dev": true, "dependencies": { "detect-newline": "^3.0.0" @@ -4204,33 +4372,33 @@ } }, "node_modules/jest-each": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.6.3.tgz", - "integrity": "sha512-KoXfJ42k8cqbkfshW7sSHcdfnv5agDdHCPA87ZBdmHP+zJstTJc0ttQaJ/x7zK6noAL76hOuTIJ6ZkQRS5dcyg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", "dev": true, "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", - "jest-util": "^29.6.3", - "pretty-format": "^29.6.3" + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-environment-node": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.6.4.tgz", - "integrity": "sha512-i7SbpH2dEIFGNmxGCpSc2w9cA4qVD+wfvg2ZnfQ7XVrKL0NA5uDVBIiGH8SR4F0dKEv/0qI5r+aDomDf04DpEQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", "dev": true, "dependencies": { - "@jest/environment": "^29.6.4", - "@jest/fake-timers": "^29.6.4", + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", - "jest-mock": "^29.6.3", - "jest-util": "^29.6.3" + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -4246,9 +4414,9 @@ } }, "node_modules/jest-haste-map": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.6.4.tgz", - "integrity": "sha512-12Ad+VNTDHxKf7k+M65sviyynRoZYuL1/GTuhEVb8RYsNSNln71nANRb/faSyWvx0j+gHcivChXHIoMJrGYjog==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", "dev": true, "dependencies": { "@jest/types": "^29.6.3", @@ -4258,8 +4426,8 @@ "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.6.3", - "jest-util": "^29.6.3", - "jest-worker": "^29.6.4", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" }, @@ -4271,37 +4439,37 @@ } }, "node_modules/jest-leak-detector": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.6.3.tgz", - "integrity": "sha512-0kfbESIHXYdhAdpLsW7xdwmYhLf1BRu4AA118/OxFm0Ho1b2RcTmO4oF6aAMaxpxdxnJ3zve2rgwzNBD4Zbm7Q==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", "dev": true, "dependencies": { "jest-get-type": "^29.6.3", - "pretty-format": "^29.6.3" + "pretty-format": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.6.4.tgz", - "integrity": "sha512-KSzwyzGvK4HcfnserYqJHYi7sZVqdREJ9DMPAKVbS98JsIAvumihaNUbjrWw0St7p9IY7A9UskCW5MYlGmBQFQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", "dev": true, "dependencies": { "chalk": "^4.0.0", - "jest-diff": "^29.6.4", + "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", - "pretty-format": "^29.6.3" + "pretty-format": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-message-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.6.3.tgz", - "integrity": "sha512-FtzaEEHzjDpQp51HX4UMkPZjy46ati4T5pEMyM6Ik48ztu4T9LQplZ6OsimHx7EuM9dfEh5HJa6D3trEftu3dA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "dev": true, "dependencies": { "@babel/code-frame": "^7.12.13", @@ -4310,7 +4478,7 @@ "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", - "pretty-format": "^29.6.3", + "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" }, @@ -4319,14 +4487,14 @@ } }, "node_modules/jest-mock": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", - "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", "dev": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", - "jest-util": "^29.6.3" + "jest-util": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -4359,17 +4527,17 @@ } }, "node_modules/jest-resolve": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.6.4.tgz", - "integrity": "sha512-fPRq+0vcxsuGlG0O3gyoqGTAxasagOxEuyoxHeyxaZbc9QNek0AmJWSkhjlMG+mTsj+8knc/mWb3fXlRNVih7Q==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", "dev": true, "dependencies": { "chalk": "^4.0.0", "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.6.4", + "jest-haste-map": "^29.7.0", "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.6.3", - "jest-validate": "^29.6.3", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", "resolve": "^1.20.0", "resolve.exports": "^2.0.0", "slash": "^3.0.0" @@ -4379,43 +4547,43 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.6.4.tgz", - "integrity": "sha512-7+6eAmr1ZBF3vOAJVsfLj1QdqeXG+WYhidfLHBRZqGN24MFRIiKG20ItpLw2qRAsW/D2ZUUmCNf6irUr/v6KHA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", "dev": true, "dependencies": { "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.6.4" + "jest-snapshot": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-runner": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.6.4.tgz", - "integrity": "sha512-SDaLrMmtVlQYDuG0iSPYLycG8P9jLI+fRm8AF/xPKhYDB2g6xDWjXBrR5M8gEWsK6KVFlebpZ4QsrxdyIX1Jaw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", "dev": true, "dependencies": { - "@jest/console": "^29.6.4", - "@jest/environment": "^29.6.4", - "@jest/test-result": "^29.6.4", - "@jest/transform": "^29.6.4", + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "emittery": "^0.13.1", "graceful-fs": "^4.2.9", - "jest-docblock": "^29.6.3", - "jest-environment-node": "^29.6.4", - "jest-haste-map": "^29.6.4", - "jest-leak-detector": "^29.6.3", - "jest-message-util": "^29.6.3", - "jest-resolve": "^29.6.4", - "jest-runtime": "^29.6.4", - "jest-util": "^29.6.3", - "jest-watcher": "^29.6.4", - "jest-worker": "^29.6.4", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -4424,17 +4592,17 @@ } }, "node_modules/jest-runtime": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.6.4.tgz", - "integrity": "sha512-s/QxMBLvmwLdchKEjcLfwzP7h+jsHvNEtxGP5P+Fl1FMaJX2jMiIqe4rJw4tFprzCwuSvVUo9bn0uj4gNRXsbA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", "dev": true, "dependencies": { - "@jest/environment": "^29.6.4", - "@jest/fake-timers": "^29.6.4", - "@jest/globals": "^29.6.4", + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.6.4", - "@jest/transform": "^29.6.4", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", @@ -4442,13 +4610,13 @@ "collect-v8-coverage": "^1.0.0", "glob": "^7.1.3", "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.6.4", - "jest-message-util": "^29.6.3", - "jest-mock": "^29.6.3", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.6.4", - "jest-snapshot": "^29.6.4", - "jest-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -4457,9 +4625,9 @@ } }, "node_modules/jest-snapshot": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.6.4.tgz", - "integrity": "sha512-VC1N8ED7+4uboUKGIDsbvNAZb6LakgIPgAF4RSpF13dN6YaMokfRqO+BaqK4zIh6X3JffgwbzuGqDEjHm/MrvA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", "dev": true, "dependencies": { "@babel/core": "^7.11.6", @@ -4467,20 +4635,20 @@ "@babel/plugin-syntax-jsx": "^7.7.2", "@babel/plugin-syntax-typescript": "^7.7.2", "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.6.4", - "@jest/transform": "^29.6.4", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", "@jest/types": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0", "chalk": "^4.0.0", - "expect": "^29.6.4", + "expect": "^29.7.0", "graceful-fs": "^4.2.9", - "jest-diff": "^29.6.4", + "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.6.4", - "jest-message-util": "^29.6.3", - "jest-util": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", "natural-compare": "^1.4.0", - "pretty-format": "^29.6.3", + "pretty-format": "^29.7.0", "semver": "^7.5.3" }, "engines": { @@ -4488,9 +4656,9 @@ } }, "node_modules/jest-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.6.3.tgz", - "integrity": "sha512-QUjna/xSy4B32fzcKTSz1w7YYzgiHrjjJjevdRf61HYk998R5vVMMNmrHESYZVDS5DSWs+1srPLPKxXPkeSDOA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "dev": true, "dependencies": { "@jest/types": "^29.6.3", @@ -4505,9 +4673,9 @@ } }, "node_modules/jest-validate": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.6.3.tgz", - "integrity": "sha512-e7KWZcAIX+2W1o3cHfnqpGajdCs1jSM3DkXjGeLSNmCazv1EeI1ggTeK5wdZhF+7N+g44JI2Od3veojoaumlfg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "dev": true, "dependencies": { "@jest/types": "^29.6.3", @@ -4515,7 +4683,7 @@ "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", - "pretty-format": "^29.6.3" + "pretty-format": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -4534,18 +4702,18 @@ } }, "node_modules/jest-watcher": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.6.4.tgz", - "integrity": "sha512-oqUWvx6+On04ShsT00Ir9T4/FvBeEh2M9PTubgITPxDa739p4hoQweWPRGyYeaojgT0xTpZKF0Y/rSY1UgMxvQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", "dev": true, "dependencies": { - "@jest/test-result": "^29.6.4", + "@jest/test-result": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", "emittery": "^0.13.1", - "jest-util": "^29.6.3", + "jest-util": "^29.7.0", "string-length": "^4.0.1" }, "engines": { @@ -4553,13 +4721,13 @@ } }, "node_modules/jest-worker": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.6.4.tgz", - "integrity": "sha512-6dpvFV4WjcWbDVGgHTWo/aupl8/LbBx2NSKfiwqf79xC/yeJjKHT1+StcKy/2KTmW16hE68ccKVOtXf+WZGz7Q==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "dev": true, "dependencies": { "@types/node": "*", - "jest-util": "^29.6.3", + "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" }, @@ -5382,9 +5550,9 @@ } }, "node_modules/pretty-format": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.6.3.tgz", - "integrity": "sha512-ZsBgjVhFAj5KeK+nHfF1305/By3lechHQSMWCTl8iHSbfOm2TN5nHEtFc/+W7fAyUeCs2n5iow72gld4gW0xDw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, "dependencies": { "@jest/schemas": "^29.6.3", @@ -5684,9 +5852,9 @@ } }, "node_modules/resolve": { - "version": "1.22.4", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz", - "integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==", + "version": "1.22.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz", + "integrity": "sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==", "dev": true, "dependencies": { "is-core-module": "^2.13.0", @@ -6205,9 +6373,9 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/ts-api-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.2.tgz", - "integrity": "sha512-Cbu4nIqnEdd+THNEsBdkolnOXhg0I8XteoHaEKgvsxpsbWda4IsUut2c187HxywQCvveojow0Dgw/amxtSKVkQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", + "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", "dev": true, "engines": { "node": ">=16.13.0" @@ -6307,6 +6475,27 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6412,9 +6601,13 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } diff --git a/package.json b/package.json index 1fd4849..a2f45af 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@typescript-eslint/parser": "^6.6.0", "eslint": "^8.48.0", "eslint-config-prettier": "^9.0.0", + "eslint-plugin-jest": "^27.4.0", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.6.4", "prettier": "^3.0.3", From 4adb64618ea9ebcb97f7fbc63735b346496ee06c Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Mon, 18 Sep 2023 23:31:05 +0900 Subject: [PATCH 13/31] Add `overrides` for test files --- .eslintrc.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.eslintrc.yml b/.eslintrc.yml index bf68375..5297aa0 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -35,8 +35,20 @@ extends: - 'plugin:prettier/recommended' - 'plugin:@typescript-eslint/recommended-type-checked' - 'plugin:@typescript-eslint/stylistic-type-checked' +overrides: + - { + files: ['test/**'], + plugins: ['jest'], + extends: ['plugin:jest/recommended'], + rules: + { + '@typescript-eslint/unbound-method': 'off', + 'jest/unbound-method': 'error', + }, + } plugins: - '@typescript-eslint' + - 'jest' ######### # Rules # ######### From 8fbc63f38d5c7a69262738db3e94978bded58417 Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Tue, 19 Sep 2023 03:13:09 +0900 Subject: [PATCH 14/31] Move utility functions to utils.ts --- src/commands/convert.ts | 261 ++++------------------------------------ src/utils.ts | 227 ++++++++++++++++++++++++++++++++-- 2 files changed, 242 insertions(+), 246 deletions(-) diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 9c7c294..b472338 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -7,8 +7,9 @@ import path from 'path'; import { authorize, isAuthorized } from '../auth'; import { C2gError } from '../c2g-error'; -import { Config, CONFIG_FILE_NAME, DEFAULT_CONFIG } from '../constants'; +import { Config, CONFIG_FILE_NAME } from '../constants'; import { MESSAGES } from '../messages'; +import * as utils from '../utils'; export interface ConvertCommandOptions { readonly browse?: boolean; @@ -23,225 +24,6 @@ export interface CsvFileObj { existingSheetsFileId: string | null; } -/** - * Read the configuration file and return its contents as an object. - * @param configFilePath The path to the configuration file - * @returns The contents of the configuration file as an object - */ -export function readConfigFileSync(configFilePath: string): Config { - // Check if the configFilePath is a valid path - if (!fs.existsSync(configFilePath)) { - throw new C2gError(MESSAGES.error.c2gErrorConfigFileNotFound); - } else { - // Read the configuration file and return its contents as an object - const parsedConfig = JSON.parse( - fs.readFileSync(configFilePath, 'utf8'), - ) as Config; - return parsedConfig; - } -} - -/** - * Validate the configuration file. - * Note that this function does not check if the target Google Drive folder exists - * or if the user has access to that folder. - * @param configObj The contents of the configuration file as an object - */ -export function validateConfig(configObj: Partial): Config { - if (configObj.sourceDir) { - // If sourceDir is not a string, return false - if (typeof configObj.sourceDir !== 'string') { - throw new TypeError(MESSAGES.error.typeErrorSourceDirMustBeString); - } - // If sourceDir is not a valid path, return false - if (!fs.existsSync(configObj.sourceDir)) { - throw new C2gError(MESSAGES.error.c2gErrorSourceDirMustBeValidPath); - } - } - if (configObj.targetDriveFolderId) { - // If targetDriveFolderId is not a string, return false - if (typeof configObj.targetDriveFolderId !== 'string') { - throw new TypeError( - MESSAGES.error.typeErrorTargetDriveFolderIdMustBeString, - ); - } - } - if (configObj.targetIsSharedDrive) { - // If targetIsSharedDrive is not a boolean, return false - if (typeof configObj.targetIsSharedDrive !== 'boolean') { - throw new TypeError( - MESSAGES.error.typeErrorTargetIsSharedDriveMustBeBoolean, - ); - } - } - if (configObj.updateExistingGoogleSheets) { - // If updateExistingGoogleSheets is not a boolean, return false - if (typeof configObj.updateExistingGoogleSheets !== 'boolean') { - throw new TypeError( - MESSAGES.error.typeErrorUpdateExistingGoogleSheetsMustBeBoolean, - ); - } - } - if (configObj.saveOriginalFilesToDrive) { - // If saveOriginalFilesToDrive is not a boolean, return false - if (typeof configObj.saveOriginalFilesToDrive !== 'boolean') { - throw new TypeError( - MESSAGES.error.typeErrorSaveOriginalFilesToDriveMustBeBoolean, - ); - } - } - const config = Object.assign({}, DEFAULT_CONFIG, configObj); - return config; -} - -/** - * Check if the given target Google Drive folder ID is "root" (case-insensitive). - * If it is, return true. Here, "root" is a special value that refers to the root folder - * in My Drive. - * @param targetDriveFolderId The target Google Drive folder ID - * @returns `true` if the target Google Drive folder ID is "root", or `false` if it isn't - */ -export function isRoot(targetDriveFolderId: string): boolean { - return targetDriveFolderId.toLowerCase() === 'root'; -} - -/** - * Get the file names of all Google Sheets files in the target Google Drive folder. - * Iterate through all pages of the results. - * @param config The configuration object defined in `c2g.config.json` - * @returns An array of objects containing the file ID and name of each Google Sheets file in the target Google Drive folder - */ -export async function getExistingSheetsFiles( - drive: drive_v3.Drive, - config: Config, - fileList: drive_v3.Schema$File[] = [], - nextPageToken?: string, -): Promise { - if (config.updateExistingGoogleSheets) { - const params: drive_v3.Params$Resource$Files$List = { - supportsAllDrives: config.targetIsSharedDrive, - q: `'${config.targetDriveFolderId}' in parents and mimeType = 'application/vnd.google-apps.spreadsheet' and trashed = false`, - fields: 'nextPageToken, files(id, name)', - }; - if (nextPageToken) { - params.pageToken = nextPageToken; - } - const existingSheetsFilesObj = await drive.files.list(params); - if (existingSheetsFilesObj.data?.files) { - fileList = fileList.concat(existingSheetsFilesObj.data.files); - if (existingSheetsFilesObj.data.nextPageToken) { - fileList = await getExistingSheetsFiles( - drive, - config, - fileList, - existingSheetsFilesObj.data.nextPageToken, - ); - } - } - } - return fileList; -} - -/** - * Get the full path of each CSV file in the given directory and return them as an array. - * @param sourceDir The path to the source directory to look for CSV files - * @returns An array of full paths of CSV files in the source directory - */ -export function getLocalCsvFilePaths(sourceDir: string): string[] { - // Read the contents of sourceDir and check if there are any CSV files with the extension of .csv - const csvFiles = []; - const sourceDirLower = sourceDir.toLowerCase(); - if (sourceDirLower.endsWith('.csv')) { - // If sourceDir is a single CSV file, simply add it to csvFiles - // Note that the value of sourceDir should be processed through validateConfig() before calling this function - csvFiles.push(sourceDir); - } else { - // If sourceDir is a directory containing CSV files, add the full path of each CSV file to csvFiles - const files = fs.readdirSync(sourceDir); - files.forEach((file) => { - const fileLower = file.toLowerCase(); - if (fileLower.endsWith('.csv')) { - csvFiles.push(path.join(sourceDir, file)); - } - }); - } - return csvFiles; -} - -/** - * Check if the given CSV file name exists in the given Google Drive folder - * and return the Google Sheets file ID if it does. - * If it doesn't, return null. - * @param csvFileName The name of the CSV file - * @param existingSheetsFilesObj The object containing the file names of all Google Sheets files in the target Google Drive folder - * @returns The Google Sheets file ID if the CSV file name exists in the target Google Drive folder, or null if it doesn't - */ -export function getExistingSheetsFileId( - csvFileName: string, - existingSheetsFiles: drive_v3.Schema$File[], -): string | null { - if (existingSheetsFiles.length > 0) { - const existingSheetsFile = existingSheetsFiles.find( - (file: drive_v3.Schema$File) => file.name === csvFileName, - ); - return existingSheetsFile?.id ? existingSheetsFile.id : null; - } else { - return null; - } -} - -/** - * Get the Google Drive folder ID of the "csv" folder in the target Google Drive folder. - * If a folder named "csv" does not exist, create it. - * If `config.saveOriginalFilesToDrive` in the given `config` is `false`, return `null`. - * @param drive The Google Drive API v3 instance created by `google.drive({ version: 'v3', auth })` - * @param config The configuration object defined in `c2g.config.json` - * @returns The Google Drive folder ID of the "csv" folder in the target Google Drive folder, - * or `null` if the given `config.saveOriginalFilesToDrive` is `false` - */ -export async function getCsvFolderId( - drive: drive_v3.Drive, - config: Config, -): Promise { - let csvFolderId: string | null = null; - if (config.saveOriginalFilesToDrive) { - // First, check if the "csv" folder exists - const csvFolderList = await drive.files.list({ - supportsAllDrives: config.targetIsSharedDrive, - q: `name = 'csv' and '${config.targetDriveFolderId}' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false`, - fields: 'files(id, name)', - }); - - // If the "csv" folder does not exist, create it - if ( - !csvFolderList.data?.files || - csvFolderList.data.files.length === 0 || - !csvFolderList.data.files[0].id - ) { - const newCsvFolderRequestBody: drive_v3.Schema$File = { - name: 'csv', - mimeType: 'application/vnd.google-apps.folder', - }; - if (isRoot(config.targetDriveFolderId)) { - newCsvFolderRequestBody.parents = [config.targetDriveFolderId]; - } - const newCsvFolder = await drive.files.create({ - supportsAllDrives: config.targetIsSharedDrive, - requestBody: newCsvFolderRequestBody, - }); - - if (!newCsvFolder.data.id) { - throw new C2gError(MESSAGES.error.c2gErrorFailedToCreateCsvFolder); - } - csvFolderId = newCsvFolder.data.id; - } else { - // If the "csv" folder exists, use its ID - csvFolderId = csvFolderList.data.files[0].id; - } - } - return csvFolderId; -} - /** * The main function of the convert command. * @param options The options passed to the convert command @@ -258,7 +40,7 @@ export default async function convert( ? options.configFilePath : path.join(process.cwd(), CONFIG_FILE_NAME); // Read the configuration file and validate its contents - const config = validateConfig(readConfigFileSync(configFilePath)); + const config = utils.validateConfig(utils.readConfigFileSync(configFilePath)); // Show message on the console let convertingCsvWithFollowingSettings = @@ -272,19 +54,19 @@ export default async function convert( : convertingCsvWithFollowingSettings; console.info(convertingCsvWithFollowingSettings); + // Get the full paths of the local CSV files in the source directory + const csvFiles = utils.getLocalCsvFilePaths(config.sourceDir); + if (csvFiles.length === 0) { + // If there are no CSV files, exit the program with a message + throw new C2gError(MESSAGES.error.c2gErrorNoCsvFilesFound); + } + // Authorize the user const auth = await authorize(); const drive = google.drive({ version: 'v3', auth }); // Get the file names of all Google Sheets files in the target Google Drive folder - const existingSheetsFiles = await getExistingSheetsFiles(drive, config); - - // Get the full path of each CSV file in the source directory - const csvFiles = getLocalCsvFilePaths(config.sourceDir); - if (csvFiles.length === 0) { - // If there are no CSV files, exit the program with a message - throw new C2gError(MESSAGES.error.c2gErrorNoCsvFilesFound); - } + const existingSheetsFiles = await utils.getExistingSheetsFiles(drive, config); // Create an array of objects containing the file name, full path, // and Google Sheets file ID with the same file name (if it exists) @@ -297,16 +79,16 @@ export default async function convert( basename: basename, path: csvFile, existingSheetsFileId: config.updateExistingGoogleSheets - ? getExistingSheetsFileId(fileName, existingSheetsFiles) + ? utils.getExistingSheetsFileId(fileName, existingSheetsFiles) : null, }; }); // Get the Google Drive folder ID of the "csv" folder in the target Google Drive folder // If the "csv" folder does not exist, create it. - const csvFolderId = await getCsvFolderId(drive, config); - // If config.saveOriginalFilesToDrive is false, csvFolderId will be null + const csvFolderId = await utils.getCsvFolderId(drive, config); + if (csvFolderId) { console.info(MESSAGES.log.uploadingOriginalCsvFilesTo(csvFolderId)); } @@ -344,13 +126,16 @@ export default async function convert( }); } else { // Create a new Google Sheets file + const requestBody: drive_v3.Schema$File = { + name: csvFileObj.name, + mimeType: 'application/vnd.google-apps.spreadsheet', + }; + if (utils.isRoot(config.targetDriveFolderId)) { + requestBody.parents = [config.targetDriveFolderId]; + } await drive.files.create({ supportsAllDrives: config.targetIsSharedDrive, - requestBody: { - name: csvFileObj.name, - mimeType: 'application/vnd.google-apps.spreadsheet', - parents: [config.targetDriveFolderId], - }, + requestBody: requestBody, media: { mimeType: 'text/csv', body: csvData, @@ -381,7 +166,7 @@ export default async function convert( if (options.browse) { // Open the target Google Drive folder in the default browser if the --browse option is specified - const url = isRoot(config.targetDriveFolderId) + const url = utils.isRoot(config.targetDriveFolderId) ? 'https://drive.google.com/drive/my-drive' : `https://drive.google.com/drive/folders/${config.targetDriveFolderId}`; console.info(MESSAGES.log.openingTargetDriveFolderOnBrowser(url)); diff --git a/src/utils.ts b/src/utils.ts index fa37fb9..47dc2e3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,18 +1,229 @@ // Utility functions -import ora from 'ora'; +import fs from 'fs'; +import { drive_v3 } from 'googleapis'; +import path from 'path'; + +import { Config, DEFAULT_CONFIG } from './constants'; +import { C2gError } from './c2g-error'; +import { MESSAGES } from './messages'; /** - * ora - The elegant terminal spinner - * @see https://www.npmjs.com/package/ora + * Read the configuration file and return its contents as an object. + * @param configFilePath The path to the configuration file + * @returns The contents of the configuration file as an object */ -export const spinner = ora(); // New spinner instance +export function readConfigFileSync(configFilePath: string): Config { + // Check if the configFilePath is a valid path + if (!fs.existsSync(configFilePath)) { + throw new C2gError(MESSAGES.error.c2gErrorConfigFileNotFound); + } else { + // Read the configuration file and return its contents as an object + const parsedConfig = JSON.parse( + fs.readFileSync(configFilePath, 'utf8'), + ) as Config; + return parsedConfig; + } +} /** - * Stop the spinner if it is running + * Validate the configuration file. + * Note that this function does not check if the target Google Drive folder exists + * or if the user has access to that folder. + * @param configObj The contents of the configuration file as an object */ -export function stopSpinner(): void { - if (spinner.isSpinning) { - spinner.stop(); +export function validateConfig(configObj: Partial): Config { + if (configObj.sourceDir) { + // If sourceDir is not a string, return false + if (typeof configObj.sourceDir !== 'string') { + throw new TypeError(MESSAGES.error.typeErrorSourceDirMustBeString); + } + // If sourceDir is not a valid path, return false + if (!fs.existsSync(configObj.sourceDir)) { + throw new C2gError(MESSAGES.error.c2gErrorSourceDirMustBeValidPath); + } + } + if (configObj.targetDriveFolderId) { + // If targetDriveFolderId is not a string, return false + if (typeof configObj.targetDriveFolderId !== 'string') { + throw new TypeError( + MESSAGES.error.typeErrorTargetDriveFolderIdMustBeString, + ); + } + } + if (configObj.targetIsSharedDrive) { + // If targetIsSharedDrive is not a boolean, return false + if (typeof configObj.targetIsSharedDrive !== 'boolean') { + throw new TypeError( + MESSAGES.error.typeErrorTargetIsSharedDriveMustBeBoolean, + ); + } + } + if (configObj.updateExistingGoogleSheets) { + // If updateExistingGoogleSheets is not a boolean, return false + if (typeof configObj.updateExistingGoogleSheets !== 'boolean') { + throw new TypeError( + MESSAGES.error.typeErrorUpdateExistingGoogleSheetsMustBeBoolean, + ); + } + } + if (configObj.saveOriginalFilesToDrive) { + // If saveOriginalFilesToDrive is not a boolean, return false + if (typeof configObj.saveOriginalFilesToDrive !== 'boolean') { + throw new TypeError( + MESSAGES.error.typeErrorSaveOriginalFilesToDriveMustBeBoolean, + ); + } + } + const config = Object.assign({}, DEFAULT_CONFIG, configObj); + return config; +} + +/** + * Check if the given target Google Drive folder ID is "root" (case-insensitive). + * If it is, return true. Here, "root" is a special value that refers to the root folder + * in My Drive. + * @param targetDriveFolderId The target Google Drive folder ID + * @returns `true` if the target Google Drive folder ID is "root", or `false` if it isn't + */ +export function isRoot(targetDriveFolderId: string): boolean { + return targetDriveFolderId.toLowerCase() === 'root'; +} + +/** + * Get the file names of all Google Sheets files in the target Google Drive folder. + * Iterate through all pages of the results if nextPageToken is present. + * If `config.updateExistingGoogleSheets` in the given `config` is `false`, return an empty array. + * @param config The configuration object defined in `c2g.config.json` + * @returns An array of objects containing the file ID and name of each Google Sheets file in the target Google Drive folder + */ +export async function getExistingSheetsFiles( + drive: drive_v3.Drive, + config: Config, + fileList: drive_v3.Schema$File[] = [], + nextPageToken?: string, +): Promise { + if (config.updateExistingGoogleSheets) { + const params: drive_v3.Params$Resource$Files$List = { + supportsAllDrives: config.targetIsSharedDrive, + q: `'${config.targetDriveFolderId}' in parents and mimeType = 'application/vnd.google-apps.spreadsheet' and trashed = false`, + fields: 'nextPageToken, files(id, name)', + }; + if (nextPageToken) { + params.pageToken = nextPageToken; + } + const existingSheetsFilesObj = await drive.files.list(params); + if (existingSheetsFilesObj.data?.files) { + fileList = fileList.concat(existingSheetsFilesObj.data.files); + if (existingSheetsFilesObj.data.nextPageToken) { + fileList = await getExistingSheetsFiles( + drive, + config, + fileList, + existingSheetsFilesObj.data.nextPageToken, + ); + } + } + } + return fileList; +} + +/** + * Get the full path of each CSV file in the given directory and return them as an array. + * @param sourceDir The path to the source directory to look for CSV files + * @returns An array of full paths of CSV files in the source directory + */ +export function getLocalCsvFilePaths(sourceDir: string): string[] { + // Read the contents of sourceDir and check if there are any CSV files with the extension of .csv + const csvFiles = []; + const sourceDirLower = sourceDir.toLowerCase(); + if (sourceDirLower.endsWith('.csv')) { + // If sourceDir is a single CSV file, simply add it to csvFiles + // Note that the value of sourceDir should be processed through validateConfig() before calling this function + csvFiles.push(sourceDir); + } else { + // If sourceDir is a directory containing CSV files, add the full path of each CSV file to csvFiles + const files = fs.readdirSync(sourceDir); + files.forEach((file) => { + const fileLower = file.toLowerCase(); + if (fileLower.endsWith('.csv')) { + csvFiles.push(path.join(sourceDir, file)); + } + }); + } + return csvFiles; +} + +/** + * Check if the given CSV file name exists in the given Google Drive folder + * and return the Google Sheets file ID if it does. + * If it doesn't, return null. + * @param csvFileName The name of the CSV file + * @param existingSheetsFilesObj The object containing the file names of all Google Sheets files in the target Google Drive folder + * @returns The Google Sheets file ID if the CSV file name exists in the target Google Drive folder, or null if it doesn't + */ +export function getExistingSheetsFileId( + csvFileName: string, + existingSheetsFiles: drive_v3.Schema$File[], +): string | null { + if (existingSheetsFiles.length > 0) { + const existingSheetsFile = existingSheetsFiles.find( + (file: drive_v3.Schema$File) => file.name === csvFileName, + ); + return existingSheetsFile?.id ? existingSheetsFile.id : null; + } else { + return null; + } +} + +/** + * Get the Google Drive folder ID of the "csv" folder in the target Google Drive folder. + * If a folder named "csv" does not exist, create it. + * If `config.saveOriginalFilesToDrive` in the given `config` is `false`, return `null`. + * @param drive The Google Drive API v3 instance created by `google.drive({ version: 'v3', auth })` + * @param config The configuration object defined in `c2g.config.json` + * @returns The Google Drive folder ID of the "csv" folder in the target Google Drive folder, + * or `null` if the given `config.saveOriginalFilesToDrive` is `false` + */ +export async function getCsvFolderId( + drive: drive_v3.Drive, + config: Config, +): Promise { + let csvFolderId: string | null = null; + if (config.saveOriginalFilesToDrive) { + // First, check if the "csv" folder exists + const csvFolderList = await drive.files.list({ + supportsAllDrives: config.targetIsSharedDrive, + q: `name = 'csv' and '${config.targetDriveFolderId}' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false`, + fields: 'files(id, name)', + }); + + // If the "csv" folder does not exist, create it + if ( + !csvFolderList.data?.files || + csvFolderList.data.files.length === 0 || + !csvFolderList.data.files[0].id + ) { + const newCsvFolderRequestBody: drive_v3.Schema$File = { + name: 'csv', + mimeType: 'application/vnd.google-apps.folder', + }; + if (isRoot(config.targetDriveFolderId)) { + newCsvFolderRequestBody.parents = [config.targetDriveFolderId]; + } + const newCsvFolder = await drive.files.create({ + supportsAllDrives: config.targetIsSharedDrive, + requestBody: newCsvFolderRequestBody, + }); + + if (!newCsvFolder.data.id) { + throw new C2gError(MESSAGES.error.c2gErrorFailedToCreateCsvFolder); + } + csvFolderId = newCsvFolder.data.id; + } else { + // If the "csv" folder exists, use its ID + csvFolderId = csvFolderList.data.files[0].id; + } } + return csvFolderId; } From db8194d2c45a542e254c4751f321670d112d0b26 Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Tue, 19 Sep 2023 03:14:31 +0900 Subject: [PATCH 15/31] Complete tests for convert command and comprising functions --- test/convert.test.ts | 765 ++++++++++++++++++------------------------- test/utils.test.ts | 456 ++++++++++++++++++++++++++ 2 files changed, 772 insertions(+), 449 deletions(-) create mode 100644 test/utils.test.ts diff --git a/test/convert.test.ts b/test/convert.test.ts index 1147625..3aa31b0 100644 --- a/test/convert.test.ts +++ b/test/convert.test.ts @@ -1,437 +1,24 @@ // Jest test for the convert command in ./src/commands/convert.ts import fs from 'fs'; -// import open from 'open'; +import open from 'open'; import path from 'path'; -// import { ChildProcess } from 'child_process'; +import { ChildProcess } from 'child_process'; import { google, drive_v3 } from 'googleapis'; import { OAuth2Client } from 'google-auth-library'; import * as auth from '../src/auth'; -import { Config, DEFAULT_CONFIG, HOME_DIR } from '../src/constants'; -import * as convert from '../src/commands/convert'; import { C2gError } from '../src/c2g-error'; +import convert from '../src/commands/convert'; +import { Config } from '../src/constants'; import { MESSAGES } from '../src/messages'; +import * as utils from '../src/utils'; -describe('readConfigFileSync', () => { - const configFilePath = '/path/to/config.json'; - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should return the contents of the config file as an object', () => { - const config = { - sourceDir: '/path/to/source', - targetDriveFolderId: '12345', - targetIsSharedDrive: true, - updateExistingGoogleSheets: true, - saveOriginalFilesToDrive: false, - }; - jest.spyOn(fs, 'existsSync').mockReturnValue(true); - jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(config)); - expect(convert.readConfigFileSync(configFilePath)).toEqual(config); - }); - - it('should throw an error if the config file does not exist', () => { - jest.spyOn(fs, 'existsSync').mockReturnValue(false); - expect(() => convert.readConfigFileSync(configFilePath)).toThrow(C2gError); - }); - - it('should throw an error if the config file is not valid JSON', () => { - jest.spyOn(fs, 'existsSync').mockReturnValue(true); - jest.spyOn(fs, 'readFileSync').mockReturnValue('not valid JSON'); - expect(() => convert.readConfigFileSync(configFilePath)).toThrow(); - }); -}); - -describe('validateConfig', () => { - it('should return the config object if it is valid', () => { - const config = { - sourceDir: HOME_DIR, - targetDriveFolderId: '12345', - targetIsSharedDrive: true, - updateExistingGoogleSheets: true, - saveOriginalFilesToDrive: false, - } as Partial; - expect(convert.validateConfig(config)).toEqual(config); - }); - - it('should throw an error if sourceDir is not a string', () => { - const config = { sourceDir: 123 } as unknown as Partial; - expect(() => convert.validateConfig(config)).toThrow(TypeError); - }); - - it('should throw an error if sourceDir is not a valid path', () => { - const config = { - sourceDir: '/path/to/nonexistent/directory', - } as Partial; - jest.spyOn(fs, 'existsSync').mockReturnValue(false); - expect(() => convert.validateConfig(config)).toThrow(C2gError); - }); - - it('should throw an error if targetDriveFolderId is not a string', () => { - const config = { targetDriveFolderId: 123 } as unknown as Partial; - expect(() => convert.validateConfig(config)).toThrow(TypeError); - }); - - it('should throw an error if targetIsSharedDrive is not a boolean', () => { - const config = { - targetIsSharedDrive: 'true', - } as unknown as Partial; - expect(() => convert.validateConfig(config)).toThrow(TypeError); - }); - - it('should throw an error if updateExistingGoogleSheets is not a boolean', () => { - const config = { - updateExistingGoogleSheets: 'true', - } as unknown as Partial; - expect(() => convert.validateConfig(config)).toThrow(TypeError); - }); - - it('should throw an error if saveOriginalFilesToDrive is not a boolean', () => { - const config = { - saveOriginalFilesToDrive: 'false', - } as unknown as Partial; - expect(() => convert.validateConfig(config)).toThrow(TypeError); - }); - - it('should add default values for missing config properties', () => { - const config = {} as Partial; - expect(convert.validateConfig(config)).toEqual(DEFAULT_CONFIG); - }); -}); - -describe('isRoot', () => { - it('should return true if targetDriveFolderId is "root" (case-insensitive)', () => { - expect(convert.isRoot('root')).toBe(true); - expect(convert.isRoot('ROOT')).toBe(true); - expect(convert.isRoot('Root')).toBe(true); - }); - it('should return false if targetDriveFolderId is not "root" or "ROOT"', () => { - expect(convert.isRoot('12345')).toBe(false); - }); -}); - -describe('getLocalCsvFilePaths', () => { - jest.mock('fs'); - const testDir = path.join(process.cwd(), 'testDir'); - - it('should return an array with the full path of a single CSV file', () => { - const mockSingleCsvFilePath = path.join(testDir, 'file1.csv'); - const csvFiles = convert.getLocalCsvFilePaths(mockSingleCsvFilePath); - expect(csvFiles).toEqual([mockSingleCsvFilePath]); - }); - - it('should return an array with the full path of all CSV files in a directory', () => { - const mockTestFiles = ['file1.csv', 'file2.CSV', 'file3.txt']; - const mockCsvFiles = ['file1.csv', 'file2.CSV']; - const mockCsvFilePaths = mockCsvFiles.map((file) => - path.join(testDir, file), - ); - jest - .spyOn(fs, 'readdirSync') - .mockReturnValue(mockTestFiles as unknown as fs.Dirent[]); - const csvFiles = convert.getLocalCsvFilePaths(testDir); - expect(csvFiles).toEqual(mockCsvFilePaths); - }); - - it('should return an empty array if there are no CSV files in a directory', () => { - jest.spyOn(fs, 'readdirSync').mockReturnValue([]); - const csvFiles = convert.getLocalCsvFilePaths(testDir); - expect(csvFiles).toEqual([]); - }); -}); - -describe('getExistingSheetsFiles', () => { - jest.mock('googleapis'); - afterEach(() => { - jest.restoreAllMocks(); - }); - - const baseConfig: Config = { - sourceDir: '/path/to/source', - targetDriveFolderId: '12345', - targetIsSharedDrive: true, - updateExistingGoogleSheets: true, - saveOriginalFilesToDrive: false, - }; - - it('should return an array of existing Google Sheets files without nextPageToken', async () => { - const mockDrive = { - files: { - list: jest.fn().mockImplementation(() => { - return { - data: { - files: [ - { - id: '12345', - name: 'file1', - } as drive_v3.Schema$File, - { - id: '67890', - name: 'file2', - } as drive_v3.Schema$File, - ], - } as drive_v3.Schema$FileList, - }; - }), - } as unknown as drive_v3.Resource$Files, - } as unknown as drive_v3.Drive; - const mockConfig = baseConfig; - expect(await convert.getExistingSheetsFiles(mockDrive, mockConfig)).toEqual( - [ - { - id: '12345', - name: 'file1', - }, - { - id: '67890', - name: 'file2', - }, - ], - ); - }); - - it('should return an array of existing Google Sheets files with recursive calls using nextPageToken', async () => { - const mockDrive = { - files: { - list: jest - .fn() - .mockImplementationOnce(() => { - return { - data: { - files: [ - { - id: '12345', - name: 'file1', - } as drive_v3.Schema$File, - { - id: '67890', - name: 'file2', - } as drive_v3.Schema$File, - ] as drive_v3.Schema$FileList, - nextPageToken: 'nextPageToken123', - }, - }; - }) - .mockImplementationOnce(() => { - return { - data: { - files: [ - { - id: 'abcde', - name: 'file3', - } as drive_v3.Schema$File, - ] as drive_v3.Schema$FileList, - }, - }; - }), - } as unknown as drive_v3.Resource$Files, - } as unknown as drive_v3.Drive; - const mockConfig = baseConfig; - expect(await convert.getExistingSheetsFiles(mockDrive, mockConfig)).toEqual( - [ - { - id: '12345', - name: 'file1', - }, - { - id: '67890', - name: 'file2', - }, - { - id: 'abcde', - name: 'file3', - }, - ], - ); - }); - - it('should return the original fileList if config.updateExistingGoogleSheets is false', async () => { - const mockDrive = {} as unknown as drive_v3.Drive; - const mockConfig = { - ...baseConfig, - updateExistingGoogleSheets: false, - }; - const mockFileList = [ - { - id: '12345', - name: 'file1', - }, - { - name: 'file2', - }, - ] as unknown as drive_v3.Schema$File[]; - expect( - await convert.getExistingSheetsFiles(mockDrive, mockConfig, mockFileList), - ).toEqual(mockFileList); - }); -}); - -describe('getExistingSheetsFileId', () => { - const mockExistingSheetsFiles = [ - { - id: '12345', - name: 'file1', - }, - { - name: 'file2', - }, - ] as unknown as drive_v3.Schema$File[]; - const mockEmptyExistingSheetsFiles = [] as unknown as drive_v3.Schema$File[]; - - it('should return the file ID if the file exists', () => { - expect( - convert.getExistingSheetsFileId('file1', mockExistingSheetsFiles), - ).toBe('12345'); - }); - - it('should return null if the existing file does not have a valid ID', () => { - expect( - convert.getExistingSheetsFileId('file2', mockExistingSheetsFiles), - ).toBeNull(); - }); - - it('should return null if the file does not exist', () => { - expect( - convert.getExistingSheetsFileId('file99', mockExistingSheetsFiles), - ).toBeNull(); - }); - - it('should return null if the array existingSheetsFiles has the length of 0', () => { - expect( - convert.getExistingSheetsFileId('file1', mockEmptyExistingSheetsFiles), - ).toBeNull(); - }); -}); - -describe('getCsvFolderId', () => { - jest.mock('googleapis'); - afterEach(() => { - jest.restoreAllMocks(); - }); - - const baseConfig: Config = { - sourceDir: '/path/to/source', - targetDriveFolderId: 'TargetDriveFolderId12345', - targetIsSharedDrive: true, - updateExistingGoogleSheets: true, - saveOriginalFilesToDrive: true, - }; - - it('should return the ID of the csv folder if config.saveOriginalFilesToDrive is false and it exists', async () => { - const mockDrive = { - files: { - list: jest.fn().mockImplementation(() => { - return { - data: { - files: [ - { - id: 'CsvFolderId12345', - name: 'csv', - } as drive_v3.Schema$File, - { - id: 'OtherFolderId67890', - name: 'csv', - } as drive_v3.Schema$File, - ] as drive_v3.Schema$FileList, - }, - }; - }), - } as unknown as drive_v3.Resource$Files, - } as unknown as drive_v3.Drive; - const mockConfig = baseConfig; - expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( - 'CsvFolderId12345', - ); - }); - - it('should create a new folder in the target Google Drive folder and return its ID', async () => { - const mockDrive = { - files: { - list: jest - .fn() - .mockImplementationOnce(() => { - return { - data: { - files: [] as drive_v3.Schema$FileList, - }, - }; - }) - .mockImplementationOnce(() => { - return {}; - }) - .mockImplementationOnce(() => { - return { - data: { - files: [ - { - noid: 'no-id', - name: 'csv', - } as drive_v3.Schema$File, - ] as drive_v3.Schema$FileList, - }, - }; - }), - create: jest.fn().mockImplementation(() => { - return { - data: { - id: 'NewlyCreatedCsvFolderId12345', - }, - }; - }), - } as unknown as drive_v3.Resource$Files, - } as unknown as drive_v3.Drive; - const mockConfig = baseConfig; - expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( - 'NewlyCreatedCsvFolderId12345', - ); - expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( - 'NewlyCreatedCsvFolderId12345', - ); - expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBe( - 'NewlyCreatedCsvFolderId12345', - ); - }); - - it('should throw an error if the csv folder could not be created', async () => { - const mockDrive = { - files: { - list: jest.fn().mockImplementation(() => { - return { - data: { - files: [] as drive_v3.Schema$FileList, - }, - }; - }), - create: jest.fn().mockImplementation(() => { - return { - data: {}, - }; - }), - } as unknown as drive_v3.Resource$Files, - } as unknown as drive_v3.Drive; - const mockConfig = baseConfig; - await expect(convert.getCsvFolderId(mockDrive, mockConfig)).rejects.toThrow( - C2gError, - ); - }); - - it('should return null if config.saveOriginalFilesToDrive is false', async () => { - const mockDrive = {} as unknown as drive_v3.Drive; - const mockConfig = { - ...baseConfig, - saveOriginalFilesToDrive: false, - }; - expect(await convert.getCsvFolderId(mockDrive, mockConfig)).toBeNull(); - }); -}); +jest.mock('fs'); +jest.mock('googleapis'); +jest.mock('open'); describe('convert', () => { - jest.mock('googleapis'); - jest.mock('fs'); - afterEach(() => { jest.restoreAllMocks(); }); @@ -446,7 +33,7 @@ describe('convert', () => { it('should throw an error if the user is not logged in', async () => { jest.spyOn(auth, 'isAuthorized').mockImplementation(() => false); - await expect(convert.default({})).rejects.toThrow( + await expect(convert({})).rejects.toThrow( new C2gError(MESSAGES.error.c2gErrorNotLoggedIn), ); }); @@ -454,16 +41,12 @@ describe('convert', () => { it('should throw an error if there are no CSV files in the designated local directory', async () => { jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); jest.spyOn(console, 'info').mockImplementation(); - jest.spyOn(fs, 'existsSync').mockReturnValue(true); // readConfigFileSync, validateConfig - jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); // readConfigFileSync - jest.spyOn(fs, 'readdirSync').mockReturnValue([]); // getLocalCsvFilePaths - jest.spyOn(auth, 'authorize').mockImplementation(() => { - return Promise.resolve({} as unknown as OAuth2Client); - }); - jest.spyOn(google, 'drive').mockImplementation(() => { - return {} as unknown as drive_v3.Drive; - }); - await expect(convert.default({})).rejects.toThrow( + jest + .spyOn(utils, 'readConfigFileSync') + .mockImplementation(() => mockConfig); + jest.spyOn(utils, 'validateConfig').mockImplementation(() => mockConfig); + jest.spyOn(utils, 'getLocalCsvFilePaths').mockImplementation(() => []); + await expect(convert({})).rejects.toThrow( new C2gError(MESSAGES.error.c2gErrorNoCsvFilesFound), ); }); @@ -476,11 +59,14 @@ describe('convert', () => { ); jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); jest.spyOn(console, 'info').mockImplementation(); - jest.spyOn(fs, 'existsSync').mockReturnValue(true); // readConfigFileSync, validateConfig - jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); // readConfigFileSync jest - .spyOn(fs, 'readdirSync') - .mockReturnValue(mockLocalCsvFilePaths as unknown as fs.Dirent[]); // getLocalCsvFilePaths + .spyOn(utils, 'readConfigFileSync') + .mockImplementation(() => mockConfig); + jest.spyOn(utils, 'validateConfig').mockImplementation(() => mockConfig); + jest + .spyOn(utils, 'getLocalCsvFilePaths') + .mockImplementation(() => mockLocalCsvFilePaths); + jest.spyOn(auth, 'authorize').mockImplementation(() => { return Promise.resolve({} as unknown as OAuth2Client); }); @@ -488,7 +74,7 @@ describe('convert', () => { return {} as unknown as drive_v3.Drive; }); // Act - await convert.default({ dryRun: true }); + await convert({ dryRun: true }); // Assert expect(console.info).toHaveBeenNthCalledWith( 1, @@ -517,13 +103,13 @@ describe('convert', () => { MESSAGES.log.processingCsvFileComplete, ); }); - /* + it('should open the default web browser if --browse option is enabled', async () => { // Arrange - //jest.mock('open', () => jest.fn()); - //const mockOpen = open as jest.MockedFunction; - // mockOpen.mockResolvedValue({ pid: 12345 } as unknown as ChildProcess); - // jest.spyOn(open, 'default'); + const mockOpen = open as jest.MockedFunction; + mockOpen.mockImplementation(() => { + return Promise.resolve({ pid: 12345 } as unknown as ChildProcess); + }); // Other arranged settings are the same as the previous test: // 'should show the complete conversion process on dry run' const mockLocalCsvFiles = ['file1.csv', 'file2.CSV']; @@ -532,11 +118,14 @@ describe('convert', () => { ); jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); jest.spyOn(console, 'info').mockImplementation(); - jest.spyOn(fs, 'existsSync').mockReturnValue(true); // readConfigFileSync, validateConfig - jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockConfig)); // readConfigFileSync jest - .spyOn(fs, 'readdirSync') - .mockReturnValue(mockLocalCsvFilePaths as unknown as fs.Dirent[]); // getLocalCsvFilePaths + .spyOn(utils, 'readConfigFileSync') + .mockImplementation(() => mockConfig); + jest.spyOn(utils, 'validateConfig').mockImplementation(() => mockConfig); + jest + .spyOn(utils, 'getLocalCsvFilePaths') + .mockImplementation(() => mockLocalCsvFilePaths); + jest.spyOn(auth, 'authorize').mockImplementation(() => { return Promise.resolve({} as unknown as OAuth2Client); }); @@ -544,9 +133,8 @@ describe('convert', () => { return {} as unknown as drive_v3.Drive; }); // Act - await convert.default({ dryRun: true, browse: true }); + await convert({ dryRun: true, browse: true }); // Assert - expect(mockOpen as jest.Mock).toHaveBeenCalled(); expect(mockOpen).toHaveBeenCalledWith( `https://drive.google.com/drive/folders/${mockConfig.targetDriveFolderId}`, ); @@ -557,5 +145,284 @@ describe('convert', () => { ), ); }); - */ + + it('should complete the conversion process on dry run with configFilePath', async () => { + // Arrange + const mockConfigFilePath = 'path/to/c2g.config.json'; + // Other settings are the same as the previous test: + // 'should show the complete conversion process on dry run' + const mockLocalCsvFiles = ['file1.csv', 'file2.CSV']; + const mockLocalCsvFilePaths = mockLocalCsvFiles.map((file) => + path.join(mockConfig.sourceDir, file), + ); + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); + jest.spyOn(console, 'info').mockImplementation(); + jest + .spyOn(utils, 'readConfigFileSync') + .mockImplementation(() => mockConfig); + jest.spyOn(utils, 'validateConfig').mockImplementation(() => mockConfig); + jest + .spyOn(utils, 'getLocalCsvFilePaths') + .mockImplementation(() => mockLocalCsvFilePaths); + + jest.spyOn(auth, 'authorize').mockImplementation(() => { + return Promise.resolve({} as unknown as OAuth2Client); + }); + jest.spyOn(google, 'drive').mockImplementation(() => { + return {} as unknown as drive_v3.Drive; + }); + // Act: Add configFilePath as an option + await convert({ + dryRun: true, + configFilePath: mockConfigFilePath, + }); + // Assert + expect(utils.readConfigFileSync).toHaveBeenCalledWith(mockConfigFilePath); + }); + + it('should complete the conversion process on dry run with updateExistingGoogleSheets being true', async () => { + // Arrange + const mockConfigWithUpdateExistingGoogleSheets: Config = { + ...mockConfig, + updateExistingGoogleSheets: true, + }; + jest.spyOn(utils, 'getExistingSheetsFiles').mockImplementation(() => { + return Promise.resolve([]); + }); + jest.spyOn(utils, 'getExistingSheetsFileId').mockImplementation(() => null); + // Other settings are the same as the previous test: + // 'should show the complete conversion process on dry run' + // except for mockConfig being replaced with mockConfigWithUpdateExistingGoogleSheets + const mockLocalCsvFiles = ['file1.csv', 'file2.CSV']; + const mockLocalCsvFilePaths = mockLocalCsvFiles.map((file) => + path.join(mockConfigWithUpdateExistingGoogleSheets.sourceDir, file), + ); + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); + jest.spyOn(console, 'info').mockImplementation(); + jest + .spyOn(utils, 'readConfigFileSync') + .mockImplementation(() => mockConfigWithUpdateExistingGoogleSheets); + jest + .spyOn(utils, 'validateConfig') + .mockImplementation(() => mockConfigWithUpdateExistingGoogleSheets); + jest + .spyOn(utils, 'getLocalCsvFilePaths') + .mockImplementation(() => mockLocalCsvFilePaths); + + jest.spyOn(auth, 'authorize').mockImplementation(() => { + return Promise.resolve({} as unknown as OAuth2Client); + }); + // Act: Add configFilePath as an option + await convert({ dryRun: true }); + // Assert + expect(utils.getExistingSheetsFileId).toHaveBeenCalled(); + }); + + it('should complete the conversion process with updateExistingGoogleSheets=true', async () => { + // Arrange + const mockConfigWithUpdateExistingGoogleSheets: Config = { + ...mockConfig, + updateExistingGoogleSheets: true, + }; + const mockExistingSheetsFileId = 'file1Id12345'; + jest.spyOn(utils, 'getExistingSheetsFiles').mockImplementation(() => { + return Promise.resolve([]); + }); + jest + .spyOn(utils, 'getExistingSheetsFileId') + .mockImplementationOnce(() => mockExistingSheetsFileId) + .mockImplementationOnce(() => null); + const mockReadStream = {} as fs.ReadStream; + jest.spyOn(fs, 'createReadStream').mockReturnValue(mockReadStream); + const mockDrive = { + files: { + update: jest.fn().mockImplementation(() => { + return Promise.resolve({}); // update existing Google Sheets file + }), + create: jest.fn().mockImplementation(() => { + return Promise.resolve({}); // create new Google Sheets file + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + jest.spyOn(google, 'drive').mockImplementation(() => mockDrive); + // Other settings are the same as the previous test: + // 'should show the complete conversion process on dry run' + // except for mockConfig being replaced with mockConfigWithUpdateExistingGoogleSheets + const mockLocalCsvFiles = ['file1.csv', 'file2.CSV']; + const mockLocalCsvFilePaths = mockLocalCsvFiles.map((file) => + path.join(mockConfigWithUpdateExistingGoogleSheets.sourceDir, file), + ); + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); + jest.spyOn(console, 'info').mockImplementation(); + jest + .spyOn(utils, 'readConfigFileSync') + .mockImplementation(() => mockConfigWithUpdateExistingGoogleSheets); + jest + .spyOn(utils, 'validateConfig') + .mockImplementation(() => mockConfigWithUpdateExistingGoogleSheets); + jest + .spyOn(utils, 'getLocalCsvFilePaths') + .mockImplementation(() => mockLocalCsvFilePaths); + + jest.spyOn(auth, 'authorize').mockImplementation(() => { + return Promise.resolve({} as unknown as OAuth2Client); + }); + // Act + await convert({}); + // Assert + expect(mockDrive.files.update).toHaveBeenCalledTimes(1); + expect(mockDrive.files.update).toHaveBeenCalledWith({ + supportsAllDrives: + mockConfigWithUpdateExistingGoogleSheets.targetIsSharedDrive, + fileId: mockExistingSheetsFileId, + media: { + mimeType: 'text/csv', + body: mockReadStream, + }, + }); + expect(mockDrive.files.create).toHaveBeenCalledTimes(1); + expect(mockDrive.files.create).toHaveBeenCalledWith({ + supportsAllDrives: + mockConfigWithUpdateExistingGoogleSheets.targetIsSharedDrive, + requestBody: { + name: 'file2', + mimeType: 'application/vnd.google-apps.spreadsheet', + }, + media: { + mimeType: 'text/csv', + body: mockReadStream, + }, + }); + }); + + it('should complete the conversion process with updateExistingGoogleSheets=true, targetDriveFolderId=root, --browse=true', async () => { + // Arrange + const mockConfigWithTargetRoot: Config = { + ...mockConfig, + updateExistingGoogleSheets: true, + targetDriveFolderId: 'root', + }; + const mockExistingSheetsFileId = 'file1Id12345'; + jest.spyOn(utils, 'getExistingSheetsFiles').mockImplementation(() => { + return Promise.resolve([]); + }); + jest + .spyOn(utils, 'getExistingSheetsFileId') + .mockImplementationOnce(() => mockExistingSheetsFileId) + .mockImplementationOnce(() => null); + const mockReadStream = {} as fs.ReadStream; + jest.spyOn(fs, 'createReadStream').mockReturnValue(mockReadStream); + const mockDrive = { + files: { + update: jest.fn().mockImplementation(() => { + return Promise.resolve({}); // update existing Google Sheets file + }), + create: jest.fn().mockImplementation(() => { + return Promise.resolve({}); // create new Google Sheets file + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + jest.spyOn(google, 'drive').mockImplementation(() => mockDrive); + // Other settings are the same as the previous test: + // 'should show the complete conversion process on dry run' + // except for mockConfig being replaced with mockConfigWithUpdateExistingGoogleSheets + const mockLocalCsvFiles = ['file1.csv', 'file2.CSV']; + const mockLocalCsvFilePaths = mockLocalCsvFiles.map((file) => + path.join(mockConfigWithTargetRoot.sourceDir, file), + ); + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); + jest.spyOn(console, 'info').mockImplementation(); + jest + .spyOn(utils, 'readConfigFileSync') + .mockImplementation(() => mockConfigWithTargetRoot); + jest + .spyOn(utils, 'validateConfig') + .mockImplementation(() => mockConfigWithTargetRoot); + jest + .spyOn(utils, 'getLocalCsvFilePaths') + .mockImplementation(() => mockLocalCsvFilePaths); + + jest.spyOn(auth, 'authorize').mockImplementation(() => { + return Promise.resolve({} as unknown as OAuth2Client); + }); + const mockOpen = open as jest.MockedFunction; + mockOpen.mockImplementation(() => { + return Promise.resolve({ pid: 12345 } as unknown as ChildProcess); + }); + // Act + await convert({ browse: true }); + // Assert + expect(mockOpen).toHaveBeenCalledWith( + 'https://drive.google.com/drive/my-drive', + ); + expect(console.info).toHaveBeenNthCalledWith( + 4, + MESSAGES.log.processingCsvFile(mockLocalCsvFiles[1], null), + ); + expect(console.info).toHaveBeenNthCalledWith( + 6, + MESSAGES.log.openingTargetDriveFolderOnBrowser( + 'https://drive.google.com/drive/my-drive', + ), + ); + }); + + it('should complete the conversion process with saveOriginalFilesToDrive=true', async () => { + // Arrange + const mockConfigWithSaveOriginalFilesToDrive: Config = { + ...mockConfig, + saveOriginalFilesToDrive: true, + }; + const mockCsvFolderId = 'CsvFolderId12345'; + jest.spyOn(utils, 'getExistingSheetsFiles').mockImplementation(() => { + return Promise.resolve([]); + }); + jest.spyOn(utils, 'getExistingSheetsFileId').mockImplementation(() => null); + jest.spyOn(utils, 'getCsvFolderId').mockImplementation(() => { + return Promise.resolve(mockCsvFolderId); + }); + const mockReadStream = {} as fs.ReadStream; + jest.spyOn(fs, 'createReadStream').mockReturnValue(mockReadStream); + const mockDrive = { + files: { + update: jest.fn().mockImplementation(() => { + return Promise.resolve({}); // update existing Google Sheets file + }), + create: jest.fn().mockImplementation(() => { + return Promise.resolve({}); // create new Google Sheets file + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + jest.spyOn(google, 'drive').mockImplementation(() => mockDrive); + // Other settings are the same as the previous test: + // 'should show the complete conversion process on dry run' + // except for mockConfig being replaced with mockConfigWithUpdateExistingGoogleSheets + const mockLocalCsvFiles = ['file1.csv', 'file2.CSV']; + const mockLocalCsvFilePaths = mockLocalCsvFiles.map((file) => + path.join(mockConfigWithSaveOriginalFilesToDrive.sourceDir, file), + ); + jest.spyOn(auth, 'isAuthorized').mockImplementation(() => true); + jest.spyOn(console, 'info').mockImplementation(); + jest + .spyOn(utils, 'readConfigFileSync') + .mockImplementation(() => mockConfigWithSaveOriginalFilesToDrive); + jest + .spyOn(utils, 'validateConfig') + .mockImplementation(() => mockConfigWithSaveOriginalFilesToDrive); + jest + .spyOn(utils, 'getLocalCsvFilePaths') + .mockImplementation(() => mockLocalCsvFilePaths); + + jest.spyOn(auth, 'authorize').mockImplementation(() => { + return Promise.resolve({} as unknown as OAuth2Client); + }); + // Act + await convert({}); + // Assert + expect(console.info).toHaveBeenNthCalledWith( + 2, + MESSAGES.log.uploadingOriginalCsvFilesTo(mockCsvFolderId), + ); + expect(mockDrive.files.create).toHaveBeenCalledTimes(4); + }); }); diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 0000000..1302ff1 --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,456 @@ +// Jest test for functions in ./src/utils.ts + +import fs from 'fs'; +import path from 'path'; +import { drive_v3 } from 'googleapis'; + +import { Config, DEFAULT_CONFIG, HOME_DIR } from '../src/constants'; +import * as utils from '../src/utils'; +import { C2gError } from '../src/c2g-error'; + +jest.mock('fs'); +jest.mock('googleapis'); + +describe('readConfigFileSync', () => { + const configFilePath = '/path/to/config.json'; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return the contents of the config file as an object', () => { + const config = { + sourceDir: '/path/to/source', + targetDriveFolderId: '12345', + targetIsSharedDrive: true, + updateExistingGoogleSheets: true, + saveOriginalFilesToDrive: false, + }; + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(config)); + expect(utils.readConfigFileSync(configFilePath)).toEqual(config); + }); + + it('should throw an error if the config file does not exist', () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + expect(() => utils.readConfigFileSync(configFilePath)).toThrow(C2gError); + }); + + it('should throw an error if the config file is not valid JSON', () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'readFileSync').mockReturnValue('not valid JSON'); + expect(() => utils.readConfigFileSync(configFilePath)).toThrow(); + }); +}); + +describe('validateConfig', () => { + it('should return the config object if it is valid', () => { + const config = { + sourceDir: HOME_DIR, + targetDriveFolderId: '12345', + targetIsSharedDrive: true, + updateExistingGoogleSheets: true, + saveOriginalFilesToDrive: false, + } as Partial; + expect(utils.validateConfig(config)).toEqual(config); + }); + + it('should throw an error if sourceDir is not a string', () => { + const config = { sourceDir: 123 } as unknown as Partial; + expect(() => utils.validateConfig(config)).toThrow(TypeError); + }); + + it('should throw an error if sourceDir is not a valid path', () => { + const config = { + sourceDir: '/path/to/nonexistent/directory', + } as Partial; + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + expect(() => utils.validateConfig(config)).toThrow(C2gError); + }); + + it('should throw an error if targetDriveFolderId is not a string', () => { + const config = { targetDriveFolderId: 123 } as unknown as Partial; + expect(() => utils.validateConfig(config)).toThrow(TypeError); + }); + + it('should throw an error if targetIsSharedDrive is not a boolean', () => { + const config = { + targetIsSharedDrive: 'true', + } as unknown as Partial; + expect(() => utils.validateConfig(config)).toThrow(TypeError); + }); + + it('should throw an error if updateExistingGoogleSheets is not a boolean', () => { + const config = { + updateExistingGoogleSheets: 'true', + } as unknown as Partial; + expect(() => utils.validateConfig(config)).toThrow(TypeError); + }); + + it('should throw an error if saveOriginalFilesToDrive is not a boolean', () => { + const config = { + saveOriginalFilesToDrive: 'false', + } as unknown as Partial; + expect(() => utils.validateConfig(config)).toThrow(TypeError); + }); + + it('should add default values for missing config properties', () => { + const config = {} as Partial; + expect(utils.validateConfig(config)).toEqual(DEFAULT_CONFIG); + }); +}); + +describe('isRoot', () => { + it('should return true if targetDriveFolderId is "root" (case-insensitive)', () => { + expect(utils.isRoot('root')).toBe(true); + expect(utils.isRoot('ROOT')).toBe(true); + expect(utils.isRoot('Root')).toBe(true); + }); + it('should return false if targetDriveFolderId is not "root" or "ROOT"', () => { + expect(utils.isRoot('12345')).toBe(false); + }); +}); + +describe('getLocalCsvFilePaths', () => { + const testDir = path.join(process.cwd(), 'testDir'); + + it('should return an array with the full path of a single CSV file', () => { + const mockSingleCsvFilePath = path.join(testDir, 'file1.csv'); + const csvFiles = utils.getLocalCsvFilePaths(mockSingleCsvFilePath); + expect(csvFiles).toEqual([mockSingleCsvFilePath]); + }); + + it('should return an array with the full path of all CSV files in a directory', () => { + const mockTestFiles = ['file1.csv', 'file2.CSV', 'file3.txt']; + const mockCsvFiles = ['file1.csv', 'file2.CSV']; + const mockCsvFilePaths = mockCsvFiles.map((file) => + path.join(testDir, file), + ); + jest + .spyOn(fs, 'readdirSync') + .mockReturnValue(mockTestFiles as unknown as fs.Dirent[]); + const csvFiles = utils.getLocalCsvFilePaths(testDir); + expect(csvFiles).toEqual(mockCsvFilePaths); + }); + + it('should return an empty array if there are no CSV files in a directory', () => { + jest.spyOn(fs, 'readdirSync').mockReturnValue([]); + const csvFiles = utils.getLocalCsvFilePaths(testDir); + expect(csvFiles).toEqual([]); + }); +}); + +describe('getExistingSheetsFiles', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + const baseConfig: Config = { + sourceDir: '/path/to/source', + targetDriveFolderId: '12345', + targetIsSharedDrive: true, + updateExistingGoogleSheets: true, + saveOriginalFilesToDrive: false, + }; + + it('should return an array of existing Google Sheets files without nextPageToken', async () => { + const mockDrive = { + files: { + list: jest.fn().mockImplementation(() => { + return { + data: { + files: [ + { + id: '12345', + name: 'file1', + } as drive_v3.Schema$File, + { + id: '67890', + name: 'file2', + } as drive_v3.Schema$File, + ], + } as drive_v3.Schema$FileList, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await utils.getExistingSheetsFiles(mockDrive, mockConfig)).toEqual([ + { + id: '12345', + name: 'file1', + }, + { + id: '67890', + name: 'file2', + }, + ]); + }); + + it('should return an array of existing Google Sheets files with recursive calls using nextPageToken', async () => { + const mockDrive = { + files: { + list: jest + .fn() + .mockImplementationOnce(() => { + return { + data: { + files: [ + { + id: '12345', + name: 'file1', + } as drive_v3.Schema$File, + { + id: '67890', + name: 'file2', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + nextPageToken: 'nextPageToken123', + }, + }; + }) + .mockImplementationOnce(() => { + return { + data: { + files: [ + { + id: 'abcde', + name: 'file3', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + }, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await utils.getExistingSheetsFiles(mockDrive, mockConfig)).toEqual([ + { + id: '12345', + name: 'file1', + }, + { + id: '67890', + name: 'file2', + }, + { + id: 'abcde', + name: 'file3', + }, + ]); + }); + + it('should return the original fileList if config.updateExistingGoogleSheets is false', async () => { + const mockDrive = {} as unknown as drive_v3.Drive; + const mockConfig = { + ...baseConfig, + updateExistingGoogleSheets: false, + }; + const mockFileList = [ + { + id: '12345', + name: 'file1', + }, + { + name: 'file2', + }, + ] as unknown as drive_v3.Schema$File[]; + expect( + await utils.getExistingSheetsFiles(mockDrive, mockConfig, mockFileList), + ).toEqual(mockFileList); + }); +}); + +describe('getExistingSheetsFileId', () => { + const mockExistingSheetsFiles = [ + { + id: '12345', + name: 'file1', + }, + { + name: 'file2', + }, + ] as unknown as drive_v3.Schema$File[]; + const mockEmptyExistingSheetsFiles = [] as unknown as drive_v3.Schema$File[]; + + it('should return the file ID if the file exists', () => { + expect( + utils.getExistingSheetsFileId('file1', mockExistingSheetsFiles), + ).toBe('12345'); + }); + + it('should return null if the existing file does not have a valid ID', () => { + expect( + utils.getExistingSheetsFileId('file2', mockExistingSheetsFiles), + ).toBeNull(); + }); + + it('should return null if the file does not exist', () => { + expect( + utils.getExistingSheetsFileId('file99', mockExistingSheetsFiles), + ).toBeNull(); + }); + + it('should return null if the array existingSheetsFiles has the length of 0', () => { + expect( + utils.getExistingSheetsFileId('file1', mockEmptyExistingSheetsFiles), + ).toBeNull(); + }); +}); + +describe('getCsvFolderId', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + const baseConfig: Config = { + sourceDir: '/path/to/source', + targetDriveFolderId: 'TargetDriveFolderId12345', + targetIsSharedDrive: true, + updateExistingGoogleSheets: true, + saveOriginalFilesToDrive: true, + }; + + it('should return the ID of the csv folder if config.saveOriginalFilesToDrive is false and it exists', async () => { + const mockDrive = { + files: { + list: jest.fn().mockImplementation(() => { + return { + data: { + files: [ + { + id: 'CsvFolderId12345', + name: 'csv', + } as drive_v3.Schema$File, + { + id: 'OtherFolderId67890', + name: 'csv', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + }, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await utils.getCsvFolderId(mockDrive, mockConfig)).toBe( + 'CsvFolderId12345', + ); + }); + + it('should create a new folder in the target Google Drive folder and return its ID', async () => { + const mockDrive = { + files: { + list: jest + .fn() + .mockImplementationOnce(() => { + return { + data: { + files: [] as drive_v3.Schema$FileList, + }, + }; + }) + .mockImplementationOnce(() => { + return {}; + }) + .mockImplementationOnce(() => { + return { + data: { + files: [ + { + noid: 'no-id', + name: 'csv', + } as drive_v3.Schema$File, + ] as drive_v3.Schema$FileList, + }, + }; + }), + create: jest.fn().mockImplementation(() => { + return { + data: { + id: 'NewlyCreatedCsvFolderId12345', + }, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + expect(await utils.getCsvFolderId(mockDrive, mockConfig)).toBe( + 'NewlyCreatedCsvFolderId12345', + ); + expect(await utils.getCsvFolderId(mockDrive, mockConfig)).toBe( + 'NewlyCreatedCsvFolderId12345', + ); + expect(await utils.getCsvFolderId(mockDrive, mockConfig)).toBe( + 'NewlyCreatedCsvFolderId12345', + ); + }); + + it('should create a new folder at the root of My Drive and return its ID', async () => { + const mockDrive = { + files: { + list: jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + files: [] as drive_v3.Schema$FileList, + }, + }); + }), + create: jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + id: 'NewlyCreatedCsvFolderId12345', + }, + }); + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = { + ...baseConfig, + targetDriveFolderId: 'root', + }; + expect(await utils.getCsvFolderId(mockDrive, mockConfig)).toBe( + 'NewlyCreatedCsvFolderId12345', + ); + expect(mockDrive.files.create).toHaveBeenCalledWith({ + supportsAllDrives: mockConfig.targetIsSharedDrive, + requestBody: { + name: 'csv', + mimeType: 'application/vnd.google-apps.folder', + parents: [mockConfig.targetDriveFolderId], + }, + }); + }); + + it('should throw an error if the csv folder could not be created', async () => { + const mockDrive = { + files: { + list: jest.fn().mockImplementation(() => { + return { + data: { + files: [] as drive_v3.Schema$FileList, + }, + }; + }), + create: jest.fn().mockImplementation(() => { + return { + data: {}, + }; + }), + } as unknown as drive_v3.Resource$Files, + } as unknown as drive_v3.Drive; + const mockConfig = baseConfig; + await expect(utils.getCsvFolderId(mockDrive, mockConfig)).rejects.toThrow( + C2gError, + ); + }); + + it('should return null if config.saveOriginalFilesToDrive is false', async () => { + const mockDrive = {} as unknown as drive_v3.Drive; + const mockConfig = { + ...baseConfig, + saveOriginalFilesToDrive: false, + }; + expect(await utils.getCsvFolderId(mockDrive, mockConfig)).toBeNull(); + }); +}); From 8a54c0867e70e646f3984e0afdbcec08dc43bcb4 Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Tue, 19 Sep 2023 03:14:59 +0900 Subject: [PATCH 16/31] Update collectCoverageFrom --- jest.config.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/jest.config.ts b/jest.config.ts index add363e..8946ea1 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -11,10 +11,7 @@ export default { collectCoverageFrom: [ '/src/**/*.ts', '!/src/**/index.ts', - '!/src/**/auth.ts', - '!/src/**/constants.ts', '!/src/**/package.ts', - '!/src/**/utils.ts', '/postbuild/postbuild.mjs', ], // The directory where Jest should output its coverage files From ba698e86c3f9118c5490f7b360e9737b1b5d3124 Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Tue, 19 Sep 2023 03:15:18 +0900 Subject: [PATCH 17/31] Remove ora spinner --- src/index.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index ef67fba..a84494d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,12 @@ #!/usr/bin/env node /* eslint @typescript-eslint/no-floating-promises: ["error", { ignoreIIFE: true }] */ -import loudRejection from 'loud-rejection'; import { program } from 'commander'; +import loudRejection from 'loud-rejection'; // Local imports import { C2gError } from './c2g-error'; import { PACKAGE_JSON } from './package'; -import { spinner, stopSpinner } from './utils'; // Commands import convert from './commands/convert'; @@ -23,12 +22,12 @@ program.storeOptionsAsProperties(false); // Display package version program.version( - PACKAGE_JSON?.version || '0.0.0', + PACKAGE_JSON?.version ?? '0.0.0', '-v, --version', 'Output the current version', ); program - .name(`${PACKAGE_JSON?.name || 'csv2gsheets'}`) + .name(`${PACKAGE_JSON?.name ?? 'csv2gsheets'}`) .usage(' [options]') .description( `${PACKAGE_JSON?.name} - ${PACKAGE_JSON?.description}\nUse \`c2g\` for shorthand.`, @@ -82,10 +81,7 @@ program try { // User input is provided from the command line arguments await program.parseAsync(process.argv); - stopSpinner(); } catch (error) { - // Handle errors - stopSpinner(); if (error instanceof C2gError) { console.error(error.message); } else if (error instanceof Error) { @@ -96,5 +92,4 @@ program console.error('An unknown error occurred.', error); } } - spinner.clear(); })(); From a88962314658043fe897042976c8b12e29000d4a Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Tue, 19 Sep 2023 03:19:54 +0900 Subject: [PATCH 18/31] Remove package `ora` from devDependencies --- package-lock.json | 386 ++++++++++------------------------------------ package.json | 1 - 2 files changed, 84 insertions(+), 303 deletions(-) diff --git a/package-lock.json b/package-lock.json index 486fe42..10b3981 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "googleapis": "^126.0.1", "inquirer": "^9.2.10", "loud-rejection": "^2.2.0", - "ora": "^7.0.1", "read-pkg-up": "^10.1.0" }, "bin": { @@ -1568,16 +1567,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.7.0.tgz", - "integrity": "sha512-gUqtknHm0TDs1LhY12K2NA3Rmlmp88jK9Tx8vGZMfHeNMLE3GH2e9TRub+y+SOjuYgtOmok+wt1AyDPZqxbNag==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.7.2.tgz", + "integrity": "sha512-ooaHxlmSgZTM6CHYAFRlifqh1OAr3PAQEwi7lhYhaegbnXrnh7CDcHmc3+ihhbQC7H0i4JF0psI5ehzkF6Yl6Q==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.7.0", - "@typescript-eslint/type-utils": "6.7.0", - "@typescript-eslint/utils": "6.7.0", - "@typescript-eslint/visitor-keys": "6.7.0", + "@typescript-eslint/scope-manager": "6.7.2", + "@typescript-eslint/type-utils": "6.7.2", + "@typescript-eslint/utils": "6.7.2", + "@typescript-eslint/visitor-keys": "6.7.2", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -1603,15 +1602,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.7.0.tgz", - "integrity": "sha512-jZKYwqNpNm5kzPVP5z1JXAuxjtl2uG+5NpaMocFPTNC2EdYIgbXIPImObOkhbONxtFTTdoZstLZefbaK+wXZng==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.7.2.tgz", + "integrity": "sha512-KA3E4ox0ws+SPyxQf9iSI25R6b4Ne78ORhNHeVKrPQnoYsb9UhieoiRoJgrzgEeKGOXhcY1i8YtOeCHHTDa6Fw==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.7.0", - "@typescript-eslint/types": "6.7.0", - "@typescript-eslint/typescript-estree": "6.7.0", - "@typescript-eslint/visitor-keys": "6.7.0", + "@typescript-eslint/scope-manager": "6.7.2", + "@typescript-eslint/types": "6.7.2", + "@typescript-eslint/typescript-estree": "6.7.2", + "@typescript-eslint/visitor-keys": "6.7.2", "debug": "^4.3.4" }, "engines": { @@ -1631,13 +1630,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.7.0.tgz", - "integrity": "sha512-lAT1Uau20lQyjoLUQ5FUMSX/dS07qux9rYd5FGzKz/Kf8W8ccuvMyldb8hadHdK/qOI7aikvQWqulnEq2nCEYA==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.7.2.tgz", + "integrity": "sha512-bgi6plgyZjEqapr7u2mhxGR6E8WCzKNUFWNh6fkpVe9+yzRZeYtDTbsIBzKbcxI+r1qVWt6VIoMSNZ4r2A+6Yw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.7.0", - "@typescript-eslint/visitor-keys": "6.7.0" + "@typescript-eslint/types": "6.7.2", + "@typescript-eslint/visitor-keys": "6.7.2" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1648,13 +1647,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.7.0.tgz", - "integrity": "sha512-f/QabJgDAlpSz3qduCyQT0Fw7hHpmhOzY/Rv6zO3yO+HVIdPfIWhrQoAyG+uZVtWAIS85zAyzgAFfyEr+MgBpg==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.7.2.tgz", + "integrity": "sha512-36F4fOYIROYRl0qj95dYKx6kybddLtsbmPIYNK0OBeXv2j9L5nZ17j9jmfy+bIDHKQgn2EZX+cofsqi8NPATBQ==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.7.0", - "@typescript-eslint/utils": "6.7.0", + "@typescript-eslint/typescript-estree": "6.7.2", + "@typescript-eslint/utils": "6.7.2", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -1675,9 +1674,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.7.0.tgz", - "integrity": "sha512-ihPfvOp7pOcN/ysoj0RpBPOx3HQTJTrIN8UZK+WFd3/iDeFHHqeyYxa4hQk4rMhsz9H9mXpR61IzwlBVGXtl9Q==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.7.2.tgz", + "integrity": "sha512-flJYwMYgnUNDAN9/GAI3l8+wTmvTYdv64fcH8aoJK76Y+1FCZ08RtI5zDerM/FYT5DMkAc+19E4aLmd5KqdFyg==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1688,13 +1687,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.7.0.tgz", - "integrity": "sha512-dPvkXj3n6e9yd/0LfojNU8VMUGHWiLuBZvbM6V6QYD+2qxqInE7J+J/ieY2iGwR9ivf/R/haWGkIj04WVUeiSQ==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.7.2.tgz", + "integrity": "sha512-kiJKVMLkoSciGyFU0TOY0fRxnp9qq1AzVOHNeN1+B9erKFCJ4Z8WdjAkKQPP+b1pWStGFqezMLltxO+308dJTQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.7.0", - "@typescript-eslint/visitor-keys": "6.7.0", + "@typescript-eslint/types": "6.7.2", + "@typescript-eslint/visitor-keys": "6.7.2", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1715,17 +1714,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.7.0.tgz", - "integrity": "sha512-MfCq3cM0vh2slSikQYqK2Gq52gvOhe57vD2RM3V4gQRZYX4rDPnKLu5p6cm89+LJiGlwEXU8hkYxhqqEC/V3qA==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.7.2.tgz", + "integrity": "sha512-ZCcBJug/TS6fXRTsoTkgnsvyWSiXwMNiPzBUani7hDidBdj1779qwM1FIAmpH4lvlOZNF3EScsxxuGifjpLSWQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.7.0", - "@typescript-eslint/types": "6.7.0", - "@typescript-eslint/typescript-estree": "6.7.0", + "@typescript-eslint/scope-manager": "6.7.2", + "@typescript-eslint/types": "6.7.2", + "@typescript-eslint/typescript-estree": "6.7.2", "semver": "^7.5.4" }, "engines": { @@ -1740,12 +1739,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.0.tgz", - "integrity": "sha512-/C1RVgKFDmGMcVGeD8HjKv2bd72oI1KxQDeY8uc66gw9R0OK0eMq48cA+jv9/2Ag6cdrsUGySm1yzYmfz0hxwQ==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.2.tgz", + "integrity": "sha512-uVw9VIMFBUTz8rIeaUT3fFe8xIUx8r4ywAdlQv1ifH+6acn/XF8Y6rwJ7XNmkNMDrTW+7+vxFFPIF40nJCVsMQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.7.0", + "@typescript-eslint/types": "6.7.2", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -2069,11 +2068,11 @@ } }, "node_modules/bl": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", - "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dependencies": { - "buffer": "^6.0.3", + "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } @@ -2166,9 +2165,9 @@ } }, "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "funding": [ { "type": "github", @@ -2185,7 +2184,7 @@ ], "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "ieee754": "^1.1.13" } }, "node_modules/buffer-equal-constant-time": { @@ -2268,7 +2267,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2751,11 +2749,6 @@ "node": ">=6.0.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3830,39 +3823,6 @@ "node": ">=14.18.0" } }, - "node_modules/inquirer/node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/inquirer/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/inquirer/node_modules/chalk": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", @@ -3874,92 +3834,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/inquirer/node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inquirer/node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inquirer/node_modules/log-symbols/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/inquirer/node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inquirer/node_modules/ora/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4062,14 +3936,11 @@ } }, "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/is-number": { @@ -4921,29 +4792,29 @@ "dev": true }, "node_modules/log-symbols": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-5.1.0.tgz", - "integrity": "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dependencies": { - "chalk": "^5.0.0", - "is-unicode-supported": "^1.1.0" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/loud-rejection": { @@ -5196,113 +5067,38 @@ } }, "node_modules/ora": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-7.0.1.tgz", - "integrity": "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^4.0.0", - "cli-spinners": "^2.9.0", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^1.3.0", - "log-symbols": "^5.1.0", - "stdin-discarder": "^0.1.0", - "string-width": "^6.1.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ora/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/ora/node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "dependencies": { - "restore-cursor": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/emoji-regex": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.2.1.tgz", - "integrity": "sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==" - }, - "node_modules/ora/node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/string-width": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-6.1.0.tgz", - "integrity": "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^10.2.1", - "strip-ansi": "^7.0.1" - }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "engines": { - "node": ">=16" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -6169,20 +5965,6 @@ "node": ">=8" } }, - "node_modules/stdin-discarder": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", - "integrity": "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==", - "dependencies": { - "bl": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/package.json b/package.json index a2f45af..c7c2683 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "googleapis": "^126.0.1", "inquirer": "^9.2.10", "loud-rejection": "^2.2.0", - "ora": "^7.0.1", "read-pkg-up": "^10.1.0" } } From 3bb3c815ed0ade05416521438b5d00881b57b963 Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Tue, 19 Sep 2023 03:20:10 +0900 Subject: [PATCH 19/31] Remove unnecessary export statement --- src/package.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/package.ts b/src/package.ts index f68e22e..1ca3383 100644 --- a/src/package.ts +++ b/src/package.ts @@ -5,6 +5,6 @@ import { fileURLToPath } from 'url'; import { readPackageUpSync } from 'read-pkg-up'; // Package Info -export const __dirname: string = dirname(fileURLToPath(import.meta.url)); +const __dirname: string = dirname(fileURLToPath(import.meta.url)); export const PACKAGE_JSON = readPackageUpSync({ cwd: __dirname })?.packageJson || undefined; From 25b5d2fc5bef9168028a71f2357ef3ebfeba296a Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Tue, 19 Sep 2023 03:34:53 +0900 Subject: [PATCH 20/31] Update regexp in postbuild to include patterns like `import * as utils from ../utils.ts` --- postbuild/postbuild.mjs | 4 ++-- test/postbuild.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/postbuild/postbuild.mjs b/postbuild/postbuild.mjs index 070e3fb..9427da3 100644 --- a/postbuild/postbuild.mjs +++ b/postbuild/postbuild.mjs @@ -7,7 +7,7 @@ import path from 'path'; // The target strings to be modified. // prettier-ignore -export const TARGET_REGEXP_STR = "^(import (\\S*, )?({[^}]*}|\\S*) from '\\.{1,2}\\/[^']*{{fileName}})(';)$"; +export const TARGET_REGEXP_STR = "^(import (\\S*, )?({[^}]*}|\\S*( as \\S*)?) from '\\.{1,2}\\/[^']*{{fileName}})(';)$"; /** * Find .js files in the given directory and its subfolders, and return an array of file objects, @@ -71,7 +71,7 @@ export function createRegexpFromFileNames(fileObjArr) { */ export function replaceFileContent(fileContent, regexp, extension) { if (fileContent.match(regexp)) { - fileContent = fileContent.replace(regexp, `$1${extension}$4`); // insert '.js' before the last single quote + fileContent = fileContent.replace(regexp, `$1${extension}$5`); // insert '.js' before the last single quote } return fileContent; } diff --git a/test/postbuild.test.ts b/test/postbuild.test.ts index 484bc0a..2d05386 100644 --- a/test/postbuild.test.ts +++ b/test/postbuild.test.ts @@ -86,7 +86,7 @@ describe('createRegexpFromFileNames', () => { describe('replaceFileContent', () => { it('should replace the content of a file', () => { const fileContent = - "import { test1 } from './test1';\nimport test2, { test2sub } from './test2';\nimport fs from 'fs';\nimport test2 from 'test2';\nconst test2 = () => {\n console.log('test2');\n};\n\nexport { test2 };\n"; + "import { test1 } from './test1';\nimport test2, { test2sub } from './test2';\nimport fs from 'fs';\nimport test2 from 'test2';\nimport * as utils from './test2';\nconst test2 = () => {\n console.log('test2');\n};\n\nexport { test2 };\n"; const regexp = new RegExp( postbuild.TARGET_REGEXP_STR.replace('{{fileName}}', 'test2'), 'gm', @@ -94,7 +94,7 @@ describe('replaceFileContent', () => { const extension = '.js'; expect(fileContent.match(regexp)).not.toBeNull(); expect(postbuild.replaceFileContent(fileContent, regexp, extension)).toBe( - "import { test1 } from './test1';\nimport test2, { test2sub } from './test2.js';\nimport fs from 'fs';\nimport test2 from 'test2';\nconst test2 = () => {\n console.log('test2');\n};\n\nexport { test2 };\n", + "import { test1 } from './test1';\nimport test2, { test2sub } from './test2.js';\nimport fs from 'fs';\nimport test2 from 'test2';\nimport * as utils from './test2.js';\nconst test2 = () => {\n console.log('test2');\n};\n\nexport { test2 };\n", ); }); }); From b15a96be51159ac874fb2c21e0264a8afc233e99 Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Tue, 19 Sep 2023 03:40:39 +0900 Subject: [PATCH 21/31] Move createConfigFile to utils.ts --- src/commands/init.ts | 71 ++++---------------------------------------- src/utils.ts | 65 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 67 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index c571487..d708e3a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,14 +1,15 @@ // init command // Import the necessary modules -import inquirer, { QuestionCollection } from 'inquirer'; +import inquirer from 'inquirer'; import fs from 'fs'; import path from 'path'; import { isAuthorized } from '../auth'; -import { Config, CONFIG_FILE_NAME, DEFAULT_CONFIG } from '../constants'; +import { CONFIG_FILE_NAME } from '../constants'; import login from './login'; import { MESSAGES } from '../messages'; +import * as utils from '../utils'; interface InquirerInitOverwriteResponse { overwrite: boolean; @@ -18,68 +19,6 @@ interface InitCommandOptions { readonly login?: boolean; } -/** - * Creates a config file in the current directory based on user input - */ -async function createConfigFile(): Promise { - // Define the questions to be asked - const questions: QuestionCollection = [ - { - name: 'sourceDir', - type: 'input', - message: MESSAGES.prompt.enterSourceDir, - default: DEFAULT_CONFIG.sourceDir, - validate: (value: string) => { - if (fs.existsSync(value)) { - return true; - } else { - return MESSAGES.prompt.enterValidPath; - } - }, - }, - { - name: 'targetDriveFolderId', - type: 'input', - message: MESSAGES.prompt.enterTargetDriveFolderId, - default: DEFAULT_CONFIG.targetDriveFolderId, - validate: (value: string) => { - if (value.length) { - return true; - } else { - return MESSAGES.prompt.enterValidId; - } - }, - }, - { - name: 'targetIsSharedDrive', - type: 'confirm', - message: MESSAGES.prompt.targetIsSharedDriveYN, - default: DEFAULT_CONFIG.targetIsSharedDrive, - }, - { - name: 'updateExistingGoogleSheets', - type: 'confirm', - message: MESSAGES.prompt.updateExistingGoogleSheetsYN, - default: DEFAULT_CONFIG.updateExistingGoogleSheets, - }, - { - name: 'saveOriginalFilesToDrive', - type: 'confirm', - message: MESSAGES.prompt.saveOriginalFilesToDriveYN, - default: DEFAULT_CONFIG.saveOriginalFilesToDrive, - }, - ]; - - // Prompt the user for input - const answers = (await inquirer.prompt(questions)) as Config; - - // Write the answers to a config file - fs.writeFileSync( - path.join(process.cwd(), CONFIG_FILE_NAME), - JSON.stringify(answers, null, 2), - ); -} - /** * Create a config file `c2g.config.json` in the current directory. * If a config file already exists, prompt the user to overwrite it. @@ -100,13 +39,13 @@ export default async function init( }, ])) as InquirerInitOverwriteResponse; if (overwrite.overwrite) { - await createConfigFile(); + await utils.createConfigFile(); } else { // exit without doing anything console.info(MESSAGES.log.noChangesWereMade); } } else { - await createConfigFile(); + await utils.createConfigFile(); } // If the option "login" is true, authorize the user if (options?.login) { diff --git a/src/utils.ts b/src/utils.ts index 47dc2e3..f10029b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,12 +2,75 @@ import fs from 'fs'; import { drive_v3 } from 'googleapis'; +import inquirer, { QuestionCollection } from 'inquirer'; import path from 'path'; -import { Config, DEFAULT_CONFIG } from './constants'; +import { Config, CONFIG_FILE_NAME, DEFAULT_CONFIG } from './constants'; import { C2gError } from './c2g-error'; import { MESSAGES } from './messages'; +/** + * Creates a config file in the current directory based on user input + */ +export async function createConfigFile(): Promise { + // Define the questions to be asked + const questions: QuestionCollection = [ + { + name: 'sourceDir', + type: 'input', + message: MESSAGES.prompt.enterSourceDir, + default: DEFAULT_CONFIG.sourceDir, + validate: (value: string) => { + if (fs.existsSync(value)) { + return true; + } else { + return MESSAGES.prompt.enterValidPath; + } + }, + }, + { + name: 'targetDriveFolderId', + type: 'input', + message: MESSAGES.prompt.enterTargetDriveFolderId, + default: DEFAULT_CONFIG.targetDriveFolderId, + validate: (value: string) => { + if (value.length) { + return true; + } else { + return MESSAGES.prompt.enterValidId; + } + }, + }, + { + name: 'targetIsSharedDrive', + type: 'confirm', + message: MESSAGES.prompt.targetIsSharedDriveYN, + default: DEFAULT_CONFIG.targetIsSharedDrive, + }, + { + name: 'updateExistingGoogleSheets', + type: 'confirm', + message: MESSAGES.prompt.updateExistingGoogleSheetsYN, + default: DEFAULT_CONFIG.updateExistingGoogleSheets, + }, + { + name: 'saveOriginalFilesToDrive', + type: 'confirm', + message: MESSAGES.prompt.saveOriginalFilesToDriveYN, + default: DEFAULT_CONFIG.saveOriginalFilesToDrive, + }, + ]; + + // Prompt the user for input + const answers = (await inquirer.prompt(questions)) as Config; + + // Write the answers to a config file + fs.writeFileSync( + path.join(process.cwd(), CONFIG_FILE_NAME), + JSON.stringify(answers, null, 2), + ); +} + /** * Read the configuration file and return its contents as an object. * @param configFilePath The path to the configuration file From 81c2c8e822927d94445bd6645413eb90db492f8a Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Tue, 19 Sep 2023 13:46:17 +0900 Subject: [PATCH 22/31] Define validation functions for createConfigFile --- src/utils.ts | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index f10029b..0c3b7d6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -9,6 +9,32 @@ import { Config, CONFIG_FILE_NAME, DEFAULT_CONFIG } from './constants'; import { C2gError } from './c2g-error'; import { MESSAGES } from './messages'; +/** + * Check if the given source directory is valid. + * @param sourceDir The source directory to validate + * @returns `true` if the source directory is valid, or a string containing the error message if it isn't + */ +export function validateSourceDir(sourceDir: string): boolean | string { + if (fs.existsSync(sourceDir)) { + return true; + } else { + return MESSAGES.prompt.enterValidPath; + } +} + +/** + * Check if the given target Google Drive folder ID is a string of length > 0. + * @param id The target Google Drive folder ID to validate + * @returns `true` if the target Google Drive folder ID is valid, or a string containing the error message if it isn't + */ +export function validateTargetDriveFolderId(id: string): boolean | string { + if (id.length) { + return true; + } else { + return MESSAGES.prompt.enterValidId; + } +} + /** * Creates a config file in the current directory based on user input */ @@ -20,26 +46,14 @@ export async function createConfigFile(): Promise { type: 'input', message: MESSAGES.prompt.enterSourceDir, default: DEFAULT_CONFIG.sourceDir, - validate: (value: string) => { - if (fs.existsSync(value)) { - return true; - } else { - return MESSAGES.prompt.enterValidPath; - } - }, + validate: validateSourceDir, }, { name: 'targetDriveFolderId', type: 'input', message: MESSAGES.prompt.enterTargetDriveFolderId, default: DEFAULT_CONFIG.targetDriveFolderId, - validate: (value: string) => { - if (value.length) { - return true; - } else { - return MESSAGES.prompt.enterValidId; - } - }, + validate: validateTargetDriveFolderId, }, { name: 'targetIsSharedDrive', From 7b95689676566e8bdb378693df83eed54336cd7f Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Tue, 19 Sep 2023 13:46:51 +0900 Subject: [PATCH 23/31] Add tests for createConfigFile() --- test/utils.test.ts | 94 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 14 deletions(-) diff --git a/test/utils.test.ts b/test/utils.test.ts index 1302ff1..cfa4588 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -4,12 +4,76 @@ import fs from 'fs'; import path from 'path'; import { drive_v3 } from 'googleapis'; -import { Config, DEFAULT_CONFIG, HOME_DIR } from '../src/constants'; -import * as utils from '../src/utils'; import { C2gError } from '../src/c2g-error'; +import * as constants from '../src/constants'; +import inquirer from 'inquirer'; +import { MESSAGES } from '../src/messages'; +import * as utils from '../src/utils'; jest.mock('fs'); jest.mock('googleapis'); +jest.mock('inquirer'); + +describe('createConfigFile', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should create a config file with the default values', async () => { + // Arrange + const mockConfig = { + sourceDir: constants.DEFAULT_CONFIG.sourceDir, + targetDriveFolderId: constants.DEFAULT_CONFIG.targetDriveFolderId, + targetIsSharedDrive: constants.DEFAULT_CONFIG.targetDriveFolderId, + updateExistingGoogleSheets: + constants.DEFAULT_CONFIG.updateExistingGoogleSheets, + saveOriginalFilesToDrive: + constants.DEFAULT_CONFIG.saveOriginalFilesToDrive, + }; + jest.spyOn(inquirer, 'prompt').mockResolvedValue(mockConfig); + // Act + await utils.createConfigFile(); + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(process.cwd(), constants.CONFIG_FILE_NAME), + JSON.stringify(mockConfig, null, 2), + ); + }); + + describe('validation functions for inquirer: validateSourceDir, validateTargetDriveFolderId', () => { + it('should return true if sourceDir is a valid path', () => { + // Arrange + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + // Act & Assert + expect(utils.validateSourceDir('/some/path/')).toBe(true); + }); + + it('should return a predefined message if sourceDir is not a valid path', () => { + // Arrange + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + // Act & Assert + expect(utils.validateSourceDir('/some/path/')).toBe( + MESSAGES.prompt.enterValidPath, + ); + }); + + it('should return true if targetDriveFolderId is a string of length > 0', () => { + // Arrange + const targetDriveFolderId = '12345'; + // Act & Assert + expect(utils.validateTargetDriveFolderId(targetDriveFolderId)).toBe(true); + }); + + it('should return a predefined message if targetDriveFolderId is a string of 0 length', () => { + // Arrange + const targetDriveFolderId = ''; + // Act & Assert + expect(utils.validateTargetDriveFolderId(targetDriveFolderId)).toBe( + MESSAGES.prompt.enterValidId, + ); + }); + }); +}); describe('readConfigFileSync', () => { const configFilePath = '/path/to/config.json'; @@ -46,57 +110,59 @@ describe('readConfigFileSync', () => { describe('validateConfig', () => { it('should return the config object if it is valid', () => { const config = { - sourceDir: HOME_DIR, + sourceDir: constants.HOME_DIR, targetDriveFolderId: '12345', targetIsSharedDrive: true, updateExistingGoogleSheets: true, saveOriginalFilesToDrive: false, - } as Partial; + } as Partial; expect(utils.validateConfig(config)).toEqual(config); }); it('should throw an error if sourceDir is not a string', () => { - const config = { sourceDir: 123 } as unknown as Partial; + const config = { sourceDir: 123 } as unknown as Partial; expect(() => utils.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if sourceDir is not a valid path', () => { const config = { sourceDir: '/path/to/nonexistent/directory', - } as Partial; + } as Partial; jest.spyOn(fs, 'existsSync').mockReturnValue(false); expect(() => utils.validateConfig(config)).toThrow(C2gError); }); it('should throw an error if targetDriveFolderId is not a string', () => { - const config = { targetDriveFolderId: 123 } as unknown as Partial; + const config = { + targetDriveFolderId: 123, + } as unknown as Partial; expect(() => utils.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if targetIsSharedDrive is not a boolean', () => { const config = { targetIsSharedDrive: 'true', - } as unknown as Partial; + } as unknown as Partial; expect(() => utils.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if updateExistingGoogleSheets is not a boolean', () => { const config = { updateExistingGoogleSheets: 'true', - } as unknown as Partial; + } as unknown as Partial; expect(() => utils.validateConfig(config)).toThrow(TypeError); }); it('should throw an error if saveOriginalFilesToDrive is not a boolean', () => { const config = { saveOriginalFilesToDrive: 'false', - } as unknown as Partial; + } as unknown as Partial; expect(() => utils.validateConfig(config)).toThrow(TypeError); }); it('should add default values for missing config properties', () => { - const config = {} as Partial; - expect(utils.validateConfig(config)).toEqual(DEFAULT_CONFIG); + const config = {} as Partial; + expect(utils.validateConfig(config)).toEqual(constants.DEFAULT_CONFIG); }); }); @@ -145,7 +211,7 @@ describe('getExistingSheetsFiles', () => { jest.restoreAllMocks(); }); - const baseConfig: Config = { + const baseConfig: constants.Config = { sourceDir: '/path/to/source', targetDriveFolderId: '12345', targetIsSharedDrive: true, @@ -303,7 +369,7 @@ describe('getCsvFolderId', () => { jest.restoreAllMocks(); }); - const baseConfig: Config = { + const baseConfig: constants.Config = { sourceDir: '/path/to/source', targetDriveFolderId: 'TargetDriveFolderId12345', targetIsSharedDrive: true, From 4040731213fba96b5089e4eb301e275a1b0e4f17 Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Tue, 19 Sep 2023 13:47:04 +0900 Subject: [PATCH 24/31] Create init.test.ts --- test/init.test.ts | 86 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 test/init.test.ts diff --git a/test/init.test.ts b/test/init.test.ts new file mode 100644 index 0000000..8a83b17 --- /dev/null +++ b/test/init.test.ts @@ -0,0 +1,86 @@ +// Jest test for the init command in ./src/commands/init.ts + +import fs from 'fs'; +import inquirer from 'inquirer'; + +import * as auth from '../src/auth'; +import login from '../src/commands/login'; +import init from '../src/commands/init'; +import { MESSAGES } from '../src/messages'; +import * as utils from '../src/utils'; + +jest.mock('fs'); +jest.mock('inquirer'); +jest.mock('../src/commands/login'); + +describe('init', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('should create a config file if it does not exist', async () => { + // Arrange + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + jest.spyOn(utils, 'createConfigFile').mockResolvedValue(); + jest.spyOn(inquirer, 'prompt').mockResolvedValue({}); + // Act + await init(); + // Assert + expect(fs.existsSync).toHaveBeenCalledTimes(1); + expect(inquirer.prompt).not.toHaveBeenCalled(); + expect(utils.createConfigFile).toHaveBeenCalledTimes(1); + }); + + it('should proceed with the login process if options.login is true and isAuthorized returns false', async () => { + // Arrange + const mockLogin = login as jest.MockedFunction; + mockLogin.mockImplementation(() => { + return Promise.resolve(); + }); + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + jest.spyOn(utils, 'createConfigFile').mockResolvedValue(); + jest.spyOn(auth, 'isAuthorized').mockReturnValue(false); + jest.spyOn(console, 'info').mockImplementation(); + // Act + await init({ login: true }); + // Assert + expect(mockLogin).toHaveBeenCalledTimes(2); + expect(console.info).toHaveBeenCalledWith(MESSAGES.log.loggingIn); + }); + + describe('when the config file exists, ask the user whether to overwrite the file', () => { + it('should overwrite the config file if the user response is `Y` for "yes"', async () => { + // Arrange + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(inquirer, 'prompt').mockResolvedValue({ + overwrite: true, + }); + jest.spyOn(utils, 'createConfigFile').mockResolvedValue(); + jest.spyOn(console, 'info').mockImplementation(); + // Act + await init(); + // Assert + expect(fs.existsSync).toHaveBeenCalledTimes(1); + expect(inquirer.prompt).toHaveBeenCalledTimes(1); + expect(utils.createConfigFile).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + }); + + it('should not overwrite the config file if the user response is `N` for "no"', async () => { + // Arrange + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(inquirer, 'prompt').mockResolvedValue({ + overwrite: false, + }); + jest.spyOn(utils, 'createConfigFile').mockResolvedValue(); + jest.spyOn(console, 'info').mockImplementation(); + // Act + await init(); + // Assert + expect(fs.existsSync).toHaveBeenCalledTimes(1); + expect(inquirer.prompt).toHaveBeenCalledTimes(1); + expect(utils.createConfigFile).not.toHaveBeenCalled(); + expect(console.info).toHaveBeenCalledTimes(1); + expect(console.info).toHaveBeenCalledWith(MESSAGES.log.noChangesWereMade); + }); + }); +}); From 741497313a0953bba4a4d1bea798ae92f641c55a Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Tue, 19 Sep 2023 13:47:19 +0900 Subject: [PATCH 25/31] Add transformIgnorePatterns --- jest.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jest.config.ts b/jest.config.ts index 8946ea1..9590589 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -33,5 +33,8 @@ export default { }, ], }, + transformIgnorePatterns: [ + '/node_modules/(?!chalk|escape-string-regexp|figures|inquirer|is-unicode-supported/)', + ], verbose: true, }; From 5d9e0f1f8d524aa57d46076e56d7c72624555695 Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Tue, 19 Sep 2023 17:03:02 +0900 Subject: [PATCH 26/31] Change `loadSavedToken` async -> sync --- src/auth.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 2a8b3cc..cc5c935 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -31,7 +31,7 @@ interface CredentialsKey { client_secret: string; redirect_uris: string[]; } -interface Credentials { +export interface Credentials { installed?: CredentialsKey; web?: CredentialsKey; } @@ -58,15 +58,11 @@ export function isAuthorized(): boolean { * Read previously authorized tokens from the save file. * @returns The OAuth2Client object or null if no saved token exists. */ -export async function loadSavedToken(): Promise { - try { - if (isAuthorized()) { - const token = await fs.promises.readFile(TOKEN_PATH, 'utf8'); - return google.auth.fromJSON(JSON.parse(token) as Token) as OAuth2Client; - } else { - return null; - } - } catch (err) { +export function loadSavedToken(): OAuth2Client | null { + if (isAuthorized()) { + const token = fs.readFileSync(TOKEN_PATH, 'utf8'); + return google.auth.fromJSON(JSON.parse(token) as Token) as OAuth2Client; + } else { return null; } } @@ -75,7 +71,7 @@ export async function loadSavedToken(): Promise { * Serialize credentials to a file compatible with GoogleAuth.fromJSON. * @param client The OAuth2Client object to serialize. */ -async function saveToken(client: OAuth2Client): Promise { +export async function saveToken(client: OAuth2Client): Promise { const credentialsStr = await fs.promises.readFile(CREDENTIALS_PATH, 'utf8'); const parsedCredentials = JSON.parse(credentialsStr) as Credentials; if ('installed' in parsedCredentials || 'web' in parsedCredentials) { @@ -101,7 +97,7 @@ async function saveToken(client: OAuth2Client): Promise { * @returns The OAuth2Client object. */ export async function authorize(): Promise { - let client = await loadSavedToken(); + let client = loadSavedToken(); if (!client) { client = await authenticate({ keyfilePath: CREDENTIALS_PATH, From 5cdd5b2c9767eafb6a29d09db8f8a552667a5f5f Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Tue, 19 Sep 2023 17:34:47 +0900 Subject: [PATCH 27/31] Change `saveToken` async -> sync --- src/auth.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index cc5c935..788e5cd 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -22,7 +22,7 @@ const CREDENTIALS_PATH = path.join(HOME_DIR, CREDENTIALS_FILE_NAME); export const TOKEN_PATH = path.join(HOME_DIR, TOKEN_FILE_NAME); // Interface -interface CredentialsKey { +export interface CredentialsKey { client_id: string; project_id: string; auth_uri: string; @@ -71,8 +71,8 @@ export function loadSavedToken(): OAuth2Client | null { * Serialize credentials to a file compatible with GoogleAuth.fromJSON. * @param client The OAuth2Client object to serialize. */ -export async function saveToken(client: OAuth2Client): Promise { - const credentialsStr = await fs.promises.readFile(CREDENTIALS_PATH, 'utf8'); +export function saveToken(client: OAuth2Client): void { + const credentialsStr = fs.readFileSync(CREDENTIALS_PATH, 'utf8'); const parsedCredentials = JSON.parse(credentialsStr) as Credentials; if ('installed' in parsedCredentials || 'web' in parsedCredentials) { const key = parsedCredentials.installed ?? parsedCredentials.web; @@ -86,7 +86,7 @@ export async function saveToken(client: OAuth2Client): Promise { access_token: client.credentials.access_token, refresh_token: client.credentials.refresh_token, }); - await fs.promises.writeFile(TOKEN_PATH, payload); + fs.writeFileSync(TOKEN_PATH, payload); } else { throw new C2gError(MESSAGES.error.c2gErrorInvalidCredentials); } @@ -104,7 +104,7 @@ export async function authorize(): Promise { scopes: SCOPES, }); if (client?.credentials) { - await saveToken(client); + saveToken(client); } return client; } else { From e5bebc63f31e3eebb6a3baadc7c84dd5d5d2e327 Mon Sep 17 00:00:00 2001 From: ttsukagoshi <55706659+ttsukagoshi@users.noreply.github.com> Date: Tue, 19 Sep 2023 17:35:10 +0900 Subject: [PATCH 28/31] Create auth.test.ts --- test/auth.test.ts | 178 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 test/auth.test.ts diff --git a/test/auth.test.ts b/test/auth.test.ts new file mode 100644 index 0000000..951b443 --- /dev/null +++ b/test/auth.test.ts @@ -0,0 +1,178 @@ +// Jest tests for ./src/auth.ts + +import fs from 'fs'; +import { google } from 'googleapis'; +import { OAuth2Client } from 'google-auth-library'; + +import * as auth from '../src/auth'; +import { C2gError } from '../src/c2g-error'; +import { MESSAGES } from '../src/messages'; + +jest.mock('fs'); +jest.mock('googleapis'); +jest.mock('google-auth-library'); + +describe('isAuthorized', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return true if credential file exists', () => { + // Arrange + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + // Act + const result = auth.isAuthorized(); + // Assert + expect(result).toBe(true); + expect(fs.existsSync).toHaveBeenCalledTimes(2); + }); + + it('should throw an error if credential file does not exist', () => { + // Arrange + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + // Act + const result = () => auth.isAuthorized(); + // Assert + expect(result).toThrow( + new C2gError(MESSAGES.error.c2gErrorCredentialsFileNotFound), + ); + expect(fs.existsSync).toHaveBeenCalledTimes(1); + }); +}); + +describe('loadSavedToken', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return null if token file does not exist', () => { + // Arrange + jest + .spyOn(fs, 'existsSync') + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + // Act & Assert + expect(auth.loadSavedToken()).toBeNull(); + }); + + it('should return OAuth2Client if token file exists', () => { + // Arrange + const mockToken = { + token: 'mock-token-string', + }; + jest.spyOn(google.auth, 'fromJSON').mockImplementation(); + jest + .spyOn(fs, 'existsSync') + .mockReturnValueOnce(true) + .mockReturnValueOnce(true); + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockToken)); + // Act + auth.loadSavedToken(); + // Assert + expect(google.auth.fromJSON).toHaveBeenCalledTimes(1); + }); +}); + +describe('saveToken', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + const mockClient = { + credentials: { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + }, + } as unknown as OAuth2Client; + + it('should throw an error if credentials file does not have top level key of `installed` or `web`', () => { + // Arrange + const mockCredentials = { + unknownKey: {}, + } as unknown as auth.Credentials; + jest + .spyOn(fs, 'readFileSync') + .mockReturnValue(JSON.stringify(mockCredentials)); + // Act & Assert + expect(() => auth.saveToken(mockClient)).toThrow( + new C2gError(MESSAGES.error.c2gErrorInvalidCredentials), + ); + }); + + it('should throw an error if credentials file does not have CredentialsKey values', () => { + // Arrange + const mockCredentials = { + installed: undefined, + web: '', + } as unknown as auth.Credentials; + jest + .spyOn(fs, 'readFileSync') + .mockReturnValue(JSON.stringify(mockCredentials)); + // Act & Assert + expect(() => auth.saveToken(mockClient)).toThrow( + new C2gError(MESSAGES.error.c2gErrorInvalidCredentials), + ); + }); + + it('should save the token file without error', () => { + // Arrange + const mockCredentials: auth.Credentials = { + installed: { + client_id: 'mock-client-id', + client_secret: 'mock-client-secret', + } as unknown as auth.CredentialsKey, + }; + const mockPayload = JSON.stringify({ + type: 'authorized_user', + client_id: mockCredentials.installed?.client_id, + client_secret: mockCredentials.installed?.client_secret, + access_token: mockClient.credentials.access_token, + refresh_token: mockClient.credentials.refresh_token, + }); + jest + .spyOn(fs, 'readFileSync') + .mockReturnValue(JSON.stringify(mockCredentials)); + jest.spyOn(fs, 'writeFileSync').mockImplementation(); + // Act + auth.saveToken(mockClient); + // Assert + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + expect(fs.writeFileSync).toHaveBeenCalledWith(auth.TOKEN_PATH, mockPayload); + }); +}); + +describe('authorize', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + const mockClient = { + credentials: { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + }, + } as unknown as OAuth2Client; + const mockCredentials: auth.Credentials = { + installed: { + client_id: 'mock-client-id', + client_secret: 'mock-client-secret', + } as unknown as auth.CredentialsKey, + }; + const mockToken = { + type: 'authorized_user', + client_id: mockCredentials.installed?.client_id, + client_secret: mockCredentials.installed?.client_secret, + access_token: mockClient.credentials.access_token, + refresh_token: mockClient.credentials.refresh_token, + }; + + it('should return OAuth2Client if token file exists', async () => { + // Arrange + jest.spyOn(fs, 'existsSync').mockReturnValue(true); // isAuthorized + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockToken)); // loadSavedToken + jest.spyOn(google.auth, 'fromJSON').mockImplementation(); // loadSavedToken + // Act + const result = await auth.authorize(); + // Assert + expect(result).toEqual(mockToken); + }); +}); From d40ab4e0b019b429248b1c2fc1cd98a7d0a06593 Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Wed, 20 Sep 2023 01:05:58 +0900 Subject: [PATCH 29/31] Add tests for `authorize` and `getUserEmail` --- test/auth.test.ts | 141 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 137 insertions(+), 4 deletions(-) diff --git a/test/auth.test.ts b/test/auth.test.ts index 951b443..ed28bc1 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -1,14 +1,16 @@ // Jest tests for ./src/auth.ts import fs from 'fs'; -import { google } from 'googleapis'; -import { OAuth2Client } from 'google-auth-library'; +import { authenticate } from '@google-cloud/local-auth'; +import { google, oauth2_v2 } from 'googleapis'; +import { OAuth2Client, JWT as JSONClient } from 'google-auth-library'; import * as auth from '../src/auth'; import { C2gError } from '../src/c2g-error'; import { MESSAGES } from '../src/messages'; jest.mock('fs'); +jest.mock('@google-cloud/local-auth'); jest.mock('googleapis'); jest.mock('google-auth-library'); @@ -169,10 +171,141 @@ describe('authorize', () => { // Arrange jest.spyOn(fs, 'existsSync').mockReturnValue(true); // isAuthorized jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockToken)); // loadSavedToken - jest.spyOn(google.auth, 'fromJSON').mockImplementation(); // loadSavedToken + jest + .spyOn(google.auth, 'fromJSON') + .mockImplementation(() => mockClient as unknown as JSONClient); // loadSavedToken + // Act + const result = await auth.authorize(); + // Assert + expect(result).toEqual(mockClient); + }); + + it('should create and return a new OAuth2Client after running `authenticate` and `saveToken`', async () => { + // Arrange + jest + .spyOn(fs, 'existsSync') + .mockReturnValueOnce(true) // isAuthorized > fs.existsSync(CREDENTIALS_PATH) + .mockReturnValueOnce(false); // isAuthorized > fs.existsSync(TOKEN_PATH) + const mockAuthenticate = authenticate as jest.MockedFunction< + typeof authenticate + >; + mockAuthenticate.mockResolvedValue(mockClient); + jest + .spyOn(fs, 'readFileSync') + .mockReturnValue(JSON.stringify(mockCredentials)); // saveToken + jest.spyOn(fs, 'writeFileSync').mockImplementation(); // saveToken // Act const result = await auth.authorize(); // Assert - expect(result).toEqual(mockToken); + expect(result).toEqual(mockClient); + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + expect(fs.writeFileSync).toHaveBeenCalledWith( + auth.TOKEN_PATH, + JSON.stringify(mockToken), + ); + }); +}); + +describe('getUserEmail', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return null if the user is not authorized', async () => { + // Arrange + jest + .spyOn(fs, 'existsSync') + .mockReturnValueOnce(true) // isAuthorized > fs.existsSync(CREDENTIALS_PATH) + .mockReturnValueOnce(false); // isAuthorized > fs.existsSync(TOKEN_PATH) + // Act + const result = await auth.getUserEmail(); + // Assert + expect(result).toBeNull(); + }); + + it('should return the user email if the user is authorized', async () => { + // Arrange + const mockClient = { + credentials: { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + }, + } as unknown as OAuth2Client; + const mockCredentials: auth.Credentials = { + installed: { + client_id: 'mock-client-id', + client_secret: 'mock-client-secret', + } as unknown as auth.CredentialsKey, + }; + const mockToken = { + type: 'authorized_user', + client_id: mockCredentials.installed?.client_id, + client_secret: mockCredentials.installed?.client_secret, + access_token: mockClient.credentials.access_token, + refresh_token: mockClient.credentials.refresh_token, + }; + const mockUserEmail = 'mock@user.email.com'; + jest.spyOn(fs, 'existsSync').mockReturnValue(true); // isAuthorized + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockToken)); // loadSavedToken + jest + .spyOn(google.auth, 'fromJSON') + .mockImplementation(() => mockClient as unknown as JSONClient); // loadSavedToken + jest.spyOn(google, 'oauth2').mockImplementation(() => { + return { + userinfo: { + get: jest.fn().mockResolvedValue({ + data: { + email: mockUserEmail, + }, + }), + }, + } as unknown as oauth2_v2.Oauth2; + }); + // Act + const result = await auth.getUserEmail(); + // Assert + expect(result).toEqual(mockUserEmail); + }); + + it('should return null if there is a error in retrieving user email', async () => { + // Arrange + jest.spyOn(google, 'oauth2').mockImplementation(() => { + return { + userinfo: { + get: jest.fn().mockRejectedValue(new Error('mock-error')), + }, + } as unknown as oauth2_v2.Oauth2; + }); + // Other arrangements are the same as the previous test: + // 'should return the user email if the user is authorized' + const mockClient = { + credentials: { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + }, + } as unknown as OAuth2Client; + const mockCredentials: auth.Credentials = { + installed: { + client_id: 'mock-client-id', + client_secret: 'mock-client-secret', + } as unknown as auth.CredentialsKey, + }; + const mockToken = { + type: 'authorized_user', + client_id: mockCredentials.installed?.client_id, + client_secret: mockCredentials.installed?.client_secret, + access_token: mockClient.credentials.access_token, + refresh_token: mockClient.credentials.refresh_token, + }; + jest.spyOn(fs, 'existsSync').mockReturnValue(true); // isAuthorized + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockToken)); // loadSavedToken + jest + .spyOn(google.auth, 'fromJSON') + .mockImplementation(() => mockClient as unknown as JSONClient); // loadSavedToken + // Act + const result = await auth.getUserEmail(); + // Assert + expect(result).toBeNull(); }); }); From c38d78dac75ad4c00a14c3fff8685751cebd76b9 Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Wed, 20 Sep 2023 01:11:31 +0900 Subject: [PATCH 30/31] Add `coverageTheshold` to jest.config.ts --- jest.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/jest.config.ts b/jest.config.ts index 9590589..e33a4dc 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -18,6 +18,14 @@ export default { coverageDirectory: 'coverage', // Indicates which provider should be used to instrument code for coverage coverageProvider: 'v8', + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, extensionsToTreatAsEsm: ['.ts'], moduleFileExtensions: ['ts', 'js', 'mjs', 'json'], testEnvironment: 'node', From 6804a50896353934e54f420905f8d511b090d8fe Mon Sep 17 00:00:00 2001 From: ttsukagoshi Date: Wed, 20 Sep 2023 01:24:29 +0900 Subject: [PATCH 31/31] Create jest-coverage.yml --- .github/workflows/jest-coverage.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/workflows/jest-coverage.yml diff --git a/.github/workflows/jest-coverage.yml b/.github/workflows/jest-coverage.yml new file mode 100644 index 0000000..f9e4540 --- /dev/null +++ b/.github/workflows/jest-coverage.yml @@ -0,0 +1,11 @@ +name: 'jest coverage' +on: + pull_request: + branches: + - main +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ArtiomTr/jest-coverage-report-action@v2