Skip to content

Commit

Permalink
[AXON-31] URI handler rework & unit test setup
Browse files Browse the repository at this point in the history
* Add a new URI handler implemntation
* Add necessary config for unit-testing vscode api
* Decouple as much as possible
* Fork the initialization to put the new router under a feature flag
  • Loading branch information
sdzh-atlassian committed Dec 16, 2024
1 parent 3ced5bc commit 0375e02
Show file tree
Hide file tree
Showing 31 changed files with 980 additions and 35 deletions.
7 changes: 7 additions & 0 deletions __mocks__/vscode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* eslint-disable @typescript-eslint/no-require-imports */
module.exports = {
...require('jest-mock-vscode').createVSCodeMock(jest),
env: {
uriScheme: 'vscode'
}
}
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module.exports = {
roots: ['<rootDir>/src'],
roots: ['<rootDir>'],
testMatch: ['**/test/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'],
transform: {
'^.+\\.(min.js|ts|tsx)$': [
Expand Down
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1521,6 +1521,7 @@
"fork-ts-checker-webpack-plugin": "^9.0.2",
"html-webpack-plugin": "^5.6.0",
"jest": "^29.7.0",
"jest-mock-vscode": "^4.0.4",
"license-checker": "^25.0.1",
"mini-css-extract-plugin": "^2.9.1",
"npm-run-all": "^4.1.5",
Expand Down
2 changes: 1 addition & 1 deletion src/atlclients/authStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { Tokens } from './tokens';
import crypto from 'crypto';
import { keychain } from '../util/keychain';
import { loggedOutEvent } from '../analytics';
import { Container } from 'src/container';
import { Container } from '../container';
const keychainServiceNameV3 = version.endsWith('-insider') ? 'atlascode-insiders-authinfoV3' : 'atlascode-authinfoV3';

enum Priority {
Expand Down
2 changes: 1 addition & 1 deletion src/atlclients/oauthRefresher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AxiosUserAgent } from '../constants';
import { ConnectionTimeout } from '../util/time';
import { Container } from '../container';
import { Disposable } from 'vscode';
import { Logger } from 'src/logger';
import { Logger } from '../logger';
import { addCurlLogging } from './interceptors';
import { getAgent } from '../jira/jira-client/providers';
import { strategyForProvider } from './strategy';
Expand Down
54 changes: 35 additions & 19 deletions src/container.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AtlascodeUriHandler, ONBOARDING_URL, SETTINGS_URL } from './uriHandler';
import { LegacyAtlascodeUriHandler, ONBOARDING_URL, SETTINGS_URL } from './uriHandler/legacyUriHandler';
import { BitbucketIssue, BitbucketSite, PullRequest, WorkspaceRepo } from './bitbucket/model';
import { Disposable, ExtensionContext, UriHandler, env, workspace, UIKind } from 'vscode';
import { Disposable, ExtensionContext, env, workspace, UIKind } from 'vscode';
import { IConfig, configuration } from './config/configuration';

import { analyticsClient } from './analytics-node-client/src/client.min.js';
Expand Down Expand Up @@ -65,8 +65,9 @@ import { VSCWelcomeActionApi } from './webview/welcome/vscWelcomeActionApi';
import { VSCWelcomeWebviewControllerFactory } from './webview/welcome/vscWelcomeWebviewControllerFactory';
import { WelcomeAction } from './lib/ipc/fromUI/welcome';
import { WelcomeInitMessage } from './lib/ipc/toUI/welcome';
import { FeatureFlagClient } from './util/featureFlags';
import { FeatureFlagClient, Features } from './util/featureFlags';
import { EventBuilder } from './util/featureFlags/eventBuilder';
import { AtlascodeUriHandler } from './uriHandler';
import { CheckoutHelper } from './bitbucket/interfaces';

const isDebuggingRegex = /^--(debug|inspect)\b(-brk\b|(?!-))=?/;
Expand All @@ -86,14 +87,6 @@ export class Container {
enable: this.getAnalyticsEnable(),
});

FeatureFlagClient.initialize({
analyticsClient: this._analyticsClient,
identifiers: {
analyticsAnonymousId: env.machineId,
},
eventBuilder: new EventBuilder(),
});

this._cancellationManager = new Map();
this._analyticsApi = new VSCAnalyticsApi(this._analyticsClient, this.isRemote, this.isWebUI);
this._commonMessageHandler = new VSCCommonMessageHandler(this._analyticsApi, this._cancellationManager);
Expand Down Expand Up @@ -189,9 +182,6 @@ export class Container {

this._loginManager = new LoginManager(this._credentialManager, this._siteManager, this._analyticsClient);
this._bitbucketHelper = new BitbucketCheckoutHelper(context.globalState);
context.subscriptions.push(
(this._uriHandler = new AtlascodeUriHandler(this._analyticsApi, this._bitbucketHelper)),
);

if (config.jira.explorer.enabled) {
context.subscriptions.push((this._jiraExplorer = new JiraContext()));
Expand All @@ -206,13 +196,44 @@ export class Container {
}

context.subscriptions.push((this._helpExplorer = new HelpExplorer()));

FeatureFlagClient.initialize({
analyticsClient: this._analyticsClient,
identifiers: {
analyticsAnonymousId: env.machineId,
},
eventBuilder: new EventBuilder(),
}).then(() => {
this.initializeUriHandler(context, this._analyticsApi, this._bitbucketHelper);
});
}

static getAnalyticsEnable(): boolean {
const telemetryConfig = workspace.getConfiguration('telemetry');
return telemetryConfig.get<boolean>('enableTelemetry', true);
}

static initializeUriHandler(
context: ExtensionContext,
analyticsApi: VSCAnalyticsApi,
bitbucketHelper: CheckoutHelper,
) {
FeatureFlagClient.checkGate(Features.EnableNewUriHandler)
.then((enabled) => {
if (enabled) {
console.log('Using new URI handler');
context.subscriptions.push(AtlascodeUriHandler.create(analyticsApi, bitbucketHelper));
} else {
context.subscriptions.push(new LegacyAtlascodeUriHandler(analyticsApi, bitbucketHelper));
}
})
.catch((err) => {
// Not likely that we'd land here - but if anything goes wrong, default to legacy handler
console.error(`Error checking feature flag ${Features.EnableNewUriHandler}: ${err}`);
context.subscriptions.push(new LegacyAtlascodeUriHandler(analyticsApi, bitbucketHelper));
});
}

static initializeBitbucket(bbCtx: BitbucketContext) {
this._bitbucketContext = bbCtx;
this._pipelinesExplorer = new PipelinesExplorer(bbCtx);
Expand Down Expand Up @@ -288,11 +309,6 @@ export class Container {
this._context.globalState.update(ConfigTargetKey, target);
}

private static _uriHandler: UriHandler;
static get uriHandler() {
return this._uriHandler;
}

private static _version: string;
static get version() {
return this._version;
Expand Down
2 changes: 1 addition & 1 deletion src/jira/jira-client/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AgentProvider, getProxyHostAndPort, shouldTunnelHost } from '@atlassian
import axios, { AxiosInstance } from 'axios';
import * as fs from 'fs';
import * as https from 'https';
import { Logger } from 'src/logger';
import { Logger } from '../../logger';
import * as sslRootCas from 'ssl-root-cas';
import { DetailedSiteInfo, SiteInfo } from '../../atlclients/authInfo';
import { BasicInterceptor } from '../../atlclients/basicInterceptor';
Expand Down
47 changes: 47 additions & 0 deletions src/uriHandler/actions/checkoutBranch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Uri, window } from 'vscode';
import { CheckoutBranchUriHandlerAction } from './checkoutBranch';

describe('CheckoutBranchUriHandlerAction', () => {
const mockAnalyticsApi = {
fireDeepLinkEvent: jest.fn(),
};
const mockCheckoutHelper = {
checkoutRef: jest.fn().mockResolvedValue(true),
};
let action: CheckoutBranchUriHandlerAction;

beforeEach(() => {
jest.clearAllMocks();
action = new CheckoutBranchUriHandlerAction(mockCheckoutHelper as any, mockAnalyticsApi as any);
});

describe('handle', () => {
it('throws if required query params are missing', async () => {
await expect(action.handle(Uri.parse('https://some-uri/checkoutBranch'))).rejects.toThrow();
await expect(
action.handle(Uri.parse('https://some-uri/checkoutBranch?cloneUrl=...&ref=...')),
).rejects.toThrow();
await expect(
action.handle(Uri.parse('https://some-uri/checkoutBranch?cloneUrl=...&refType=...')),
).rejects.toThrow();
await expect(
action.handle(Uri.parse('https://some-uri/checkoutBranch?ref=...&refType=...')),
).rejects.toThrow();
});

it('checks out the branch and fires an event on success', async () => {
mockCheckoutHelper.checkoutRef.mockResolvedValue(true);
await action.handle(Uri.parse('https://some-uri/checkoutBranch?cloneUrl=one&ref=two&refType=three'));

expect(mockCheckoutHelper.checkoutRef).toHaveBeenCalledWith('one', 'two', 'three', '');
expect(mockAnalyticsApi.fireDeepLinkEvent).toHaveBeenCalled();
});

it('shows an error message on failure', async () => {
mockCheckoutHelper.checkoutRef.mockRejectedValue(new Error('oh no'));
await action.handle(Uri.parse('https://some-uri/checkoutBranch?cloneUrl=one&ref=two&refType=three'));

expect(window.showErrorMessage).toHaveBeenCalled();
});
});
});
53 changes: 53 additions & 0 deletions src/uriHandler/actions/checkoutBranch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Uri, window } from 'vscode';
import { isAcceptedBySuffix, UriHandlerAction } from '../uriHandlerAction';
import { CheckoutHelper } from '../../bitbucket/interfaces';
import { AnalyticsApi } from '../../lib/analyticsApi';
import { Logger } from '../../logger';

/**
* Use a deep link to checkout a branch
*
* Expected link:
* `vscode://atlassian.atlascode/checkoutBranch?cloneUrl=...&ref=...&refType=...`
*
* Query params:
* - `cloneUrl`: the clone URL of the repository
* - `ref`: the ref to check out
* - `refType`: the type of ref to check out
* - `sourceCloneUrl`: (optional) the clone URL of the source repository (for branches originating from a forked repo)
*/
export class CheckoutBranchUriHandlerAction implements UriHandlerAction {
constructor(
private bitbucketHelper: CheckoutHelper,
private analyticsApi: AnalyticsApi,
) {}

isAccepted(uri: Uri): boolean {
return isAcceptedBySuffix(uri, 'checkoutBranch');
}

async handle(uri: Uri) {
const query = new URLSearchParams(uri.query);
const cloneUrl = decodeURIComponent(query.get('cloneUrl') || '');
const sourceCloneUrl = decodeURIComponent(query.get('sourceCloneUrl') || ''); //For branches originating from a forked repo
const ref = query.get('ref');
const refType = query.get('refType');
if (!ref || !cloneUrl || !refType) {
throw new Error(`Query params are missing data: ${query}`);
}

try {
const success = await this.bitbucketHelper.checkoutRef(cloneUrl, ref, refType, sourceCloneUrl);

if (success) {
this.analyticsApi.fireDeepLinkEvent(
decodeURIComponent(query.get('source') || 'unknown'),
'checkoutBranch',
);
}
} catch (e) {
Logger.debug('error checkout out branch:', e);
window.showErrorMessage('Error checkout out branch (check log for details)');
}
}
}
45 changes: 45 additions & 0 deletions src/uriHandler/actions/cloneRepository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Uri, window } from 'vscode';
import { CloneRepositoryUriHandlerAction } from './cloneRepository';

describe('CloneRepositoryUriHandlerAction', () => {
const mockAnalyticsApi = {
fireDeepLinkEvent: jest.fn(),
};
const mockCheckoutHelper = {
cloneRepository: jest.fn(),
};
let action: CloneRepositoryUriHandlerAction;

beforeEach(() => {
jest.clearAllMocks();
action = new CloneRepositoryUriHandlerAction(mockCheckoutHelper as any, mockAnalyticsApi as any);
});

describe('isAccepted', () => {
it('only accepts URIs ending with cloneRepository', () => {
expect(action.isAccepted(Uri.parse('https://some-uri/cloneRepository'))).toBe(true);
expect(action.isAccepted(Uri.parse('https://some-uri/otherThing'))).toBe(false);
});
});

describe('handle', () => {
it('throws if required query params are missing', async () => {
await expect(action.handle(Uri.parse('https://some-uri/cloneRepository'))).rejects.toThrow();
});

it('clones the repo and fires an event on success', async () => {
mockCheckoutHelper.cloneRepository.mockResolvedValue(null);
await action.handle(Uri.parse('https://some-uri/cloneRepository?q=one'));

expect(mockCheckoutHelper.cloneRepository).toHaveBeenCalledWith('one');
expect(mockAnalyticsApi.fireDeepLinkEvent).toHaveBeenCalled();
});

it('shows an error message on failure', async () => {
mockCheckoutHelper.cloneRepository.mockRejectedValue(new Error('oh no'));
await action.handle(Uri.parse('https://some-uri/cloneRepository?q=one'));

expect(window.showErrorMessage).toHaveBeenCalled();
});
});
});
44 changes: 44 additions & 0 deletions src/uriHandler/actions/cloneRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Uri, window } from 'vscode';
import { isAcceptedBySuffix, UriHandlerAction } from '../uriHandlerAction';
import { CheckoutHelper } from '../../bitbucket/interfaces';
import { AnalyticsApi } from '../../lib/analyticsApi';
import { Logger } from '../../logger';

/**
* Use a deep link to clone a repository
*
* Expected link:
* `vscode://atlassian.atlascode/cloneRepository?q=...`
*
* Query params:
* - `q`: the clone URL of the repository
*/
export class CloneRepositoryUriHandlerAction implements UriHandlerAction {
constructor(
private bitbucketHelper: CheckoutHelper,
private analyticsApi: AnalyticsApi,
) {}

isAccepted(uri: Uri): boolean {
return isAcceptedBySuffix(uri, 'cloneRepository');
}

async handle(uri: Uri) {
const query = new URLSearchParams(uri.query);
const repoUrl = decodeURIComponent(query.get('q') || '');
if (!repoUrl) {
throw new Error(`Cannot parse clone URL from: ${query}`);
}

try {
await this.bitbucketHelper.cloneRepository(repoUrl);
this.analyticsApi.fireDeepLinkEvent(
decodeURIComponent(query.get('source') || 'unknown'),
'cloneRepository',
);
} catch (e) {
Logger.debug('error cloning repository:', e);
window.showErrorMessage('Error cloning repository (check log for details)');
}
}
}
Loading

0 comments on commit 0375e02

Please sign in to comment.