From 4aa0297a5aa797620092d675dcec9a259c8a17ed Mon Sep 17 00:00:00 2001 From: Thai Pangsakulyanont Date: Wed, 17 Jul 2019 22:18:26 +0700 Subject: [PATCH 1/5] Initialize mocks --- src/TodoActionsMain.test.ts | 21 +++++++++------------ src/__mocks__/CodeRepository.ts | 12 ++++++++++++ src/__mocks__/DataStore.ts | 26 ++++++++++++++++++++++++++ src/__mocks__/TaskManagementSystem.ts | 16 ++++++++++++++++ 4 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 src/__mocks__/CodeRepository.ts create mode 100644 src/__mocks__/DataStore.ts create mode 100644 src/__mocks__/TaskManagementSystem.ts diff --git a/src/TodoActionsMain.test.ts b/src/TodoActionsMain.test.ts index 997073d..a4774f9 100644 --- a/src/TodoActionsMain.test.ts +++ b/src/TodoActionsMain.test.ts @@ -1,12 +1,9 @@ -// TODO [#35]: Add integration test for TodoActionsMain. -// -// Code that interface with external data have been separated into their own modules. -// These includes: -// -// - `DataStore` -// - `CodeRepository` -// - `TaskManagementSystem` -// -// They can be mocked by creating a mock version using `__mocks__` folder. -// https://jestjs.io/docs/en/manual-mocks -test.todo('works') +import { runMain } from './TodoActionsMain' + +jest.mock('./DataStore') +jest.mock('./CodeRepository') +jest.mock('./TaskManagementSystem') + +test.skip('works', async () => { + await runMain() +}) diff --git a/src/__mocks__/CodeRepository.ts b/src/__mocks__/CodeRepository.ts new file mode 100644 index 0000000..396becd --- /dev/null +++ b/src/__mocks__/CodeRepository.ts @@ -0,0 +1,12 @@ +type Real = typeof import('../CodeRepository') + +export const repoContext: Real['repoContext'] = { + repositoryNodeId: '__GITHUB_REPO_NODE_ID__', + repositoryOwner: '_dtinth', + repositoryName: '_todo-actions', + defaultBranch: 'master', +} + +export const scanCodeRepository: Real['scanCodeRepository'] = async () => { + throw new Error('!!!') +} diff --git a/src/__mocks__/DataStore.ts b/src/__mocks__/DataStore.ts new file mode 100644 index 0000000..68906e2 --- /dev/null +++ b/src/__mocks__/DataStore.ts @@ -0,0 +1,26 @@ +type Real = typeof import('../DataStore') + +export const beginTaskResolution: Real['beginTaskResolution'] = async ( + todoUniqueKey, + repositoryId, +) => { + throw new Error('!!!') + // log.debug( + // 'Found already-existing identifier %s for TODO %s.', + // task.value.taskReference, + // todoUniqueKey, + // ) + // return { existingTaskReference: task.value.taskReference } + + return { + async acquireTaskCreationLock() { + return { + async finish(taskReference, state) {}, + } + }, + } +} + +export const findAllUncompletedTasks: Real['findAllUncompletedTasks'] = async repositoryId => { + throw new Error('!!!') +} diff --git a/src/__mocks__/TaskManagementSystem.ts b/src/__mocks__/TaskManagementSystem.ts new file mode 100644 index 0000000..569b183 --- /dev/null +++ b/src/__mocks__/TaskManagementSystem.ts @@ -0,0 +1,16 @@ +type Real = typeof import('../TaskManagementSystem') + +export const createTask: Real['createTask'] = async information => { + throw new Error('!!!') +} + +export const completeTask: Real['completeTask'] = async taskReference => { + throw new Error('!!!') +} + +export const updateTask: Real['updateTask'] = async ( + taskReference, + information, +) => { + throw new Error('!!!') +} From 597c1dd53021af0d148e4f346a41ac445605a236 Mon Sep 17 00:00:00 2001 From: Thai Pangsakulyanont Date: Wed, 17 Jul 2019 23:01:38 +0700 Subject: [PATCH 2/5] Add integration test --- package.json | 4 +- src/CodeRepository.ts | 13 +----- src/File.ts | 4 +- src/TodoActionsMain.test.ts | 66 ++++++++++++++++++++++++++- src/TodoActionsMain.ts | 14 +++++- src/__mocks__/CodeRepository.ts | 19 +++++++- src/__mocks__/DataStore.ts | 37 +++++++++++---- src/__mocks__/TaskManagementSystem.ts | 15 ++++-- src/__mocks__/World.ts | 40 ++++++++++++++++ tsconfig.json | 3 +- yarn.lock | 12 +++++ 11 files changed, 197 insertions(+), 30 deletions(-) create mode 100644 src/__mocks__/World.ts diff --git a/package.json b/package.json index 0733b0c..cf796ae 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "devDependencies": { "@types/jest": "^24.0.15", - "@types/node": "^12.0.12", + "@types/lodash.sortby": "^4.7.6", "@types/mongodb": "^3.1.28", + "@types/node": "^12.0.12", "jest": "^24.8.0", "madge": "^3.4.4", "ts-jest": "^24.0.2", @@ -18,6 +19,7 @@ "@octokit/graphql": "^3.0.1", "@octokit/rest": "^16.28.4", "dotenv": "^8.0.0", + "lodash.sortby": "^4.7.0", "mongodb": "^3.2.7", "tkt": "1.1.0" } diff --git a/src/CodeRepository.ts b/src/CodeRepository.ts index b436487..3a4acf8 100644 --- a/src/CodeRepository.ts +++ b/src/CodeRepository.ts @@ -1,11 +1,9 @@ import { existsSync, readFileSync } from 'fs' import { logger, invariant } from 'tkt' import { execSync, execFileSync } from 'child_process' -import { ITodo, IFile } from './types' +import { IFile } from './types' import { File } from './File' -import * as TodoParser from './TodoParser' - const log = logger('CodeRepository') const event = @@ -46,7 +44,6 @@ export const repoContext = { } type CodeRepositoryState = { - todoComments: ITodo[] files: IFile[] isOnDefaultBranch: boolean saveChanges(commitMessage: string): Promise @@ -59,21 +56,13 @@ export async function scanCodeRepository(): Promise { }) .split('\n') .filter(name => name) - - const todoComments = [] const files: IFile[] = [] log.info('Parsing TODO tags...') for (const filePath of filesWithTodoMarker) { - // TODO [#22]: Implement ignoring paths - if (filePath === 'README.md') continue const file = new File(filePath) - const todos = TodoParser.parseTodos(file) - log.info('%s: %s found', filePath, todos.length) - todoComments.push(...todos) files.push(file) } return { - todoComments, files, isOnDefaultBranch: execSync('git rev-parse --abbrev-ref HEAD', { diff --git a/src/File.ts b/src/File.ts index b75603b..1b00361 100644 --- a/src/File.ts +++ b/src/File.ts @@ -35,7 +35,9 @@ export class MockFile implements IFile { this.contents = new FileContents(contents) } - save() {} + save() { + this.contents.changed = false + } } export class FileContents implements IFileContents { diff --git a/src/TodoActionsMain.test.ts b/src/TodoActionsMain.test.ts index a4774f9..a6ce6b3 100644 --- a/src/TodoActionsMain.test.ts +++ b/src/TodoActionsMain.test.ts @@ -1,9 +1,73 @@ import { runMain } from './TodoActionsMain' +import { resetMockWorld } from './__mocks__/World' +import sortBy from 'lodash.sortby' jest.mock('./DataStore') jest.mock('./CodeRepository') jest.mock('./TaskManagementSystem') -test.skip('works', async () => { +const MARKER = 'TODO' + +it('works', async () => { + const world = resetMockWorld() + + // Round 1: Arrange + world.file( + 'main.js', + ` + // ${MARKER}: Hello world + // This is great! + + + `, + ) + + // Round 1: Act await runMain() + + // Round 1: Assert commits + expect(world.commits.length).toEqual(2) + expect(world.commits[0].files.get('main.js')).toMatch( + new RegExp(`${MARKER} \\[\\$\\w+\\]: Hello world`), + ) + expect(world.commits[1].files.get('main.js')).toMatch( + new RegExp(`${MARKER} \\[#\\d+\\]: Hello world`), + ) + + // Round 1: Assert tasks + expect(world.tasks.length).toEqual(2) + expect(sortBy(world.tasks.map(t => t.title))).toEqual([ + 'Hello world', + 'Somebody once told me', + ]) + + // Round 2: Arrange + const task1 = world.tasks.find(t => t.title === 'Hello world')! + const task2 = world.tasks.find(t => t.title === 'Somebody once told me')! + world.file( + 'main.js', + ` + + `, + ) + + // Round 2: Act + await runMain() + + // Round 2: Assert commits + // No new commits expected + expect(world.commits.length).toEqual(2) + + // Round 2: Assert tasks + expect(task1.completed).toBe(true) + expect(task2.completed).toBe(false) + expect(task2.title).toBe('Somebody once told me?') }) diff --git a/src/TodoActionsMain.ts b/src/TodoActionsMain.ts index 8b394e9..d98cf32 100644 --- a/src/TodoActionsMain.ts +++ b/src/TodoActionsMain.ts @@ -1,7 +1,9 @@ import { invariant } from 'tkt' import { logger } from 'tkt' import { ObjectId } from 'bson' +import { ITodo } from './types' +import * as TodoParser from './TodoParser' import * as TaskUpdater from './TaskUpdater' import * as CodeRepository from './CodeRepository' @@ -10,10 +12,20 @@ const log = logger('main') export async function runMain() { log.info('Search for files with TODO tags...') const { - todoComments, + files, saveChanges, isOnDefaultBranch, } = await CodeRepository.scanCodeRepository() + + const todoComments: ITodo[] = [] + for (const file of files) { + // TODO [#22]: Implement ignoring paths + if (file.fileName === 'README.md') continue + const todos = TodoParser.parseTodos(file) + log.info('%s: %s found', file.fileName, todos.length) + todoComments.push(...todos) + } + log.info('Total TODOs found: %s', todoComments.length) const todosWithoutReference = todoComments.filter(todo => !todo.reference) log.info('TODOs without references: %s', todosWithoutReference.length) diff --git a/src/__mocks__/CodeRepository.ts b/src/__mocks__/CodeRepository.ts index 396becd..d619f3e 100644 --- a/src/__mocks__/CodeRepository.ts +++ b/src/__mocks__/CodeRepository.ts @@ -1,3 +1,5 @@ +import { mockWorld } from './World' + type Real = typeof import('../CodeRepository') export const repoContext: Real['repoContext'] = { @@ -8,5 +10,20 @@ export const repoContext: Real['repoContext'] = { } export const scanCodeRepository: Real['scanCodeRepository'] = async () => { - throw new Error('!!!') + return { + files: [...mockWorld.files.values()], + isOnDefaultBranch: mockWorld.branch === repoContext.defaultBranch, + async saveChanges(commitMessage) { + if (![...mockWorld.files.values()].some(f => f.contents.changed)) return + mockWorld.commits.push({ + message: commitMessage, + files: new Map( + [...mockWorld.files.values()].map(f => [ + f.fileName, + f.contents.toString(), + ]), + ), + }) + }, + } } diff --git a/src/__mocks__/DataStore.ts b/src/__mocks__/DataStore.ts index 68906e2..fc5aef0 100644 --- a/src/__mocks__/DataStore.ts +++ b/src/__mocks__/DataStore.ts @@ -1,26 +1,45 @@ +import { mockWorld } from './World' + type Real = typeof import('../DataStore') export const beginTaskResolution: Real['beginTaskResolution'] = async ( todoUniqueKey, repositoryId, ) => { - throw new Error('!!!') - // log.debug( - // 'Found already-existing identifier %s for TODO %s.', - // task.value.taskReference, - // todoUniqueKey, - // ) - // return { existingTaskReference: task.value.taskReference } + const existing = mockWorld.store.find(entry => entry._id === todoUniqueKey) + if (existing) { + return { existingTaskReference: existing.reference } + } return { async acquireTaskCreationLock() { return { - async finish(taskReference, state) {}, + async finish(taskReference, state) { + mockWorld.store.push({ + _id: todoUniqueKey, + reference: taskReference, + state: state, + completed: false, + }) + }, } }, } } export const findAllUncompletedTasks: Real['findAllUncompletedTasks'] = async repositoryId => { - throw new Error('!!!') + return mockWorld.store + .filter(entry => !entry.completed) + .map(entry => { + return { + taskReference: entry.reference, + state: entry.state, + async markAsCompleted() { + entry.completed = true + }, + async updateState(newState) { + entry.state = newState + }, + } + }) } diff --git a/src/__mocks__/TaskManagementSystem.ts b/src/__mocks__/TaskManagementSystem.ts index 569b183..5f04a8b 100644 --- a/src/__mocks__/TaskManagementSystem.ts +++ b/src/__mocks__/TaskManagementSystem.ts @@ -1,16 +1,25 @@ +import { mockWorld, MockTask } from './World' + type Real = typeof import('../TaskManagementSystem') export const createTask: Real['createTask'] = async information => { - throw new Error('!!!') + const number = mockWorld.tasks.length + 1 + const task: MockTask = { ...information, number, completed: false } + mockWorld.tasks.push(task) + return `#${task.number}` } export const completeTask: Real['completeTask'] = async taskReference => { - throw new Error('!!!') + getTask(taskReference).completed = true } export const updateTask: Real['updateTask'] = async ( taskReference, information, ) => { - throw new Error('!!!') + Object.assign(getTask(taskReference), information) +} + +function getTask(taskReference: string) { + return mockWorld.tasks.find(t => `#${t.number}` === taskReference)! } diff --git a/src/__mocks__/World.ts b/src/__mocks__/World.ts new file mode 100644 index 0000000..e15edc0 --- /dev/null +++ b/src/__mocks__/World.ts @@ -0,0 +1,40 @@ +import { IFile, ITaskState } from '../types' +import { MockFile } from '../File' + +export let mockWorld: MockWorld + +export type MockTask = { + title: string + body: string + number: number + completed: boolean +} + +export type MockDataStoreEntry = { + _id: string + completed: boolean + reference: string + state: ITaskState +} + +export type MockCommit = { + message: string + files: Map +} + +export function resetMockWorld() { + mockWorld = new MockWorld() + return mockWorld +} + +class MockWorld { + files: Map = new Map() + branch = 'master' + store: MockDataStoreEntry[] = [] + tasks: MockTask[] = [] + commits: MockCommit[] = [] + + file(fileName: string, contents: string) { + this.files.set(fileName, new MockFile(fileName, contents)) + } +} diff --git a/tsconfig.json b/tsconfig.json index b7dbcea..5ea6dfe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "skipLibCheck": true, "rootDir": "src", "outDir": "lib", - "sourceMap": true + "sourceMap": true, + "esModuleInterop": true }, "include": ["src"], "exclude": ["node_modules", "**/node_modules/*"] diff --git a/yarn.lock b/yarn.lock index 4497de2..c3f36ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -420,6 +420,18 @@ dependencies: "@types/jest-diff" "*" +"@types/lodash.sortby@^4.7.6": + version "4.7.6" + resolved "https://registry.yarnpkg.com/@types/lodash.sortby/-/lodash.sortby-4.7.6.tgz#eed689835f274b553db4ae16a4a23f58b79618a1" + integrity sha512-EnvAOmKvEg7gdYpYrS6+fVFPw5dL9rBnJi3vcKI7wqWQcLJVF/KRXK9dH29HjGNVvFUj0s9prRP3J8jEGnGKDw== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.136" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.136.tgz#413e85089046b865d960c9ff1d400e04c31ab60f" + integrity sha512-0GJhzBdvsW2RUccNHOBkabI8HZVdOXmXbXhuKlDEd5Vv12P7oAVGfomGp3Ne21o5D/qu1WmthlNKFaoZJJeErA== + "@types/mongodb@^3.1.28": version "3.1.28" resolved "https://registry.yarnpkg.com/@types/mongodb/-/mongodb-3.1.28.tgz#c049cdff343788d77f5cc8c5f2e4af72ba7d047b" From 1bf468209d9c9dd3c6315d1a7b248571fffee333 Mon Sep 17 00:00:00 2001 From: Thai Pangsakulyanont Date: Wed, 17 Jul 2019 23:04:16 +0700 Subject: [PATCH 3/5] Fix esModuleInterop --- src/TaskManagementSystem.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/TaskManagementSystem.ts b/src/TaskManagementSystem.ts index 9884c4c..8a553c4 100644 --- a/src/TaskManagementSystem.ts +++ b/src/TaskManagementSystem.ts @@ -1,5 +1,4 @@ import { invariant, logger } from 'tkt' -import { ITodo } from './types' import * as CodeRepository from './CodeRepository' @@ -47,7 +46,7 @@ export async function createTask( } export async function completeTask(taskReference: string): Promise { - const Octokit = await import('@octokit/rest') + const Octokit = (await import('@octokit/rest')).default const octokit = new Octokit({ auth: `token ${process.env.GITHUB_TOKEN || invariant(false, 'Required GITHUB_TOKEN variable.')}`, @@ -65,7 +64,7 @@ export async function updateTask( taskReference: string, information: TaskInformation, ): Promise { - const Octokit = await import('@octokit/rest') + const Octokit = (await import('@octokit/rest')).default const octokit = new Octokit({ auth: `token ${process.env.GITHUB_TOKEN || invariant(false, 'Required GITHUB_TOKEN variable.')}`, From d04201b13737f8fb5537862a8358cec746923f9e Mon Sep 17 00:00:00 2001 From: Thai Pangsakulyanont Date: Wed, 17 Jul 2019 23:09:47 +0700 Subject: [PATCH 4/5] Add test that it skips non default branch --- src/TodoActionsMain.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/TodoActionsMain.test.ts b/src/TodoActionsMain.test.ts index a6ce6b3..8e35994 100644 --- a/src/TodoActionsMain.test.ts +++ b/src/TodoActionsMain.test.ts @@ -71,3 +71,29 @@ it('works', async () => { expect(task2.completed).toBe(false) expect(task2.title).toBe('Somebody once told me?') }) + +it('skips non default branch', async () => { + const world = resetMockWorld() + + // Arrange + world.branch = 'featureBranch' + world.file( + 'main.js', + ` + // ${MARKER}: Hello world + // This is great! + + + `, + ) + + // Act + await runMain() + + // Assert + expect(world.commits.length).toEqual(0) +}) From f31f1822afab896ba077fe07da245c3e267f0b47 Mon Sep 17 00:00:00 2001 From: Thai Pangsakulyanont Date: Wed, 17 Jul 2019 23:25:56 +0700 Subject: [PATCH 5/5] Fix mock file not saved --- src/TodoActionsMain.test.ts | 5 +++++ src/__mocks__/CodeRepository.ts | 13 +++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/TodoActionsMain.test.ts b/src/TodoActionsMain.test.ts index 8e35994..2b409fc 100644 --- a/src/TodoActionsMain.test.ts +++ b/src/TodoActionsMain.test.ts @@ -45,6 +45,11 @@ it('works', async () => { 'Somebody once told me', ]) + // Idempotent check + await runMain() + expect(world.commits.length).toEqual(2) + expect(world.tasks.length).toEqual(2) + // Round 2: Arrange const task1 = world.tasks.find(t => t.title === 'Hello world')! const task2 = world.tasks.find(t => t.title === 'Somebody once told me')! diff --git a/src/__mocks__/CodeRepository.ts b/src/__mocks__/CodeRepository.ts index d619f3e..7f8e450 100644 --- a/src/__mocks__/CodeRepository.ts +++ b/src/__mocks__/CodeRepository.ts @@ -10,19 +10,16 @@ export const repoContext: Real['repoContext'] = { } export const scanCodeRepository: Real['scanCodeRepository'] = async () => { + const files = [...mockWorld.files.values()] return { - files: [...mockWorld.files.values()], + files: files, isOnDefaultBranch: mockWorld.branch === repoContext.defaultBranch, async saveChanges(commitMessage) { - if (![...mockWorld.files.values()].some(f => f.contents.changed)) return + if (!files.some(f => f.contents.changed)) return + files.forEach(f => f.save()) mockWorld.commits.push({ message: commitMessage, - files: new Map( - [...mockWorld.files.values()].map(f => [ - f.fileName, - f.contents.toString(), - ]), - ), + files: new Map(files.map(f => [f.fileName, f.contents.toString()])), }) }, }