diff --git a/src/gitExport.ts b/src/gitExport.ts index f480e7f..66b54ea 100644 --- a/src/gitExport.ts +++ b/src/gitExport.ts @@ -8,11 +8,11 @@ import { Uri, window, } from 'vscode'; -import { Octokit } from '@octokit/rest'; -import { Repository } from './repository'; -import { Distinct } from './openedRepository'; import { mkdir } from 'fs/promises'; +import { Octokit } from '@octokit/rest'; import { RequestError } from '@octokit/request-error'; +import type { Repository } from './repository'; +import type { Distinct } from './openedRepository'; const GITHUB_AUTH_PROVIDER_ID = 'github'; // https://fossil-scm.org/home/doc/trunk/www/mirrortogithub.md says @@ -32,38 +32,36 @@ export class Credentials { disposables.push( authentication.onDidChangeSessions(async e => { if (e.provider.id === GITHUB_AUTH_PROVIDER_ID) { - this.octokit = await this.tryCreateOctokit(); + await this.tryCreateOctokit(); } }) ); } if (!this.octokit) { - this.octokit = await this.tryCreateOctokit(); + await this.tryCreateOctokit(); } } - private async tryCreateOctokit( + /** @internal */ async tryCreateOctokit( createIfNone: boolean = false ): Promise { - const session = (this.session = await authentication.getSession( + const session = await authentication.getSession( GITHUB_AUTH_PROVIDER_ID, SCOPES, { createIfNone } - )); - - return session - ? new Octokit({ - auth: session.accessToken, - }) - : undefined; + ); + if (session) { + this.octokit = new Octokit({ + auth: session.accessToken, + }); + this.session = session; + return this.octokit; + } + return; } async getOctokit(): Promise { - if (this.octokit) { - return this.octokit; - } - this.octokit = await this.tryCreateOctokit(true); - return this.octokit!; + return this.octokit ?? (await this.tryCreateOctokit(true))!; } } @@ -93,25 +91,20 @@ export async function inputExportOptions( disposables: Disposable[] ): Promise { // ask: exportParentPath (hardest part, no explicit vscode API for this) - let exportParentPath = await window.showSaveDialog({ - title: - 'Parent path for intermediate git repository ' + - 'outside of fossil repository', - saveLabel: 'Select', - filters: { - Directories: [], // trying to select only directories - }, - }); + const exportParentPath = ( + await window.showOpenDialog({ + title: + 'Parent path for intermediate git repository ' + + 'outside of fossil repository', + openLabel: 'Select', + canSelectFiles: false, + canSelectFolders: true, + }) + )?.at(0); if (!exportParentPath) { return; } - if (exportParentPath.path.endsWith('.undefined')) { - // somewhat vscode bug - exportParentPath = exportParentPath.with({ - path: exportParentPath.path.slice(0, -10), - }); - } // ask: repository name const config = await repository.config( @@ -123,6 +116,8 @@ export async function inputExportOptions( prompt: 'The name of the new repository', ignoreFocusOut: true, value: config.get('short-project-name') || config.get('project-name'), + validateInput: text => + /^\w+$/.test(text) ? '' : 'Must be a single word', }); if (!name) { return; @@ -166,6 +161,16 @@ export async function inputExportOptions( // github option was chosen // so, we must authenticate await credentials.initialize(disposables); + const session = credentials.session; + if (!session) { + await window.showErrorMessage( + `No github session available, fossil won't export ${( + credentials as any + ).octokit!}` + ); + return; + } + const octokit = await credentials.getOctokit(); const userInfo = await octokit.users.getAuthenticated(); const orgs = await octokit.orgs.listForAuthenticatedUser(); @@ -207,12 +212,12 @@ export async function inputExportOptions( } // ask: privacy - const publicItem: QuickPickItem = { - label: '$(globe) Public', - }; const privateItem: QuickPickItem = { label: '$(lock) Private', }; + const publicItem: QuickPickItem = { + label: '$(globe) Public', + }; const selectedPrivacy = await window.showQuickPick( [privateItem, publicItem], { @@ -268,7 +273,6 @@ export async function inputExportOptions( } } // add token to url - const session = credentials.session!; const remoteUri = Uri.parse(response.data.html_url) as AutoPushURISafe; const remoteUriWithToken = remoteUri.with({ authority: `${session.account.label}:${session.accessToken}@${remoteUri.authority}`, @@ -283,10 +287,10 @@ export async function exportGit( ): Promise { await window.withProgress( { - title: `Creating $(github) repository ${options.url}`, + title: `Exporting git repository ${options.url}`, location: ProgressLocation.Notification, }, - async (progress): Promise => { + async (progress): Promise => { progress.report({ message: `Setting up fossil with ${options.url}`, increment: 33, @@ -295,6 +299,14 @@ export async function exportGit( name: 'Fossil git export', cwd: repository.root, }); + const terminalIsClosed = new Promise(ready => { + const dis = window.onDidCloseTerminal(closedTerminal => { + if (closedTerminal === terminal) { + dis.dispose(); + ready(); + } + }); + }); await mkdir(options.path, { recursive: true, mode: 0o700 }); terminal.sendText( // space at the start to skip history @@ -302,22 +314,16 @@ export async function exportGit( options.path } --mainbranch main --autopush ${options.urlUnsafe.toString()}` ); + terminal.show(); progress.report({ message: - '$(terminal) running export (manually close the terminal to finish)', + 'Running export (manually close the terminal to finish)', increment: 66, }); - await new Promise(ready => { - const dis = window.onDidCloseTerminal(closedTerminal => { - if (closedTerminal === terminal) { - progress.report({ - message: 'done', - increment: 100, - }); - ready(); - dis.dispose(); - } - }); + await terminalIsClosed; + progress.report({ + message: 'done', + increment: 100, }); } ); diff --git a/src/test/suite/common.ts b/src/test/suite/common.ts index 7106a1e..d4e3220 100644 --- a/src/test/suite/common.ts +++ b/src/test/suite/common.ts @@ -18,6 +18,11 @@ import { OpenedRepository, ResourceStatus } from '../../openedRepository'; import { FossilResourceGroup } from '../../resourceGroups'; import { delay } from '../../util'; +export type SinonStubT any> = sinon.SinonStub< + Parameters, + ReturnType +>; + export async function cleanRoot(): Promise { /* c8 ignore next 5 */ if (!vscode.workspace.workspaceFolders) { diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 2405883..a1561d0 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -18,6 +18,7 @@ import { PatchSuite, StageSuite, StashSuite, UpdateSuite } from './stateSuite'; import { RenameSuite } from './renameSuite'; import { BranchSuite } from './branchSuite'; import { RevertSuite } from './revertSuite'; +import { GetExportSuite as GitExportSuite } from './gitExportSuite'; suite('Fossil.OpenedRepo', function (this: Suite) { this.ctx.sandbox = sinon.createSandbox(); @@ -46,6 +47,7 @@ suite('Fossil.OpenedRepo', function (this: Suite) { suite('FileSystem', FileSystemSuite); suite('Diff', DiffSuite); suite('Quality of Life', QualityOfLifeSuite); + suite('Git Export', GitExportSuite); afterEach(() => { this.ctx.sandbox.restore(); diff --git a/src/test/suite/gitExportSuite.ts b/src/test/suite/gitExportSuite.ts new file mode 100644 index 0000000..4577c13 --- /dev/null +++ b/src/test/suite/gitExportSuite.ts @@ -0,0 +1,536 @@ +import { + authentication, + AuthenticationSession, + commands, + Disposable, + QuickPickItemKind, + Terminal, + Uri, + window, +} from 'vscode'; +import * as sinon from 'sinon'; +import { SinonStubT, fakeExecutionResult, getExecStub } from './common'; +import * as assert from 'assert/strict'; +import { Suite } from 'mocha'; +import { Credentials } from '../../gitExport'; +import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; +import { promises } from 'fs'; +import { RequestError } from '@octokit/request-error'; + +const useSpecified = { __sentinel: true } as const; + +function getValue( + value: string | undefined | typeof useSpecified, + specified: string | undefined +): string | undefined { + if (value == useSpecified) { + return specified; + } + return value as string | undefined; +} + +class GitExportTestHelper { + public readonly sod: SinonStubT; + public readonly sib: SinonStubT; + public readonly sqp: SinonStubT; + public readonly execStub: ReturnType; + public readonly fakeOctokit: ReturnType< + typeof GitExportTestHelper.prototype.createFakeOctokit + >; + public readonly getSessionStub: SinonStubT< + typeof authentication.getSession + >; + + public readonly configJson = JSON.stringify([ + { name: 'short-project-name', value: 'spn' }, + { name: 'project-name', value: 'pn' }, + { name: 'project-description', value: 'pd' }, + ]); + + constructor( + private readonly sandbox: sinon.SinonSandbox, + options: { + exportDirectory?: Uri[]; + repositoryName?: string | typeof useSpecified; + destination?: + | '$(github) Export to github' + | '$(globe) Export using git url'; + organization?: 'testUser' | 'testOrg'; + repositoryDescription?: string | typeof useSpecified; + private?: boolean; + userName?: 'mr. Test'; + orgDescription?: 'the great'; + gitUrl?: string; + configJson?: string; + createForAuthenticatedUser?: 'valid' | 'already exists' | 'error'; + } = {} + ) { + options = { + exportDirectory: [Uri.parse('file:///tmp/gitExport')], + repositoryName: useSpecified, + destination: '$(github) Export to github', + repositoryDescription: useSpecified, + organization: 'testUser', + private: true, + userName: 'mr. Test', + orgDescription: 'the great', + createForAuthenticatedUser: 'valid', + ...options, + }; + this.sod = sandbox + .stub(window, 'showOpenDialog') + .resolves(options.exportDirectory); + this.execStub = getExecStub(sandbox) + .withArgs(['sqlite', '--readonly']) + .resolves( + fakeExecutionResult({ + stdout: options.configJson ?? this.configJson, + }) + ); + this.sib = sandbox.stub(window, 'showInputBox'); + this.sib + .withArgs(sinon.match({ prompt: 'The name of the new repository' })) + .callsFake(o => + Promise.resolve(getValue(options.repositoryName, o!.value)) + ); + this.sib + .withArgs( + sinon.match({ prompt: 'Description of the new repository' }) + ) + .callsFake(o => + Promise.resolve( + getValue(options.repositoryDescription, o!.value) + ) + ); + this.sib + .withArgs(sinon.match({ prompt: 'The URL of an empty repository' })) + .resolves(options.gitUrl); + + this.sqp = sandbox + .stub(window, 'showQuickPick') + .onFirstCall() + .callsFake(items => { + assert.ok(items instanceof Array); + assert.equal(items.length, 2); + assert.equal(items[0].label, '$(github) Export to github'); + assert.equal(items[1].label, '$(globe) Export using git url'); + return Promise.resolve( + items.find(v => v.label == options.destination) + ); + }) + .onSecondCall() + .callsFake(items => { + assert.ok(items instanceof Array); + assert.equal(items.length, 3); + assert.equal(items[0].label, 'testUser'); + assert.equal( + items[0].description, + options.userName ? 'mr. Test' : '' + ); + assert.equal(items[1].label, 'Organizations'); + assert.equal(items[1].kind, QuickPickItemKind.Separator); + assert.equal(items[2].label, 'testOrg'); + assert.equal( + items[2].description, + options.orgDescription ? 'the great' : '' + ); + + return Promise.resolve( + items.find(v => v.label == options.organization) + ); + }) + .onThirdCall() + .callsFake(items => { + assert.ok(items instanceof Array); + assert.equal(items.length, 2); + assert.equal(items[0].label, '$(lock) Private'); + assert.equal(items[1].label, '$(globe) Public'); + return Promise.resolve( + (() => { + switch (options.private) { + case true: + return items[0]; + case false: + return items[1]; + } + return; + })() + ); + }); + + const fakeSession: AuthenticationSession = { + id: 'someId', + accessToken: 'fakeAccessToken', + scopes: ['repo'], + account: { + id: 'fakeHub', + label: 'fakeAccountLabel', + }, + }; + const tryCreateOctokitStub = sandbox.stub( + Credentials.prototype, + 'tryCreateOctokit' + ); + const fakeOctokit = (this.fakeOctokit = this.createFakeOctokit( + options.userName, + options.orgDescription, + options.createForAuthenticatedUser! + )); + const getSessionStub = (this.getSessionStub = sandbox + .stub(authentication, 'getSession') + .resolves(fakeSession)); + const originalOctokit = tryCreateOctokitStub.callsFake(async function ( + this: Credentials + ) { + sinon.assert.notCalled(getSessionStub); + const realOctoKitWithFakeToken = + await originalOctokit.wrappedMethod.apply(this); + sinon.assert.calledOnce(getSessionStub); + + sinon.assert.calledOnce(getSessionStub); + if (realOctoKitWithFakeToken) { + assert.equal(this.session, fakeSession); + (this as any).octokit = fakeOctokit; + return fakeOctokit as unknown as Octokit; + } else { + assert.ok(fakeSession); + } + return; + }); + + const getOctokitStub = sandbox + .stub(Credentials.prototype, 'getOctokit') + .callsFake(async function (this: Credentials) { + const realOctoKitWithFakeToken = + await getOctokitStub.wrappedMethod.apply(this); + assert.ok(realOctoKitWithFakeToken); + return fakeOctokit as unknown as Octokit; + }); + } + + fakeTerminal() { + const fakeTerminal = { + sendText: this.sandbox.stub(), + show: this.sandbox.stub(), + }; + fakeTerminal.show.callsFake(() => { + const closeTerminalCb = odct.args[0][0]; + closeTerminalCb(fakeTerminal as unknown as Terminal); + }); + this.sandbox + .stub(window, 'createTerminal') + .returns(fakeTerminal as unknown as Terminal); + + const fakeDisposable = this.sandbox.createStubInstance(Disposable); + const odct = this.sandbox + .stub(window, 'onDidCloseTerminal') + .callsFake((): any => { + return fakeDisposable; + }); + const mkdir = this.sandbox.stub(promises, 'mkdir').resolves(); + return { + fakeTerminal, + fakeDisposable, + odct, + mkdir, + }; + } + + stubShowErrorMessage() { + return this.sandbox + .stub(window, 'showErrorMessage') + .resolves('Continue' as any); + } + + private createFakeOctokit( + userName: 'mr. Test' | undefined, + orgDescription: 'the great' | undefined, + createForAuthenticatedUser: 'valid' | 'already exists' | 'error' + ) { + const fakeOctokit = { + users: { + getAuthenticated: this.sandbox.stub().resolves({ + data: { + login: 'testUser', + name: userName, + avatar_url: 'file://avatar.png', + }, + }), + }, + orgs: { + listForAuthenticatedUser: this.sandbox.stub().resolves({ + data: [ + { + login: 'testOrg', + description: orgDescription, + avatar_url: 'file://orgAvatar.png', + }, + ], + }), + }, + repos: { + createForAuthenticatedUser: this.sandbox.stub(), + createInOrg: this.sandbox + .stub() + .callsFake( + async ( + params: RestEndpointMethodTypes['repos']['createInOrg']['parameters'] + ) => ({ + data: { + html_url: `https://examplegit.com/${params.org}/${params.name}`, + }, + }) + ), + get: this.sandbox + .stub() + .callsFake( + async ( + params: RestEndpointMethodTypes['repos']['get']['parameters'] + ) => ({ + data: { + html_url: `https://examplegit.com/${params.owner}/${params.repo}`, + }, + }) + ), + }, + }; + switch (createForAuthenticatedUser) { + case 'valid': + fakeOctokit.repos.createForAuthenticatedUser.callsFake( + async ( + params: RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['parameters'] + ) => ({ + data: { + html_url: `https://examplegit.com/${params.name}`, + }, + }) + ); + break; + case 'already exists': + fakeOctokit.repos.createForAuthenticatedUser.rejects( + new RequestError('message', 401, { + response: { + data: { + message: 'already exists', + errors: [], + }, + status: 100, + url: 'url', + headers: {}, + }, + request: { + headers: {}, + method: 'POST', + url: 'url', + }, + }) + ); + break; + case 'error': + fakeOctokit.repos.createForAuthenticatedUser.rejects( + new Error('connection error') + ); + break; + } + return fakeOctokit; + } +} + +export function GetExportSuite(this: Suite): void { + test('No session', async () => { + // warning! must be first test + const helper = new GitExportTestHelper(this.ctx.sandbox); + helper.fakeTerminal(); + helper.getSessionStub.resolves(undefined); + const sem = this.ctx.sandbox.stub(window, 'showErrorMessage'); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnce(helper.getSessionStub); + sinon.assert.calledOnceWithMatch( + sem, + "No github session available, fossil won't export" + ); + }); + + test('Export to git (successful)', async () => { + const execStub = getExecStub(this.ctx.sandbox); + execStub.withArgs(['git', 'export']).resolves(fakeExecutionResult({})); + await commands.executeCommand('fossil.gitExport'); + sinon.assert.calledOnce(execStub); + }); + + test('Publish repository to github by user as public', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox); + const term = helper.fakeTerminal(); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledThrice(helper.sqp); + sinon.assert.calledOnce(term.mkdir); + sinon.assert.calledOnce(term.odct); + sinon.assert.calledOnce(term.fakeDisposable.dispose); + sinon.assert.calledOnce( + helper.fakeOctokit.repos.createForAuthenticatedUser + ); + }); + + test('Publish repository to git', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox, { + destination: '$(globe) Export using git url', + gitUrl: 'https://user:password@example.com/git/test', + }); + const term = helper.fakeTerminal(); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnce(helper.sqp); + sinon.assert.calledOnce(term.mkdir); + sinon.assert.calledOnce(term.odct); + sinon.assert.calledOnce(term.fakeDisposable.dispose); + sinon.assert.notCalled( + helper.fakeOctokit.repos.createForAuthenticatedUser + ); + const validateInput = helper.sib.firstCall.args[0]!['validateInput']!; + assert.equal(validateInput(''), 'Must be a single word'); + assert.equal(validateInput('name'), ''); + }); + + test('Publish repository to github by organization as private', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox, { + private: true, + organization: 'testOrg', + userName: undefined, + orgDescription: undefined, + }); + const term = helper.fakeTerminal(); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledThrice(helper.sqp); + sinon.assert.calledOnce(term.mkdir); + sinon.assert.calledOnce(term.odct); + sinon.assert.calledOnce(term.fakeDisposable.dispose); + sinon.assert.calledOnce(helper.fakeOctokit.repos.createInOrg); + }); + + test('Publish repository to github that already exists', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox, { + createForAuthenticatedUser: 'already exists', + }); + const term = helper.fakeTerminal(); + const sem = helper.stubShowErrorMessage(); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledThrice(helper.sqp); + sinon.assert.calledOnce(term.mkdir); + sinon.assert.calledOnce(term.odct); + sinon.assert.calledOnce(term.fakeDisposable.dispose); + sinon.assert.calledOnce( + helper.fakeOctokit.repos.createForAuthenticatedUser + ); + sinon.assert.calledOnce(sem); + }); + + test('Publish repository to github that already exists (cancel)', async () => { + this.ctx.sandbox.reset(); + const helper = new GitExportTestHelper(this.ctx.sandbox, { + createForAuthenticatedUser: 'already exists', + }); + const term = helper.fakeTerminal(); + const sem = helper.stubShowErrorMessage(); + sem.resolves(); // cancel action + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnce( + helper.fakeOctokit.repos.createForAuthenticatedUser + ); + sinon.assert.calledOnceWithMatch(sem, 'already exists:\n'); + sinon.assert.notCalled(term.mkdir); + sinon.assert.notCalled(term.fakeTerminal.sendText); + }); + + test('Publish repository to github with unknown error', async () => { + this.ctx.sandbox.reset(); + const helper = new GitExportTestHelper(this.ctx.sandbox, { + createForAuthenticatedUser: 'error', + }); + const term = helper.fakeTerminal(); + const sem = helper.stubShowErrorMessage(); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnce( + helper.fakeOctokit.repos.createForAuthenticatedUser + ); + sinon.assert.notCalled(term.fakeTerminal.sendText); + sinon.assert.calledOnceWithMatch( + sem, + 'Failed to create github repository: Error: connection error' + ); + }); + + test('Cancel export directory selection', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox, { + exportDirectory: undefined, + }); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnce(helper.sod); + }); + + test('Cancel repository name', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox, { + repositoryName: undefined, + }); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnce(helper.sod); + sinon.assert.calledOnce(helper.sib); + }); + + test('Cancel export git url', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox, { + destination: '$(globe) Export using git url', + gitUrl: undefined, + }); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnce(helper.sod); + sinon.assert.calledTwice(helper.sib); + }); + + test('Cancel destination (github/git)', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox, { + destination: undefined, + }); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnce(helper.sod); + sinon.assert.calledOnce(helper.sib); + sinon.assert.calledOnce(helper.sqp); + }); + + test('Cancel organization (github/git)', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox, { + organization: undefined, + }); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnce(helper.sod); + sinon.assert.calledOnce(helper.sib); + sinon.assert.calledTwice(helper.sqp); + }); + + test('Cancel description (github/git)', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox, { + repositoryDescription: undefined, + }); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnce(helper.sod); + sinon.assert.calledTwice(helper.sib); + sinon.assert.calledTwice(helper.sqp); + }); + + test('Cancel private (github/git)', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox, { + private: undefined, + }); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnce(helper.sod); + sinon.assert.calledTwice(helper.sib); + sinon.assert.calledThrice(helper.sqp); + }); + + test('Full project name can be used', async () => { + const helper = new GitExportTestHelper(this.ctx.sandbox, { + repositoryName: undefined, + configJson: JSON.stringify([{ name: 'project-name', value: 'pn' }]), + }); + await commands.executeCommand('fossil.gitPublish'); + sinon.assert.calledOnceWithMatch(helper.sib, { value: 'pn' }); + }); +}