diff --git a/package.json b/package.json index 66cee9d460af7..597ddef65bc89 100644 --- a/package.json +++ b/package.json @@ -5661,6 +5661,16 @@ "title": "Refresh Repository Access", "category": "GitLens" }, + { + "command": "gitlens.plus.simulateSubscriptionState", + "title": "Simulate Subscription State (Debugging)", + "category": "GitLens" + }, + { + "command": "gitlens.plus.restoreSubscriptionState", + "title": "Restore Subscription State (Debugging)", + "category": "GitLens" + }, { "command": "gitlens.gk.switchOrganization", "title": "Switch Organization...", @@ -9792,6 +9802,14 @@ "command": "gitlens.plus.refreshRepositoryAccess", "when": "gitlens:enabled" }, + { + "command": "gitlens.plus.simulateSubscriptionState", + "when": "gitlens:enabled && gitlens:debugging" + }, + { + "command": "gitlens.plus.restoreSubscriptionState", + "when": "gitlens:enabled && gitlens:debugging" + }, { "command": "gitlens.gk.switchOrganization", "when": "gitlens:gk:hasOrganizations" diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 3e49dce7e0681..808d4f2a15332 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -1,4 +1,6 @@ export declare global { + declare const DEBUG: boolean; + export type PartialDeep = T extends Record ? { [K in keyof T]?: PartialDeep } : T; export type Optional = Omit & { [P in K]?: T[P] }; export type PickPartialDeep = Omit, K> & { [P in K]?: Partial }; diff --git a/src/constants.commands.ts b/src/constants.commands.ts index e569de38fa54b..3185450cb016f 100644 --- a/src/constants.commands.ts +++ b/src/constants.commands.ts @@ -151,6 +151,8 @@ export const enum Commands { PlusStartPreviewTrial = 'gitlens.plus.startPreviewTrial', PlusUpgrade = 'gitlens.plus.upgrade', PlusValidate = 'gitlens.plus.validate', + PlusSimulateSubscriptionState = 'gitlens.plus.simulateSubscriptionState', + PlusRestoreSubscriptionState = 'gitlens.plus.restoreSubscriptionState', QuickOpenFileHistory = 'gitlens.quickOpenFileHistory', RefreshLaunchpad = 'gitlens.launchpad.refresh', RefreshGraph = 'gitlens.graph.refresh', diff --git a/src/constants.storage.ts b/src/constants.storage.ts index 3381f3c55b91b..c25e929bcc8e9 100644 --- a/src/constants.storage.ts +++ b/src/constants.storage.ts @@ -112,11 +112,13 @@ export interface Stored { timestamp?: number; } +export type StoredGKLicenses = Partial>; + export interface StoredGKCheckInResponse { user: StoredGKUser; licenses: { - paidLicenses: Record; - effectiveLicenses: Record; + paidLicenses: StoredGKLicenses; + effectiveLicenses: StoredGKLicenses; }; } diff --git a/src/plus/gk/account/__debug__accountDebug.ts b/src/plus/gk/account/__debug__accountDebug.ts new file mode 100644 index 0000000000000..2bf9b325f57d1 --- /dev/null +++ b/src/plus/gk/account/__debug__accountDebug.ts @@ -0,0 +1,277 @@ +import { window } from 'vscode'; +import { Commands } from '../../../constants.commands'; +import { SubscriptionPlanId, SubscriptionState } from '../../../constants.subscription'; +import type { Container } from '../../../container'; +import { registerCommand } from '../../../system/vscode/command'; +import { configuration } from '../../../system/vscode/configuration'; +import type { GKCheckInResponse, GKLicenses, GKLicenseType, GKUser } from '../checkin'; +import { getSubscriptionFromCheckIn } from '../checkin'; +import { getPreviewTrialAndDays } from '../utils'; +import { getSubscriptionPlan } from './subscription'; +import type { SubscriptionService } from './subscriptionService'; + +class AccountDebug { + constructor( + private readonly container: Container, + private readonly subscriptionStub: { + getSession: () => SubscriptionService['_session']; + getSubscription: () => SubscriptionService['_subscription']; + onDidCheckIn: SubscriptionService['_onDidCheckIn']; + changeSubscription: SubscriptionService['changeSubscription']; + getStoredSubscription: SubscriptionService['getStoredSubscription']; + }, + ) { + this.container.context.subscriptions.push( + registerCommand(Commands.PlusSimulateSubscriptionState, () => this.simulateSubscriptionState()), + registerCommand(Commands.PlusRestoreSubscriptionState, () => this.restoreSubscriptionState()), + ); + } + + private async simulateSubscriptionState() { + if ( + !this.container.debugging || + this.subscriptionStub.getSession() == null || + this.subscriptionStub.getSubscription() == null + ) { + return; + } + + // Show a quickpick to select a subscription state to simulate + const picks: { label: string; state: SubscriptionState; reactivatedTrial?: boolean; expiredPaid?: boolean }[] = + [ + { label: 'Free', state: SubscriptionState.Free }, + { label: 'Free In Preview Trial', state: SubscriptionState.FreeInPreviewTrial }, + { label: 'Free Preview Trial Expired', state: SubscriptionState.FreePreviewTrialExpired }, + { label: 'Free+ In Trial', state: SubscriptionState.FreePlusInTrial }, + { + label: 'Free+ In Trial (Reactivated)', + state: SubscriptionState.FreePlusInTrial, + reactivatedTrial: true, + }, + { label: 'Free+ Trial Expired', state: SubscriptionState.FreePlusTrialExpired }, + { + label: 'Free+ Trial Reactivation Eligible', + state: SubscriptionState.FreePlusTrialReactivationEligible, + }, + { label: 'Paid', state: SubscriptionState.Paid }, + // TODO: Update this subscription state once we have a "paid expired" state availale + { label: 'Paid Expired', state: SubscriptionState.Paid, expiredPaid: true }, + { label: 'Verification Required', state: SubscriptionState.VerificationRequired }, + ]; + + const pick = await window.showQuickPick(picks, { + title: 'Simulate Subscription State', + placeHolder: 'Select the subscription state to simulate', + }); + if (pick == null) return; + const { state: subscriptionState, reactivatedTrial, expiredPaid } = pick; + + const organizations = (await this.container.organizations.getOrganizations()) ?? []; + let activeOrganizationId = configuration.get('gitKraken.activeOrganizationId') ?? undefined; + if (activeOrganizationId === '' || (activeOrganizationId == null && organizations.length === 1)) { + activeOrganizationId = organizations[0].id; + } + + const simulatedCheckInData: GKCheckInResponse = getSimulatedCheckInResponse( + { + id: this.subscriptionStub.getSubscription()?.account?.id ?? '', + name: '', + email: '', + status: subscriptionState === SubscriptionState.VerificationRequired ? 'pending' : 'activated', + createdDate: new Date().toISOString(), + }, + subscriptionState, + 'gitkraken_v1-pro', + { + organizationId: activeOrganizationId, + trial: { reactivatedTrial: reactivatedTrial }, + expiredPaid: expiredPaid, + }, + ); + this.subscriptionStub.onDidCheckIn.fire(); + let simulatedSubscription = getSubscriptionFromCheckIn( + simulatedCheckInData, + organizations, + activeOrganizationId, + ); + + if ( + subscriptionState === SubscriptionState.FreeInPreviewTrial || + subscriptionState === SubscriptionState.FreePreviewTrialExpired + ) { + simulatedSubscription = { + ...simulatedSubscription, + plan: { + ...simulatedSubscription.plan, + actual: getSubscriptionPlan( + SubscriptionPlanId.Free, + false, + 0, + undefined, + new Date(simulatedSubscription.plan.actual.startedOn), + ), + effective: getSubscriptionPlan( + SubscriptionPlanId.Free, + false, + 0, + undefined, + new Date(simulatedSubscription.plan.effective.startedOn), + ), + }, + }; + const { previewTrial: simulatedPreviewTrial } = getPreviewTrialAndDays(); + if (subscriptionState === SubscriptionState.FreePreviewTrialExpired) { + simulatedPreviewTrial.startedOn = new Date(Date.now() - 2000).toISOString(); + simulatedPreviewTrial.expiresOn = new Date(Date.now() - 1000).toISOString(); + } + + simulatedSubscription.previewTrial = simulatedPreviewTrial; + } + + this.subscriptionStub.changeSubscription( + { + ...this.subscriptionStub.getSubscription(), + ...simulatedSubscription, + }, + { store: false }, + ); + } + + private restoreSubscriptionState() { + if (!this.container.debugging || this.subscriptionStub.getSession() == null) return; + this.subscriptionStub.changeSubscription(this.subscriptionStub.getStoredSubscription(), { store: false }); + } +} + +function getSimulatedPaidLicenseResponse( + organizationId?: string | undefined, + type: GKLicenseType = 'gitkraken_v1-pro', + status: 'active' | 'cancelled' | 'non-renewing' = 'active', +): GKLicenses { + const oneYear = 365 * 24 * 60 * 60 * 1000; + const tenSeconds = 10 * 1000; + // start 10 seconds ago + let start = new Date(Date.now() - tenSeconds); + // end in 1 year + let end = new Date(start.getTime() + oneYear); + if (status === 'cancelled') { + // set start and end back 1 year + start = new Date(start.getTime() - oneYear); + end = new Date(end.getTime() - oneYear); + } + + return { + [type satisfies GKLicenseType]: { + latestStatus: status, + latestStartDate: start.toISOString(), + latestEndDate: end.toISOString(), + organizationId: organizationId, + reactivationCount: undefined, + nextOptInDate: undefined, + }, + }; +} + +function getSimulatedTrialLicenseResponse( + organizationId?: string, + type: GKLicenseType = 'gitkraken_v1-pro', + status: 'active-new' | 'active-reactivated' | 'expired' | 'expired-reactivatable' = 'active-new', + durationDays: number = 7, +): GKLicenses { + const tenSeconds = 10 * 1000; + const oneDay = 24 * 60 * 60 * 1000; + const duration = durationDays * oneDay; + const tenSecondsAgo = new Date(Date.now() - tenSeconds); + // start 10 seconds ago + let start = tenSecondsAgo; + // end using durationDays + let end = new Date(start.getTime() + duration); + if (status === 'expired' || status === 'expired-reactivatable') { + // set start and end back durationDays + start = new Date(start.getTime() - duration); + end = new Date(end.getTime() - duration); + } + + return { + [type satisfies GKLicenseType]: { + latestStatus: status, + latestStartDate: start.toISOString(), + latestEndDate: end.toISOString(), + organizationId: organizationId, + reactivationCount: status === 'active-reactivated' ? 1 : 0, + nextOptInDate: status === 'expired-reactivatable' ? tenSecondsAgo.toISOString() : undefined, + }, + }; +} + +function getSimulatedCheckInResponse( + user: GKUser, + targetSubscriptionState: SubscriptionState, + targetSubscriptionType: GKLicenseType = 'gitkraken_v1-pro', + // TODO: Remove 'expiredPaid' option and replace logic with targetSubscriptionState once we support a Paid Expired state + options?: { + organizationId?: string; + trial?: { reactivatedTrial?: boolean; durationDays?: number }; + expiredPaid?: boolean; + }, +): GKCheckInResponse { + const tenSecondsAgo = new Date(Date.now() - 10 * 1000); + const paidLicenseData = + targetSubscriptionState === SubscriptionState.Paid + ? // TODO: Update this line once we support a Paid Expired state + getSimulatedPaidLicenseResponse( + options?.organizationId, + targetSubscriptionType, + options?.expiredPaid ? 'cancelled' : 'active', + ) + : {}; + let trialLicenseStatus: 'active-new' | 'active-reactivated' | 'expired' | 'expired-reactivatable' = 'active-new'; + switch (targetSubscriptionState) { + case SubscriptionState.FreePlusTrialExpired: + trialLicenseStatus = 'expired'; + break; + case SubscriptionState.FreePlusTrialReactivationEligible: + trialLicenseStatus = 'expired-reactivatable'; + break; + case SubscriptionState.FreePlusInTrial: + trialLicenseStatus = options?.trial?.reactivatedTrial ? 'active-reactivated' : 'active-new'; + break; + } + const trialLicenseData = + targetSubscriptionState === SubscriptionState.FreePlusInTrial || + targetSubscriptionState === SubscriptionState.FreePlusTrialExpired || + targetSubscriptionState === SubscriptionState.FreePlusTrialReactivationEligible + ? getSimulatedTrialLicenseResponse( + options?.organizationId, + targetSubscriptionType, + trialLicenseStatus, + options?.trial?.durationDays, + ) + : {}; + return { + user: user, + licenses: { + paidLicenses: paidLicenseData, + effectiveLicenses: trialLicenseData, + }, + nextOptInDate: + targetSubscriptionState === SubscriptionState.FreePlusTrialReactivationEligible + ? tenSecondsAgo.toISOString() + : undefined, + }; +} + +export function registerAccountDebug( + container: Container, + subscriptionStub: { + getSession: () => SubscriptionService['_session']; + getSubscription: () => SubscriptionService['_subscription']; + onDidCheckIn: SubscriptionService['_onDidCheckIn']; + changeSubscription: SubscriptionService['changeSubscription']; + getStoredSubscription: SubscriptionService['getStoredSubscription']; + }, +): void { + if (!container.debugging) return; + + new AccountDebug(container, subscriptionStub); +} diff --git a/src/plus/gk/account/subscriptionService.ts b/src/plus/gk/account/subscriptionService.ts index 26898911f6bcb..02c42440911f2 100644 --- a/src/plus/gk/account/subscriptionService.ts +++ b/src/plus/gk/account/subscriptionService.ts @@ -29,7 +29,7 @@ import type { Source, TrackingContext } from '../../../constants.telemetry'; import type { Container } from '../../../container'; import { AccountValidationError, RequestsAreBlockedTemporarilyError } from '../../../errors'; import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; -import { createFromDateDelta, fromNow } from '../../../system/date'; +import { fromNow } from '../../../system/date'; import { gate } from '../../../system/decorators/gate'; import { debug, log } from '../../../system/decorators/log'; import { take } from '../../../system/event'; @@ -48,7 +48,7 @@ import { openUrl } from '../../../system/vscode/utils'; import type { GKCheckInResponse } from '../checkin'; import { getSubscriptionFromCheckIn } from '../checkin'; import type { ServerConnection } from '../serverConnection'; -import { ensurePlusFeaturesEnabled } from '../utils'; +import { ensurePlusFeaturesEnabled, getPreviewTrialAndDays } from '../utils'; import { LoginUriPathPrefix } from './authenticationConnection'; import { authenticationProviderScopes } from './authenticationProvider'; import type { Organization } from './organization'; @@ -171,6 +171,21 @@ export class SubscriptionService implements Disposable { ...this.registerCommands(), ); this.updateContext(); + if (DEBUG) { + void import(/* webpackChunkName: "__debug__" */ './__debug__accountDebug').then(m => + m.registerAccountDebug(this.container, { + getSession: () => { + return this._session; + }, + getSubscription: () => { + return this._subscription; + }, + onDidCheckIn: this._onDidCheckIn, + changeSubscription: this.changeSubscription.bind(this), + getStoredSubscription: this.getStoredSubscription.bind(this), + }), + ); + } } private onRepositoriesChanged(_e: RepositoriesChangeEvent): void { @@ -688,24 +703,8 @@ export class SubscriptionService implements Disposable { // Don't overwrite a trial that is already in progress if (isSubscriptionInProTrial(this._subscription)) return; - const startedOn = new Date(); - - let days: number; - let expiresOn = new Date(startedOn); - if (this.container.debugging) { - expiresOn = createFromDateDelta(expiresOn, { minutes: 1 }); - days = 0; - } else { - // Normalize the date to just before midnight on the same day - expiresOn.setHours(23, 59, 59, 999); - expiresOn = createFromDateDelta(expiresOn, { days: 3 }); - days = 3; - } - - previewTrial = { - startedOn: startedOn.toISOString(), - expiresOn: expiresOn.toISOString(), - }; + const { previewTrial: newPreviewTrial, days, startedOn, expiresOn } = getPreviewTrialAndDays(); + previewTrial = newPreviewTrial; this.changeSubscription({ ...this._subscription, @@ -1263,7 +1262,9 @@ export class SubscriptionService implements Disposable { this.container.telemetry.sendEvent(previous == null ? 'subscription' : 'subscription/changed', data); }); - void this.storeSubscription(subscription); + if (options?.store !== false) { + void this.storeSubscription(subscription); + } this._subscription = subscription; this._etag = Date.now(); diff --git a/src/plus/gk/checkin.ts b/src/plus/gk/checkin.ts index 74add1a3ed9e1..db5e507cb91e1 100644 --- a/src/plus/gk/checkin.ts +++ b/src/plus/gk/checkin.ts @@ -3,11 +3,13 @@ import type { Organization } from './account/organization'; import type { Subscription } from './account/subscription'; import { getSubscriptionPlan, getSubscriptionPlanPriority } from './account/subscription'; +export type GKLicenses = Partial>; + export interface GKCheckInResponse { readonly user: GKUser; readonly licenses: { - readonly paidLicenses: Record; - readonly effectiveLicenses: Record; + readonly paidLicenses: GKLicenses; + readonly effectiveLicenses: GKLicenses; }; readonly nextOptInDate?: string; } diff --git a/src/plus/gk/utils.ts b/src/plus/gk/utils.ts index 77140b34e4686..96b1ccce0ade1 100644 --- a/src/plus/gk/utils.ts +++ b/src/plus/gk/utils.ts @@ -1,5 +1,6 @@ import type { MessageItem } from 'vscode'; import { window } from 'vscode'; +import { createFromDateDelta } from '../../system/date'; import { configuration } from '../../system/vscode/configuration'; import { getContext } from '../../system/vscode/context'; @@ -24,3 +25,22 @@ export async function ensurePlusFeaturesEnabled(): Promise { await configuration.updateEffective('plusFeatures.enabled', true); return true; } + +export function getPreviewTrialAndDays() { + const startedOn = new Date(); + + let expiresOn = new Date(startedOn); + // Normalize the date to just before midnight on the same day + expiresOn.setHours(23, 59, 59, 999); + expiresOn = createFromDateDelta(expiresOn, { days: 3 }); + + return { + previewTrial: { + startedOn: startedOn.toISOString(), + expiresOn: expiresOn.toISOString(), + }, + days: 3, + startedOn: startedOn, + expiresOn: expiresOn, + }; +} diff --git a/src/webviews/apps/shared/context.ts b/src/webviews/apps/shared/context.ts index 4d7e873fa68fd..8b0791f7ff85a 100644 --- a/src/webviews/apps/shared/context.ts +++ b/src/webviews/apps/shared/context.ts @@ -5,8 +5,6 @@ import { getNewLogScope } from '../../../system/logger.scope'; import { padOrTruncateEnd } from '../../../system/string'; import type { HostIpc } from './ipc'; -declare const DEBUG: boolean; - export class LoggerContext { private readonly scope: LogScope; diff --git a/webpack.config.mjs b/webpack.config.mjs index ceea545685253..744a7cab3a074 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -68,6 +68,9 @@ function getExtensionConfig(target, mode, env) { */ const plugins = [ new CleanPlugin({ cleanOnceBeforeBuildPatterns: ['!dist/webviews/**'] }), + new DefinePlugin({ + DEBUG: mode === 'development', + }), new ForkTsCheckerPlugin({ async: false, formatter: 'basic',