From ce88a1fad05e0240724ef8053f80ce162aa558d2 Mon Sep 17 00:00:00 2001 From: Christian Holbrook Date: Wed, 29 Apr 2026 10:05:23 -0600 Subject: [PATCH 1/3] Add unit tests to this project to help ensure stability --- package-lock.json | 35 ++++ package.json | 2 + src/ProjectManager.spec.ts | 135 +++++++++++++ src/ReleaseCreator.spec.ts | 386 ++++++++++++++++++++++++++++++++----- src/test-helpers.ts | 106 ++++++++++ src/utils.spec.ts | 197 +++++++++++++++++++ 6 files changed, 817 insertions(+), 44 deletions(-) create mode 100644 src/ProjectManager.spec.ts create mode 100644 src/test-helpers.ts create mode 100644 src/utils.spec.ts diff --git a/package-lock.json b/package-lock.json index 497ac52..c4248ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ }, "devDependencies": { "@types/chai": "^4.2.22", + "@types/chai-as-promised": "^8.0.2", "@types/dateformat": "~3", "@types/debounce": "^1.2.1", "@types/decompress": "^4.2.4", @@ -41,6 +42,7 @@ "@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/parser": "^5.27.0", "chai": "^4.3.4", + "chai-as-promised": "^8.0.2", "eslint": "^8.1.0", "eslint-plugin-no-only-tests": "^2.6.0", "mocha": "^11.1.0", @@ -1055,6 +1057,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai-as-promised": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-8.0.2.tgz", + "integrity": "sha512-meQ1wDr1K5KRCSvG2lX7n7/5wf70BeptTKst0axGvnN6zqaVpRqegoIbugiAPSqOW9K9aL8gDVrm7a2LXOtn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "*" + } + }, "node_modules/@types/dateformat": { "version": "3.0.1", "dev": true, @@ -1891,6 +1903,29 @@ "node": ">=4" } }, + "node_modules/chai-as-promised": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.2.tgz", + "integrity": "sha512-1GadL+sEJVLzDjcawPM4kjfnL+p/9vrxiEUonowKOAzvVg0PixJUdtuDzdkDeQhK3zfOE76GqGkZIQ7/Adcrqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "check-error": "^2.1.1" + }, + "peerDependencies": { + "chai": ">= 2.1.2 < 7" + } + }, + "node_modules/chai-as-promised/node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chalk": { "version": "4.1.2", "dev": true, diff --git a/package.json b/package.json index 107e679..c5de766 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@types/chai": "^4.2.22", + "@types/chai-as-promised": "^8.0.2", "@types/dateformat": "~3", "@types/debounce": "^1.2.1", "@types/decompress": "^4.2.4", @@ -50,6 +51,7 @@ "@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/parser": "^5.27.0", "chai": "^4.3.4", + "chai-as-promised": "^8.0.2", "eslint": "^8.1.0", "eslint-plugin-no-only-tests": "^2.6.0", "mocha": "^11.1.0", diff --git a/src/ProjectManager.spec.ts b/src/ProjectManager.spec.ts new file mode 100644 index 0000000..aad6dde --- /dev/null +++ b/src/ProjectManager.spec.ts @@ -0,0 +1,135 @@ +/* eslint-disable camelcase */ +import { expect } from 'chai'; +import * as chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { createSandbox } from 'sinon'; +import { ProjectManager, Project, ProjectDependency } from './ProjectManager'; +import { utils } from './utils'; + +chai.use(chaiAsPromised); + +const sinon = createSandbox(); + +describe('ProjectManager', () => { + beforeEach(() => { + sinon.restore(); + // Reset the singleton instance + (ProjectManager as any).instance = undefined; + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('getPreviousVersion', () => { + it('returns the previous release version', () => { + let tags = [ + 'v1.0.0', + 'v0.9.0', + 'v0.8.0' + ]; + sinon.stub(utils, 'executeCommandWithOutput').callsFake((cmd: string) => { + if (cmd === `git tag --merged HEAD`) { + return tags.join('\n'); + } + return ''; + }); + + expect(ProjectManager.getPreviousVersion('1.0.1', '')).to.equal('1.0.0'); + expect(ProjectManager.getPreviousVersion('0.9.1', '')).to.equal('0.9.0'); + expect(ProjectManager.getPreviousVersion('0.8.9', '')).to.equal('0.8.0'); + expect(ProjectManager.getPreviousVersion('0.1.0', '')).to.equal(undefined); + }); + + it('handles prerelease versions', () => { + sinon.stub(utils, 'executeCommandWithOutput').callsFake((cmd: string) => { + if (cmd === `git tag --merged HEAD`) { + return tags.join('\n'); + } + return ''; + }); + + let tags = [ + 'v0.9.9', + 'v0.9.0', + 'v0.8.0' + ]; + expect(ProjectManager.getPreviousVersion('1.0.0-alpha.0', '')).to.equal('0.9.9'); + + tags = [ + 'v1.0.0-alpha.0', + 'v1.0.0', + 'v0.9.0', + 'v0.8.0' + ]; + expect(ProjectManager.getPreviousVersion('1.0.0-alpha.1', '')).to.equal('1.0.0-alpha.0'); + + tags = [ + 'v0.9.2', + 'v0.9.1', + 'v0.9.0', + 'v1.0.0-alpha.0', + 'v0.9.0', + 'v0.8.0' + ]; + expect(ProjectManager.getPreviousVersion('1.0.0-alpha.1', '')).to.equal('1.0.0-alpha.0'); + }); + + it('returns undefined when no previous version exists', () => { + sinon.stub(utils, 'executeCommandWithOutput').returns(''); + + expect(ProjectManager.getPreviousVersion('1.0.0', '')).to.equal(undefined); + }); + }); + + describe('getProject', () => { + it('returns undefined for unknown project', () => { + const result = ProjectManager.getProject('unknown-project'); + + expect(result).to.be.undefined; + }); + }); + + describe('Project class', () => { + it('initializes with correct default values', () => { + const project = new Project('test', '@test/project', 'https://github.com/test/repo'); + + expect(project.name).to.equal('test'); + expect(project.npmName).to.equal('@test/project'); + expect(project.repositoryUrl).to.equal('https://github.com/test/repo'); + expect(project.version).to.equal(''); + expect(project.dependencies).to.deep.equal([]); + expect(project.devDependencies).to.deep.equal([]); + expect(project.changes).to.deep.equal([]); + }); + }); + + describe('ProjectDependency class', () => { + it('initializes with correct values', () => { + const dep = new ProjectDependency('pkg', 'repo', '1.0.0', '1.1.0'); + + expect(dep.name).to.equal('pkg'); + expect(dep.repoName).to.equal('repo'); + expect(dep.previousReleaseVersion).to.equal('1.0.0'); + expect(dep.newVersion).to.equal('1.1.0'); + }); + + it('hasChanged returns truthy when versions differ', () => { + const dep = new ProjectDependency('pkg', 'repo', '1.0.0', '1.1.0'); + + expect(dep.hasChanged()).to.be.ok; + }); + + it('hasChanged returns falsy when versions are the same', () => { + const dep = new ProjectDependency('pkg', 'repo', '1.0.0', '1.0.0'); + + expect(dep.hasChanged()).to.not.be.ok; + }); + + it('hasChanged returns falsy for invalid semver', () => { + const dep = new ProjectDependency('pkg', 'repo', 'invalid', '1.0.0'); + + expect(dep.hasChanged()).to.not.be.ok; + }); + }); +}); diff --git a/src/ReleaseCreator.spec.ts b/src/ReleaseCreator.spec.ts index e7ca73d..501e3cc 100644 --- a/src/ReleaseCreator.spec.ts +++ b/src/ReleaseCreator.spec.ts @@ -1,14 +1,20 @@ /* eslint-disable camelcase */ import { expect } from 'chai'; +import * as chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; import { createSandbox } from 'sinon'; import { ReleaseCreator } from './ReleaseCreator'; import { utils } from './utils'; import { ProjectManager } from './ProjectManager'; +import { createMockRelease, createMockAsset, createMockProject } from './test-helpers'; +import * as fastGlob from 'fast-glob'; + +chai.use(chaiAsPromised); const sinon = createSandbox(); let releaseCreator: ReleaseCreator; -describe('Test ReleaseCreator.ts', () => { +describe('ReleaseCreator', () => { beforeEach(() => { sinon.restore(); releaseCreator = new ReleaseCreator(); @@ -18,54 +24,346 @@ describe('Test ReleaseCreator.ts', () => { sinon.restore(); }); - it('Successfully gets the previous release version', () => { - let tags = [ - 'v1.0.0', - 'v0.9.0', - 'v0.8.0' - ]; - sinon.stub(utils, 'executeCommandWithOutput').callsFake((cmd: string, dir: string) => { - if (cmd === `git tag --merged HEAD`) { - return tags.join('\n'); - } else { - return utils.executeCommandWithOutput(cmd, dir); + describe('assertSingleRelease', () => { + it('returns release when exactly one matches tag', async () => { + const mockRelease = createMockRelease({ tag_name: 'v1.0.0' }); + sinon.stub(releaseCreator as any, 'listGitHubReleases').resolves([mockRelease]); + + const result = await (releaseCreator as any).assertSingleRelease('test-project', 'v1.0.0'); + + expect(result).to.deep.equal(mockRelease); + }); + + it('throws "No release found" when no releases exist', async () => { + sinon.stub(releaseCreator as any, 'listGitHubReleases').resolves([]); + + await expect( + (releaseCreator as any).assertSingleRelease('test-project', 'v1.0.0') + ).to.be.rejectedWith('No release found with tag v1.0.0'); + }); + + it('throws "No release found" when no tags match', async () => { + const mockRelease = createMockRelease({ tag_name: 'v2.0.0' }); + sinon.stub(releaseCreator as any, 'listGitHubReleases').resolves([mockRelease]); + + await expect( + (releaseCreator as any).assertSingleRelease('test-project', 'v1.0.0') + ).to.be.rejectedWith('No release found with tag v1.0.0'); + }); + + it('throws with count and links when multiple releases match', async () => { + const releases = [ + createMockRelease({ id: 1, tag_name: 'v1.0.0', draft: true, html_url: 'https://github.com/test/releases/1', assets: [] }), + createMockRelease({ id: 2, tag_name: 'v1.0.0', draft: false, html_url: 'https://github.com/test/releases/2', assets: [createMockAsset()] }) + ]; + sinon.stub(releaseCreator as any, 'listGitHubReleases').resolves(releases); + + try { + await (releaseCreator as any).assertSingleRelease('test-project', 'v1.0.0'); + expect.fail('Expected error to be thrown'); + } catch (e: any) { + expect(e.message).to.match(/Found 2 releases with tag v1.0.0/); + expect(e.message).to.match(/https:\/\/github.com\/test\/releases\/1/); } }); - expect(ProjectManager.getPreviousVersion('1.0.1', '')).to.equal('1.0.0'); - expect(ProjectManager.getPreviousVersion('0.9.1', '')).to.equal('0.9.0'); - expect(ProjectManager.getPreviousVersion('0.8.9', '')).to.equal('0.8.0'); - expect(ProjectManager.getPreviousVersion('0.1.0', '')).to.equal(undefined); + + it('filters by exact tag match (v1.0.0 vs v1.0.0-beta)', async () => { + const releases = [ + createMockRelease({ tag_name: 'v1.0.0' }), + createMockRelease({ tag_name: 'v1.0.0-beta' }) + ]; + sinon.stub(releaseCreator as any, 'listGitHubReleases').resolves(releases); + + const result = await (releaseCreator as any).assertSingleRelease('test-project', 'v1.0.0'); + + expect(result.tag_name).to.equal('v1.0.0'); + }); }); - it('Successfully gets the previous release version with prerelease', () => { - sinon.stub(utils, 'executeCommandWithOutput').callsFake((cmd: string, dir: string) => { - if (cmd === `git tag --merged HEAD`) { - return tags.join('\n'); - } else { - return utils.executeCommandWithOutput(cmd, dir); + describe('verifyReleaseAssets', () => { + let initializeStub: sinon.SinonStub; + let executeCommandStub: sinon.SinonStub; + + beforeEach(() => { + const mockProject = createMockProject(); + initializeStub = sinon.stub(ProjectManager, 'initialize').resolves(mockProject); + executeCommandStub = sinon.stub(utils, 'executeCommand'); + }); + + it('succeeds when assets exist', async () => { + sinon.stub(releaseCreator as any, 'getVersion').resolves('1.0.0'); + sinon.stub(releaseCreator as any, 'assertSingleRelease').resolves(createMockRelease({ id: 1 })); + sinon.stub(utils, 'octokitPageHelper').resolves([createMockAsset()]); + + // Should not throw + await releaseCreator.verifyReleaseAssets({ projectName: 'test-project', ref: 'release/1.0.0' }); + }); + + it('throws descriptive error with re-run instructions when no assets exist', async () => { + sinon.stub(releaseCreator as any, 'getVersion').resolves('1.0.0'); + sinon.stub(releaseCreator as any, 'assertSingleRelease').resolves(createMockRelease({ id: 1 })); + sinon.stub(utils, 'octokitPageHelper').resolves([]); + + try { + await releaseCreator.verifyReleaseAssets({ projectName: 'test-project', ref: 'release/1.0.0' }); + expect.fail('Expected error to be thrown'); + } catch (e: any) { + expect(e.message).to.match(/Release v1.0.0 has no assets!/); + expect(e.message).to.match(/Re-run the 'Make Release Artifacts' workflow/); } }); - let tags = [ - 'v0.9.9', - 'v0.9.0', - 'v0.8.0' - ]; - expect(ProjectManager.getPreviousVersion('1.0.0-alpha.0', '')).to.equal('0.9.9'); - tags = [ - 'v1.0.0-alpha.0', - 'v1.0.0', - 'v0.9.0', - 'v0.8.0' - ]; - expect(ProjectManager.getPreviousVersion('1.0.0-alpha.1', '')).to.equal('1.0.0-alpha.0'); - tags = [ - 'v0.9.2', - 'v0.9.1', - 'v0.9.0', - 'v1.0.0-alpha.0', - 'v0.9.0', - 'v0.8.0' - ]; - expect(ProjectManager.getPreviousVersion('1.0.0-alpha.1', '')).to.equal('1.0.0-alpha.0'); }); + + describe('getVersion', () => { + it('reads version from package.json', async () => { + // Use the real package.json in this repo + const result = await (releaseCreator as any).getVersion(process.cwd()); + + expect(result).to.match(/^\d+\.\d+\.\d+/); + }); + }); + + describe('getNewVersion', () => { + it('returns customVersion when provided', async () => { + const result = await (releaseCreator as any).getNewVersion('patch', '5.0.0', '/fake/dir'); + + expect(result).to.equal('5.0.0'); + }); + + it('increments patch version', async () => { + // Use real package.json which has version 1.0.0 + const result = await (releaseCreator as any).getNewVersion('patch', '', process.cwd()); + + expect(result).to.equal('1.0.1'); + }); + + it('increments minor version', async () => { + const result = await (releaseCreator as any).getNewVersion('minor', '', process.cwd()); + + expect(result).to.equal('1.1.0'); + }); + + it('increments major version', async () => { + const result = await (releaseCreator as any).getNewVersion('major', '', process.cwd()); + + expect(result).to.equal('2.0.0'); + }); + + it('increments prerelease version', async () => { + const result = await (releaseCreator as any).getNewVersion('prerelease', '', process.cwd()); + + expect(result).to.equal('1.0.1-0'); + }); + }); + + describe('getArtifactName', () => { + it('returns single artifact when only one exists', () => { + const result = (releaseCreator as any).getArtifactName(['artifact.tgz'], 'hint.tgz'); + + expect(result).to.equal('artifact.tgz'); + }); + + it('filters by extension when multiple artifacts', () => { + const artifacts = ['file.tgz', 'file.vsix', 'other.tgz']; + const result = (releaseCreator as any).getArtifactName(artifacts, 'hint.vsix'); + + expect(result).to.equal('file.vsix'); + }); + + it('matches by name hint when multiple with same extension', () => { + const artifacts = ['foo-1.0.0.tgz', 'bar-1.0.0.tgz']; + const result = (releaseCreator as any).getArtifactName(artifacts, 'bar-1.0.0.tgz'); + + expect(result).to.equal('bar-1.0.0.tgz'); + }); + + it('returns first match when multiple matches', () => { + const artifacts = ['test-1.0.0.tgz', 'test-1.0.0-extra.tgz']; + const result = (releaseCreator as any).getArtifactName(artifacts, 'test-1.0.0.tgz'); + + expect(result).to.equal('test-1.0.0.tgz'); + }); + + it('returns name hint as fallback when no match', () => { + const result = (releaseCreator as any).getArtifactName([], 'fallback.tgz'); + + expect(result).to.equal('fallback.tgz'); + }); + }); + + describe('appendDateToArtifactName', () => { + it('inserts branch and date before extension', () => { + const result = (releaseCreator as any).appendDateToArtifactName('package-1.0.0.tgz', '1.0.0', 'release/1.0.0'); + + expect(result).to.match(/^package-1\.0\.0-release_1\.0\.0\.\d{14}\.tgz$/); + }); + + it('replaces first slash in branch name with underscore', () => { + // Note: Current implementation only replaces first slash + const result = (releaseCreator as any).appendDateToArtifactName('test.vsix', '1.0.0', 'release/1.0.0'); + + expect(result).to.include('release_1.0.0'); + }); + }); + + describe('makePullRequestBody', () => { + it('generates draft PR body with edit changelog link', () => { + const result = (releaseCreator as any).makePullRequestBody({ + projectName: 'test-project', + releaseVersion: '1.0.0', + prevReleaseVersion: '0.9.0', + isDraft: true + }); + + expect(result).to.include('v1.0.0'); + expect(result).to.include('test-project'); + expect(result).to.include('Edit changelog'); + expect(result).to.include('release/1.0.0'); + }); + + it('generates published PR body with release link', () => { + const result = (releaseCreator as any).makePullRequestBody({ + projectName: 'test-project', + releaseVersion: '1.0.0', + prevReleaseVersion: '0.9.0', + isDraft: false, + githubReleaseLink: 'https://github.com/test/releases/v1.0.0' + }); + + expect(result).to.include('GitHub Release'); + expect(result).to.include('Changelog'); + expect(result).to.not.include('Edit changelog'); + }); + + it('includes npm install command when npm artifact', () => { + const result = (releaseCreator as any).makePullRequestBody({ + projectName: 'test-project', + releaseVersion: '1.0.0', + prevReleaseVersion: '0.9.0', + isDraft: true, + npm: { + downloadLink: 'https://example.com/package.tgz', + sha: 'abc123', + command: '```bash\nnpm install https://example.com/package.tgz\n```' + } + }); + + expect(result).to.include('npm install'); + expect(result).to.include('abc123'); + }); + + it('includes vsix download link when vsix artifact', () => { + const result = (releaseCreator as any).makePullRequestBody({ + projectName: 'test-project', + releaseVersion: '1.0.0', + prevReleaseVersion: '0.9.0', + isDraft: true, + vsix: { + downloadLink: 'https://example.com/extension.vsix', + sha: 'def456' + } + }); + + expect(result).to.include('download the .vsix'); + expect(result).to.include('def456'); + expect(result).to.include('installation instructions'); + }); + + it('includes PR number in edit changelog link when provided', () => { + const result = (releaseCreator as any).makePullRequestBody({ + projectName: 'test-project', + releaseVersion: '1.0.0', + prevReleaseVersion: '0.9.0', + isDraft: true, + prNumber: 123 + }); + + expect(result).to.include('?pr='); + expect(result).to.include('pull/123'); + }); + }); + + describe('initializeRelease', () => { + it('fails if repository is not clean', async () => { + const mockProject = createMockProject(); + sinon.stub(ProjectManager, 'initialize').resolves(mockProject); + sinon.stub(utils, 'executeCommandSucceeds').withArgs('git diff --quiet').returns(false); + + await expect( + releaseCreator.initializeRelease({ + projectName: 'test-project', + releaseType: 'patch', + branch: 'master', + installDependencies: false, + customVersion: '1.0.0' + }) + ).to.be.rejectedWith('Repository is not clean'); + }); + + it('fails if release already exists', async () => { + const mockProject = createMockProject(); + sinon.stub(ProjectManager, 'initialize').resolves(mockProject); + sinon.stub(utils, 'executeCommandSucceeds').returns(true); + sinon.stub(utils, 'executeCommandWithOutput').returns(''); + sinon.stub((releaseCreator as any).octokit.rest.repos, 'listReleases').resolves({ + data: [createMockRelease({ tag_name: 'v1.0.0' })] + }); + + await expect( + releaseCreator.initializeRelease({ + projectName: 'test-project', + releaseType: 'patch', + branch: 'master', + installDependencies: false, + customVersion: '1.0.0' + }) + ).to.be.rejectedWith('Release v1.0.0 already exists'); + }); + }); + + describe('publishRelease', () => { + it('fails if release has no assets', async () => { + const mockProject = createMockProject(); + sinon.stub(ProjectManager, 'initialize').resolves(mockProject); + sinon.stub(utils, 'executeCommand'); + sinon.stub(utils, 'executeCommandSucceeds').returns(true); + sinon.stub(releaseCreator as any, 'getVersion').resolves('1.0.0'); + + const mockRelease = createMockRelease({ draft: false }); // Not a draft, so skips updateRelease + sinon.stub(releaseCreator as any, 'assertSingleRelease').resolves(mockRelease); + sinon.stub(utils, 'octokitPageHelper').resolves([]); + + await expect( + releaseCreator.publishRelease({ + projectName: 'test-project', + ref: 'abc123', + releaseType: 'npm' + }) + ).to.be.rejectedWith(/has no assets/); + }); + }); + + describe('makeReleaseArtifacts', () => { + it('fails if published release has assets without --force', async () => { + const mockProject = createMockProject(); + sinon.stub(ProjectManager, 'initialize').resolves(mockProject); + sinon.stub(utils, 'executeCommand'); + sinon.stub(releaseCreator as any, 'getVersion').resolves('1.0.0'); + sinon.stub(fastGlob, 'sync').returns(['artifact.tgz']); + + const mockRelease = createMockRelease({ draft: false }); + sinon.stub(releaseCreator as any, 'assertSingleRelease').resolves(mockRelease); + sinon.stub(utils, 'octokitPageHelper').resolves([createMockAsset()]); + + await expect( + releaseCreator.makeReleaseArtifacts({ + branch: 'release/1.0.0', + projectName: 'test-project', + artifactPaths: '*.tgz', + force: false + }) + ).to.be.rejectedWith(/already published with assets/); + }); + }); + }); diff --git a/src/test-helpers.ts b/src/test-helpers.ts new file mode 100644 index 0000000..64f5419 --- /dev/null +++ b/src/test-helpers.ts @@ -0,0 +1,106 @@ +import { Project, ProjectDependency } from './ProjectManager'; + +/** + * Creates a mock Project with sensible defaults. + * Override any property by passing it in the overrides object. + */ +export function createMockProject(overrides: Partial = {}): Project { + const project = new Project( + overrides.name ?? 'test-project', + overrides.npmName ?? '@rokucommunity/test-project', + overrides.repositoryUrl ?? 'https://github.com/rokucommunity/test-project' + ); + project.dir = overrides.dir ?? '/tmp/.releases/test-project'; + project.version = overrides.version ?? '1.0.0'; + project.dependencies = overrides.dependencies ?? []; + project.devDependencies = overrides.devDependencies ?? []; + project.changes = overrides.changes ?? []; + project.lastTag = overrides.lastTag ?? 'v0.9.0'; + return project; +} + +/** + * Creates a mock GitHub Release object. + */ +export function createMockRelease(overrides: Partial = {}): MockRelease { + return { + id: overrides.id ?? 1, + tag_name: overrides.tag_name ?? 'v1.0.0', + name: overrides.name ?? '1.0.0', + draft: overrides.draft ?? true, + prerelease: overrides.prerelease ?? false, + html_url: overrides.html_url ?? 'https://github.com/rokucommunity/test-project/releases/tag/v1.0.0', + assets: overrides.assets ?? [], + target_commitish: overrides.target_commitish ?? 'release/1.0.0', + body: overrides.body ?? 'Release notes' + }; +} + +export interface MockRelease { + id: number; + tag_name: string; + name: string; + draft: boolean; + prerelease: boolean; + html_url: string; + assets: MockAsset[]; + target_commitish: string; + body: string; +} + +/** + * Creates a mock GitHub Release Asset object. + */ +export function createMockAsset(overrides: Partial = {}): MockAsset { + return { + id: overrides.id ?? 1, + name: overrides.name ?? 'test-project-1.0.0.tgz', + size: overrides.size ?? 1024, + download_count: overrides.download_count ?? 0, + browser_download_url: overrides.browser_download_url ?? 'https://github.com/rokucommunity/test-project/releases/download/v1.0.0/test-project-1.0.0.tgz' + }; +} + +export interface MockAsset { + id: number; + name: string; + size: number; + download_count: number; + browser_download_url: string; +} + +/** + * Creates a mock Pull Request object. + */ +export function createMockPullRequest(overrides: Partial = {}): MockPullRequest { + return { + number: overrides.number ?? 123, + title: overrides.title ?? '1.0.0', + state: overrides.state ?? 'open', + html_url: overrides.html_url ?? 'https://github.com/rokucommunity/test-project/pull/123', + head: overrides.head ?? { ref: 'release/1.0.0' }, + base: overrides.base ?? { ref: 'master' }, + body: overrides.body ?? 'PR body' + }; +} + +export interface MockPullRequest { + number: number; + title: string; + state: string; + html_url: string; + head: { ref: string }; + base: { ref: string }; + body: string; +} + +/** + * Helper to create an Octokit-style response wrapper. + */ +export function createOctokitResponse(data: T, status = 200) { + return { + data, + status, + headers: {} + }; +} diff --git a/src/utils.spec.ts b/src/utils.spec.ts new file mode 100644 index 0000000..28a8773 --- /dev/null +++ b/src/utils.spec.ts @@ -0,0 +1,197 @@ +/* eslint-disable camelcase */ +import { expect } from 'chai'; +import * as chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { createSandbox } from 'sinon'; +import { utils, standardizePath } from './utils'; + +chai.use(chaiAsPromised); + +const sinon = createSandbox(); + +describe('utils', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('isVersion', () => { + it('returns truthy for valid semver', () => { + expect(utils.isVersion('1.0.0')).to.be.ok; + expect(utils.isVersion('1.2.3')).to.be.ok; + expect(utils.isVersion('0.0.1')).to.be.ok; + expect(utils.isVersion('1.0.0-alpha.1')).to.be.ok; + expect(utils.isVersion('2.0.0-beta.0')).to.be.ok; + }); + + it('returns falsy for commit hashes', () => { + expect(utils.isVersion('abc123')).to.not.be.ok; + expect(utils.isVersion('a1b2c3d4e5f6')).to.not.be.ok; + }); + + it('returns falsy for invalid strings', () => { + expect(utils.isVersion('not-a-version')).to.not.be.ok; + expect(utils.isVersion('')).to.not.be.ok; + }); + }); + + describe('executeCommandSucceeds', () => { + it('returns true when command succeeds', () => { + const result = utils.executeCommandSucceeds('echo hello'); + + expect(result).to.be.true; + }); + + it('returns false when command fails', () => { + const result = utils.executeCommandSucceeds('exit 1'); + + expect(result).to.be.false; + }); + + it('does not throw on failure', () => { + expect(() => utils.executeCommandSucceeds('exit 1')).to.not.throw(); + }); + }); + + describe('tryExecuteCommandWithOutput', () => { + it('returns trimmed output on success', () => { + // Use a real command that we know will succeed + const result = utils.tryExecuteCommandWithOutput('echo hello'); + + expect(result).to.equal('hello'); + }); + + it('returns empty string on failure', () => { + // Use a command that will fail + const result = utils.tryExecuteCommandWithOutput('exit 1'); + + expect(result).to.equal(''); + }); + }); + + describe('octokitPageHelper', () => { + it('collects data from single page', async () => { + const mockApi = sinon.stub().resolves({ data: [{ id: 1 }, { id: 2 }] }); + + const result = await utils.octokitPageHelper(mockApi); + + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + expect(mockApi.calledOnce).to.be.true; + }); + + it('paginates when data.length equals OCTOKIT_PER_PAGE', async () => { + const fullPage = Array(utils.OCTOKIT_PER_PAGE).fill({ id: 1 }); + const partialPage = [{ id: 2 }, { id: 3 }]; + + const mockApi = sinon.stub(); + mockApi.onFirstCall().resolves({ data: fullPage }); + mockApi.onSecondCall().resolves({ data: partialPage }); + + const result = await utils.octokitPageHelper(mockApi); + + expect(result.length).to.equal(utils.OCTOKIT_PER_PAGE + 2); + expect(mockApi.calledTwice).to.be.true; + }); + + it('stops pagination when data.length < OCTOKIT_PER_PAGE', async () => { + const mockApi = sinon.stub().resolves({ data: [{ id: 1 }] }); + + await utils.octokitPageHelper(mockApi); + + expect(mockApi.calledOnce).to.be.true; + }); + + it('handles empty first page', async () => { + const mockApi = sinon.stub().resolves({ data: [] }); + + const result = await utils.octokitPageHelper(mockApi); + + expect(result).to.deep.equal([]); + expect(mockApi.calledOnce).to.be.true; + }); + + it('handles missing data property', async () => { + const mockApi = sinon.stub().resolves({}); + + const result = await utils.octokitPageHelper(mockApi); + + expect(result).to.deep.equal([]); + }); + }); + + describe('throwError', () => { + it('throws error normally', () => { + expect(() => utils.throwError('test error')).to.throw('test error'); + }); + + it('throws error when testRun is false', () => { + expect(() => utils.throwError('test error', { testRun: false })).to.throw('test error'); + }); + + it('does not throw when testRun is true', () => { + expect(() => utils.throwError('test error', { testRun: true })).to.not.throw(); + }); + + it('returns undefined when testRun is true', () => { + const result = utils.throwError('test error', { testRun: true }); + expect(result).to.be.undefined; + }); + }); + + describe('standardizePath', () => { + it('normalizes consecutive slashes', () => { + const result = utils.standardizePath('/path//to///file'); + expect(result).to.equal('/path/to/file'); + }); + + it('normalizes backslashes to forward slashes', () => { + const result = utils.standardizePath('/path\\to\\file'); + expect(result).to.equal('/path/to/file'); + }); + + it('resolves relative parts', () => { + const result = utils.standardizePath('/path/to/../file'); + expect(result).to.equal('/path/file'); + }); + + it('caches results', () => { + const path1 = '/some/unique/path/1'; + const result1 = utils.standardizePath(path1); + const result2 = utils.standardizePath(path1); + expect(result1).to.equal(result2); + }); + + it('returns non-string values unchanged', () => { + const result = utils.standardizePath(null as any); + expect(result).to.be.null; + }); + + it('lowercases Windows drive letters', () => { + const result = utils.standardizePath('C:/path/to/file'); + expect(result).to.equal('c:/path/to/file'); + }); + }); + + describe('standardizePath tagged template', () => { + it('works as tagged template literal', () => { + const dir = 'some/dir'; + const file = 'file.txt'; + const result = standardizePath`${dir}/${file}`; + expect(result).to.equal('some/dir/file.txt'); + }); + }); + + describe('sleep', () => { + it('resolves after specified milliseconds', async () => { + const start = Date.now(); + await utils.sleep(50); + const elapsed = Date.now() - start; + expect(elapsed).to.be.at.least(40); // Allow some tolerance + }); + + it('is cancellable', () => { + const promise = utils.sleep(1000); + promise.cancel(); + // Should not wait 1000ms - this test should complete quickly + }); + }); +}); From f1a1d9c55460fa4023af2f28300c6e1392d45c85 Mon Sep 17 00:00:00 2001 From: Christian Holbrook Date: Wed, 29 Apr 2026 12:38:30 -0600 Subject: [PATCH 2/3] Fix test crash --- package.json | 12 ++---------- src/ReleaseCreator.spec.ts | 23 ----------------------- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index c5de766..8b6bcb3 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "build": "rimraf dist && tsc", "lint": "eslint \"src/**\"", "watch": "tsc --watch", - "test": "nyc mocha \"src/**/*spec.ts\"", - "test:nocover": "mocha \"src/**/*.spec.ts\"", + "test": "NODE_OPTIONS='--import tsx' nyc mocha \"src/**/*spec.ts\"", + "test:nocover": "NODE_OPTIONS='--import tsx' mocha \"src/**/*.spec.ts\"", "package": "npm run build && npm pack" }, "repository": { @@ -63,10 +63,6 @@ "typescript": "^4.7.2" }, "mocha": { - "require": [ - "source-map-support/register", - "ts-node/register" - ], "watchFiles": [ "src/**/*" ], @@ -83,10 +79,6 @@ "extension": [ ".ts" ], - "require": [ - "ts-node/register", - "source-map-support/register" - ], "reporter": [ "text-summary", "html" diff --git a/src/ReleaseCreator.spec.ts b/src/ReleaseCreator.spec.ts index 501e3cc..905f69f 100644 --- a/src/ReleaseCreator.spec.ts +++ b/src/ReleaseCreator.spec.ts @@ -7,7 +7,6 @@ import { ReleaseCreator } from './ReleaseCreator'; import { utils } from './utils'; import { ProjectManager } from './ProjectManager'; import { createMockRelease, createMockAsset, createMockProject } from './test-helpers'; -import * as fastGlob from 'fast-glob'; chai.use(chaiAsPromised); @@ -343,27 +342,5 @@ describe('ReleaseCreator', () => { }); }); - describe('makeReleaseArtifacts', () => { - it('fails if published release has assets without --force', async () => { - const mockProject = createMockProject(); - sinon.stub(ProjectManager, 'initialize').resolves(mockProject); - sinon.stub(utils, 'executeCommand'); - sinon.stub(releaseCreator as any, 'getVersion').resolves('1.0.0'); - sinon.stub(fastGlob, 'sync').returns(['artifact.tgz']); - - const mockRelease = createMockRelease({ draft: false }); - sinon.stub(releaseCreator as any, 'assertSingleRelease').resolves(mockRelease); - sinon.stub(utils, 'octokitPageHelper').resolves([createMockAsset()]); - - await expect( - releaseCreator.makeReleaseArtifacts({ - branch: 'release/1.0.0', - projectName: 'test-project', - artifactPaths: '*.tgz', - force: false - }) - ).to.be.rejectedWith(/already published with assets/); - }); - }); }); From 916e8c76ba2d3533a8f2fb97f3958dc90ee3d3d8 Mon Sep 17 00:00:00 2001 From: Christian Holbrook Date: Wed, 6 May 2026 11:50:20 -0600 Subject: [PATCH 3/3] Merge master andd fix unit tests --- src/ProjectManager.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ProjectManager.spec.ts b/src/ProjectManager.spec.ts index aad6dde..98b675a 100644 --- a/src/ProjectManager.spec.ts +++ b/src/ProjectManager.spec.ts @@ -106,28 +106,29 @@ describe('ProjectManager', () => { describe('ProjectDependency class', () => { it('initializes with correct values', () => { - const dep = new ProjectDependency('pkg', 'repo', '1.0.0', '1.1.0'); + const dep = new ProjectDependency('pkg', 'repo', '1.0.0', '1.1.0', 'https://github.com/test/repo'); expect(dep.name).to.equal('pkg'); expect(dep.repoName).to.equal('repo'); expect(dep.previousReleaseVersion).to.equal('1.0.0'); expect(dep.newVersion).to.equal('1.1.0'); + expect(dep.repositoryUrl).to.equal('https://github.com/test/repo'); }); it('hasChanged returns truthy when versions differ', () => { - const dep = new ProjectDependency('pkg', 'repo', '1.0.0', '1.1.0'); + const dep = new ProjectDependency('pkg', 'repo', '1.0.0', '1.1.0', 'https://github.com/test/repo'); expect(dep.hasChanged()).to.be.ok; }); it('hasChanged returns falsy when versions are the same', () => { - const dep = new ProjectDependency('pkg', 'repo', '1.0.0', '1.0.0'); + const dep = new ProjectDependency('pkg', 'repo', '1.0.0', '1.0.0', 'https://github.com/test/repo'); expect(dep.hasChanged()).to.not.be.ok; }); it('hasChanged returns falsy for invalid semver', () => { - const dep = new ProjectDependency('pkg', 'repo', 'invalid', '1.0.0'); + const dep = new ProjectDependency('pkg', 'repo', 'invalid', '1.0.0', 'https://github.com/test/repo'); expect(dep.hasChanged()).to.not.be.ok; });