diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/Constants.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/Constants.kt index cff460f03c2c..6f4380c3ef83 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/Constants.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/Constants.kt @@ -105,12 +105,12 @@ object Constants { const val gitlab = "gitlab" const val google = "google" const val abort = "abort" - const val reload = "reload" const val `web-sign-in-token` = "web-sign-in-token" const val getUserContext = "getUserContext" const val `show-search-result` = "show-search-result" const val reset = "reset" const val `attribution-search` = "attribution-search" + const val troubleshoot_reloadAuth = "troubleshoot/reloadAuth" const val `tree-sitter` = "tree-sitter" const val indentation = "indentation" const val Automatic = "Automatic" diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/FixupTaskID.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/FixupTaskID.kt new file mode 100644 index 000000000000..e3d95f1f8551 --- /dev/null +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/FixupTaskID.kt @@ -0,0 +1,5 @@ +@file:Suppress("FunctionName", "ClassName", "unused", "EnumEntryName", "UnusedImport") +package com.sourcegraph.cody.protocol_generated + +typealias FixupTaskID = String // One of: + diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/WebviewMessage.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/WebviewMessage.kt index cbfd63346f7d..3037b8627e78 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/WebviewMessage.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/WebviewMessage.kt @@ -37,13 +37,13 @@ sealed class WebviewMessage { "copy" -> context.deserialize(element, CopyWebviewMessage::class.java) "auth" -> context.deserialize(element, AuthWebviewMessage::class.java) "abort" -> context.deserialize(element, AbortWebviewMessage::class.java) - "reload" -> context.deserialize(element, ReloadWebviewMessage::class.java) "simplified-onboarding" -> context.deserialize<`simplified-onboardingWebviewMessage`>(element, `simplified-onboardingWebviewMessage`::class.java) "getUserContext" -> context.deserialize(element, GetUserContextWebviewMessage::class.java) "search" -> context.deserialize(element, SearchWebviewMessage::class.java) "show-search-result" -> context.deserialize<`show-search-resultWebviewMessage`>(element, `show-search-resultWebviewMessage`::class.java) "reset" -> context.deserialize(element, ResetWebviewMessage::class.java) "attribution-search" -> context.deserialize<`attribution-searchWebviewMessage`>(element, `attribution-searchWebviewMessage`::class.java) + "troubleshoot/reloadAuth" -> context.deserialize(element, Troubleshoot_reloadAuthWebviewMessage::class.java) else -> throw Exception("Unknown discriminator ${element}") } } @@ -316,15 +316,6 @@ data class AbortWebviewMessage( } } -data class ReloadWebviewMessage( - val command: CommandEnum? = null, // Oneof: reload -) : WebviewMessage() { - - enum class CommandEnum { - @SerializedName("reload") Reload, - } -} - data class `simplified-onboardingWebviewMessage`( val command: CommandEnum? = null, // Oneof: simplified-onboarding val onboardingKind: OnboardingKindEnum? = null, // Oneof: web-sign-in-token @@ -389,3 +380,12 @@ data class `attribution-searchWebviewMessage`( } } +data class Troubleshoot_reloadAuthWebviewMessage( + val command: CommandEnum? = null, // Oneof: troubleshoot/reloadAuth +) : WebviewMessage() { + + enum class CommandEnum { + @SerializedName("troubleshoot/reloadAuth") Troubleshoot_reloadAuth, + } +} + diff --git a/lib/shared/src/sourcegraph-api/graphql/client.ts b/lib/shared/src/sourcegraph-api/graphql/client.ts index 2251088ca3d1..8ac41a36ffa9 100644 --- a/lib/shared/src/sourcegraph-api/graphql/client.ts +++ b/lib/shared/src/sourcegraph-api/graphql/client.ts @@ -853,7 +853,7 @@ export class SourcegraphGraphQLAPIClient { // make an anonymous request to the Testing API private fetchSourcegraphTestingAPI(body: Record): Promise { - const url = 'http://localhost:49300/.api/testLogging' + const url = 'http://localhost:49300/.test/testLogging' const headers = new Headers({ 'Content-Type': 'application/json', }) diff --git a/lib/shared/src/sourcegraph-api/telemetry/MockServerTelemetryExporter.ts b/lib/shared/src/sourcegraph-api/telemetry/MockServerTelemetryExporter.ts index 36b828c749bc..a4cafb38d0c6 100644 --- a/lib/shared/src/sourcegraph-api/telemetry/MockServerTelemetryExporter.ts +++ b/lib/shared/src/sourcegraph-api/telemetry/MockServerTelemetryExporter.ts @@ -4,11 +4,11 @@ import { logError } from '../../logger' import { isError } from '../../utils' const MOCK_URL = 'http://localhost:49300' -const ENDPOINT = '/.api/mockEventRecording' +const ENDPOINT = '/.test/mockEventRecording' /** * MockServerTelemetryExporter exports events to a mock endpoint at - * http://localhost:49300/.api/mockEventRecording + * http://localhost:49300/.test/mockEventRecording */ export class MockServerTelemetryExporter implements TelemetryExporter { constructor(private anonymousUserID: string) {} diff --git a/vscode/.storybook/preview.ts b/vscode/.storybook/preview.ts new file mode 100644 index 000000000000..cc0815b14263 --- /dev/null +++ b/vscode/.storybook/preview.ts @@ -0,0 +1,28 @@ +// Replace your-framework with the framework you are using (e.g., react, vue3) +import type { Preview } from '@storybook/react' + +const preview: Preview = { + parameters: { + viewport: { + viewports: [ + { + name: 'VSCode Normal Sidebar', + styles: { width: '400px', height: '800px' }, + type: 'desktop', + }, + { + name: 'VSCode Wide Sidebar', + styles: { width: '700px', height: '800px' }, + type: 'desktop', + }, + { + name: 'VSCode Tall Sidebar', + styles: { width: '500px', height: '1200px' }, + type: 'desktop', + }, + ], + }, + }, +} + +export default preview diff --git a/vscode/CHANGELOG.md b/vscode/CHANGELOG.md index 13f587004628..7c4bd4fe7a27 100644 --- a/vscode/CHANGELOG.md +++ b/vscode/CHANGELOG.md @@ -17,6 +17,8 @@ This is a log of all notable changes to Cody for VS Code. [Unreleased] changes a - Generate Unit Tests: Fixed an issue where Cody would generate tests for the wrong code in the file. [pull/3759](https://github.com/sourcegraph/cody/pull/3759) - Chat: Fixed an issue where changing the chat model did not update the token limit for the model. [pull/3762](https://github.com/sourcegraph/cody/pull/3762) +- Troubleshoot: Don't show SignIn page if the authentication error is because of network connectivity issues [pull/3750](https://github.com/sourcegraph/cody/pull/3750) + - Edit: Large file warnings for @-mentions are now updated dynamically as you add or remove them. [pull/3767](https://github.com/sourcegraph/cody/pull/3767) - Generate Unit Tests: Improved quality for creating file names. [pull/3763](https://github.com/sourcegraph/cody/pull/3763) - Custom Commands: Fixed an issue where newly added custom commands were not working when clicked in the sidebar tree view. [pull/3804](https://github.com/sourcegraph/cody/pull/3804) diff --git a/vscode/src/chat/ContextProvider.ts b/vscode/src/chat/ContextProvider.ts index 734871c403ea..240fc030b0ca 100644 --- a/vscode/src/chat/ContextProvider.ts +++ b/vscode/src/chat/ContextProvider.ts @@ -164,7 +164,12 @@ export class ContextProvider implements vscode.Disposable, ContextStatusProvider await this.onConfigurationChange(newConfig) // When logged out, user's endpoint will be set to null const isLoggedOut = !authStatus.isLoggedIn && !authStatus.endpoint - const eventValue = isLoggedOut ? 'disconnected' : authStatus.isLoggedIn ? 'connected' : 'failed' + const isAuthError = authStatus?.showNetworkError || authStatus?.showInvalidAccessTokenError + const eventValue = isLoggedOut + ? 'disconnected' + : authStatus.isLoggedIn && !isAuthError + ? 'connected' + : 'failed' switch (ContextEvent.Auth) { case 'auth': telemetryService.log(`${logPrefix(newConfig.agentIDE)}:Auth:${eventValue}`, undefined, { diff --git a/vscode/src/chat/chat-view/SidebarViewController.ts b/vscode/src/chat/chat-view/SidebarViewController.ts index a26cc101ddb9..72b844957cfb 100644 --- a/vscode/src/chat/chat-view/SidebarViewController.ts +++ b/vscode/src/chat/chat-view/SidebarViewController.ts @@ -117,13 +117,6 @@ export class SidebarViewController implements vscode.WebviewViewProvider { await vscode.commands.executeCommand(`cody.auth.${message.authKind}`) break } - case 'reload': - await this.authProvider.reloadAuthStatus() - telemetryService.log('CodyVSCodeExtension:authReloadButton:clicked', undefined, { - hasV2Event: true, - }) - telemetryRecorder.recordEvent('cody.authReloadButton', 'clicked') - break case 'event': telemetryService.log(message.eventName, message.properties) break @@ -151,6 +144,25 @@ export class SidebarViewController implements vscode.WebviewViewProvider { case 'show-page': await vscode.commands.executeCommand('show-page', message.page) break + case 'troubleshoot/reloadAuth': { + await this.authProvider.reloadAuthStatus() + const nextAuth = this.authProvider.getAuthStatus() + telemetryService.log( + 'CodyVSCodeExtension:troubleshoot:reloadAuth', + { + success: Boolean(nextAuth?.isLoggedIn), + }, + { + hasV2Event: true, + } + ) + telemetryRecorder.recordEvent('cody.troubleshoot', 'reloadAuth', { + metadata: { + success: nextAuth.isLoggedIn ? 1 : 0, + }, + }) + break + } default: this.handleError(new Error('Invalid request type from Webview'), 'system') } diff --git a/vscode/src/chat/protocol.ts b/vscode/src/chat/protocol.ts index ae0362a0a067..39223210102c 100644 --- a/vscode/src/chat/protocol.ts +++ b/vscode/src/chat/protocol.ts @@ -71,7 +71,6 @@ export type WebviewMessage = authMethod?: AuthMethod } | { command: 'abort' } - | { command: 'reload' } | { command: 'simplified-onboarding' onboardingKind: 'web-sign-in-token' @@ -90,6 +89,9 @@ export type WebviewMessage = command: 'attribution-search' snippet: string } + | { + command: 'troubleshoot/reloadAuth' + } /** * A message sent from the extension host to the webview. diff --git a/vscode/src/services/AuthProvider.ts b/vscode/src/services/AuthProvider.ts index e78defd1bcba..b62fe70534d8 100644 --- a/vscode/src/services/AuthProvider.ts +++ b/vscode/src/services/AuthProvider.ts @@ -56,6 +56,10 @@ export class AuthProvider { public async init(): Promise { let lastEndpoint = localStorage?.getEndpoint() || this.config.serverEndpoint let token = (await secretStorage.get(lastEndpoint || '')) || this.config.accessToken + logDebug( + 'AuthProvider:init', + token?.trim() ? 'Token recovered from secretStorage' : 'No token found in secretStorage' + ) if (lastEndpoint === LOCAL_APP_URL.toString()) { // If the user last signed in to app, which talks to dotcom, try // signing them in to dotcom. diff --git a/vscode/src/services/SecretStorageProvider.ts b/vscode/src/services/SecretStorageProvider.ts index a22d64c5fe77..3c841a174ca1 100644 --- a/vscode/src/services/SecretStorageProvider.ts +++ b/vscode/src/services/SecretStorageProvider.ts @@ -19,6 +19,10 @@ export async function getAccessToken(): Promise { } } +export async function clearAccessToken(): Promise { + await secretStorage.delete(CODY_ACCESS_TOKEN_SECRET) +} + interface SecretStorage { get(key: string): Promise store(key: string, value: string): Promise @@ -120,12 +124,28 @@ export class VSCodeSecretStorage implements SecretStorage { } class InMemorySecretStorage implements SecretStorage { - private storage: Map - private callbacks: ((key: string) => Promise)[] - - constructor() { - this.storage = new Map() - this.callbacks = [] + private storage: Map = new Map() + private callbacks: ((key: string) => Promise)[] = [] + + constructor(initialState?: string | undefined, initialToken?: string | undefined) { + if (initialState) { + const parsedState = JSON.parse(initialState) + if (Array.isArray(parsedState)) { + for (const [key, value] of parsedState) { + this.storage.set(key, value) + } + } else { + throw new Error('Initial secret storage state must be an array of (key, value) entries') + } + } + if (initialToken) { + const parsedToken = JSON.parse(initialToken) + if (Array.isArray(parsedToken) && parsedToken.length === 2) { + this.storeToken(parsedToken[0], parsedToken[1]) + } else { + throw new Error('Initial token must be an array with [endpoint, value]') + } + } } public async get(key: string): Promise { @@ -202,5 +222,8 @@ interface ConfigJson { */ export const secretStorage = process.env.CODY_TESTING === 'true' || process.env.CODY_PROFILE_TEMP === 'true' - ? new InMemorySecretStorage() + ? new InMemorySecretStorage( + process.env.CODY_TESTING === 'true' ? process.env.TESTING_SECRET_STORAGE_STATE : undefined, + process.env.CODY_TESTING === 'true' ? process.env.TESTING_SECRET_STORAGE_TOKEN : undefined + ) : new VSCodeSecretStorage() diff --git a/vscode/src/services/StatusBar.ts b/vscode/src/services/StatusBar.ts index e5311c6fb19e..e8c5300753f4 100644 --- a/vscode/src/services/StatusBar.ts +++ b/vscode/src/services/StatusBar.ts @@ -263,11 +263,21 @@ export function createStatusBar(): CodyStatusBar { // Only show this if authStatus is present, otherwise you get a flash of // yellow status bar icon when extension first loads but login hasn't // initialized yet - if (authStatus && !authStatus.isLoggedIn) { - statusBarItem.text = '$(cody-logo-heavy) Sign In' - statusBarItem.tooltip = 'Sign in to get started with Cody' - statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground') - return + if (authStatus) { + if (authStatus.showNetworkError) { + statusBarItem.text = '$(cody-logo-heavy) Connection Issues' + statusBarItem.tooltip = 'Resolve network issues for Cody to work again' + // statusBarItem.color = new vscode.ThemeColor('statusBarItem.errorForeground') + statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground') + return + } + if (!authStatus.isLoggedIn) { + statusBarItem.text = '$(cody-logo-heavy) Sign In' + statusBarItem.tooltip = 'Sign in to get started with Cody' + // statusBarItem.color = new vscode.ThemeColor('statusBarItem.warningForeground') + statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground') + return + } } if (errors.length > 0) { diff --git a/vscode/test/e2e/auth.test.ts b/vscode/test/e2e/auth.test.ts index b7ac6018247c..24c370b6f84e 100644 --- a/vscode/test/e2e/auth.test.ts +++ b/vscode/test/e2e/auth.test.ts @@ -1,5 +1,4 @@ import { expect } from '@playwright/test' - import { SERVER_URL, VALID_TOKEN } from '../fixtures/mock-server' import { type ExpectedEvents, signOut, test } from './helpers' @@ -26,7 +25,7 @@ test.extend({ ], })('requires a valid auth token and allows logouts', async ({ page, sidebar }) => { await expect(page.getByText('Authentication failed.')).not.toBeVisible() - await sidebar.getByRole('button', { name: 'Sign In to Your Enterprise Instance' }).click() + await sidebar?.getByRole('button', { name: 'Sign In to Your Enterprise Instance' }).click() await page.getByRole('option', { name: 'Sign In with URL and Access Token' }).click() await page.getByRole('combobox', { name: 'input' }).fill(SERVER_URL) await page.getByRole('combobox', { name: 'input' }).press('Enter') @@ -35,7 +34,7 @@ test.extend({ await expect(page.getByRole('alert').getByText('Authentication failed.')).toBeVisible() - await sidebar.getByRole('button', { name: 'Sign In to Your Enterprise Instance' }).click() + await sidebar?.getByRole('button', { name: 'Sign In to Your Enterprise Instance' }).click() await page.getByRole('option', { name: 'Sign In with URL and Access Token' }).click() await page.getByRole('combobox', { name: 'input' }).fill(SERVER_URL) await page.getByRole('combobox', { name: 'input' }).press('Enter') diff --git a/vscode/test/e2e/common.ts b/vscode/test/e2e/common.ts index 26cc7f7dad14..4431a795609a 100644 --- a/vscode/test/e2e/common.ts +++ b/vscode/test/e2e/common.ts @@ -6,9 +6,12 @@ import { executeCommandInPalette } from './helpers' // Sign into Cody with valid auth from the sidebar export const sidebarSignin = async ( page: Page, - sidebar: Frame, + sidebar: Frame | null, enableNotifications = false ): Promise => { + if (sidebar === null) { + throw new Error('Sidebar is null, likely because preAuthenticate is `true`') + } await sidebar.getByRole('button', { name: 'Sign In to Your Enterprise Instance' }).click() await page.getByRole('option', { name: 'Sign In with URL and Access Token' }).click() await page.getByRole('combobox', { name: 'input' }).fill(SERVER_URL) @@ -21,6 +24,10 @@ export const sidebarSignin = async ( await disableNotifications(page) } + await expectAuthenticated(page) +} + +export async function expectAuthenticated(page: Page) { await expect(page.getByText('Chat alongside your code, attach files,')).toBeVisible() } diff --git a/vscode/test/e2e/helpers.ts b/vscode/test/e2e/helpers.ts index 520d1a6a2137..c530b75a4111 100644 --- a/vscode/test/e2e/helpers.ts +++ b/vscode/test/e2e/helpers.ts @@ -15,11 +15,18 @@ import { type Frame, type FrameLocator, type Page, expect, test as base } from ' import { _electron as electron } from 'playwright' import * as uuid from 'uuid' -import { MockServer, loggedEvents, resetLoggedEvents, sendTestInfo } from '../fixtures/mock-server' - +import { + MockServer, + SERVER_URL, + VALID_TOKEN, + loggedEvents, + resetLoggedEvents, + sendTestInfo, +} from '../fixtures/mock-server' + +import { expectAuthenticated } from './common' import { installVsCode } from './install-deps' import { buildCustomCommandConfigFile } from './utils/buildCustomCommands' - // Playwright test extension: The workspace directory to run the test in. export interface WorkspaceDirectory { workspaceDirectory: string @@ -40,6 +47,10 @@ export interface DotcomUrlOverride { dotcomUrl: string | undefined } +export interface TestConfiguration { + preAuthenticate?: true | false +} + // playwright test extension: Add expectedEvents to each test to compare against export interface ExpectedEvents { expectedEvents: string[] @@ -67,17 +78,26 @@ export const test = base .extend({ dotcomUrl: undefined, }) + .extend({ + preAuthenticate: false, + }) // By default, these events should always fire for each test .extend({ - expectedEvents: [ - 'CodyInstalled', - 'CodyVSCodeExtension:auth:clickOtherSignInOptions', - 'CodyVSCodeExtension:login:clicked', - 'CodyVSCodeExtension:auth:selectSigninMenu', - 'CodyVSCodeExtension:auth:fromToken', - 'CodyVSCodeExtension:Auth:connected', - ], + expectedEvents: async ({ preAuthenticate }, use) => + await use( + preAuthenticate + ? ['CodyInstalled'] + : [ + 'CodyInstalled', + 'CodyVSCodeExtension:auth:clickOtherSignInOptions', + 'CodyVSCodeExtension:login:clicked', + 'CodyVSCodeExtension:auth:selectSigninMenu', + 'CodyVSCodeExtension:auth:fromToken', + 'CodyVSCodeExtension:Auth:connected', + ] + ), }) + .extend<{ server: MockServer }>({ // biome-ignore lint/correctness/noEmptyPattern: Playwright ascribes meaning to the empty pattern: No dependencies. server: async ({}, use) => { @@ -95,6 +115,7 @@ export const test = base dotcomUrl, server: MockServer, expectedEvents, + preAuthenticate, }, use, testInfo @@ -125,12 +146,20 @@ export const test = base dotcomUrlOverride = { TESTING_DOTCOM_URL: dotcomUrl } } + //pre authenticated can ensure that a token is already set in the secret storage + let secretStorageState: { [key: string]: string } = {} + if (preAuthenticate) { + secretStorageState = { + TESTING_SECRET_STORAGE_TOKEN: JSON.stringify([SERVER_URL, VALID_TOKEN]), + } + } // See: https://github.com/microsoft/vscode-test/blob/main/lib/runTest.ts const app = await electron.launch({ executablePath: vscodeExecutablePath, env: { ...process.env, ...dotcomUrlOverride, + ...secretStorageState, CODY_TESTING: 'true', }, args: [ @@ -165,12 +194,12 @@ export const test = base // TODO(philipp-spiess): Figure out which playwright matcher we can use that works for // the signed-in and signed-out cases await new Promise(resolve => setTimeout(resolve, 500)) - - // Ensure we're signed out. - if (await page.isVisible('[aria-label="User Settings"]')) { + if (preAuthenticate) { + await expectAuthenticated(page) + } else if (await page.isVisible('[aria-label="User Settings"]')) { + // Ensure we're signed out. await signOut(page) } - await use(page) // Critical test to prevent event logging regressions. @@ -183,6 +212,7 @@ export const test = base console.log('Logged:', loggedEvents) throw error } + resetLoggedEvents() await app.close() @@ -196,10 +226,17 @@ export const test = base await rmSyncWithRetries(extensionsDirectory, { recursive: true }) }, }) - .extend<{ sidebar: Frame }>({ - sidebar: async ({ page }, use) => { - const sidebar = await getCodySidebar(page) - await use(sidebar) + .extend<{ sidebar: Frame | null; getCodySidebar: () => Promise }>({ + sidebar: async ({ page, preAuthenticate }, use) => { + if (preAuthenticate) { + await use(null) + } else { + const sidebar = await getCodySidebar(page) + await use(sidebar) + } + }, + getCodySidebar: async ({ page }, use) => { + await use(() => getCodySidebar(page)) }, }) /** diff --git a/vscode/test/e2e/local-embeddings.test.ts b/vscode/test/e2e/local-embeddings.test.ts index a102956c1b44..a17285f7f3e4 100644 --- a/vscode/test/e2e/local-embeddings.test.ts +++ b/vscode/test/e2e/local-embeddings.test.ts @@ -90,7 +90,7 @@ test.extend({ }) }, })('non-git repositories should explain lack of embeddings', async ({ page, sidebar }) => { - await sidebar.getByRole('button', { name: 'Sign In to Your Enterprise Instance' }).hover() + await sidebar?.getByRole('button', { name: 'Sign In to Your Enterprise Instance' }).hover() await openFile(page, 'main.c') await sidebarSignin(page, sidebar) // The Enhanced Context settings is opened on first chat by default @@ -106,7 +106,7 @@ test.extend({ }) test('git repositories without a remote should explain the issue', async ({ page, sidebar }) => { - await sidebar.getByRole('button', { name: 'Sign In to Your Enterprise Instance' }).hover() + await sidebar?.getByRole('button', { name: 'Sign In to Your Enterprise Instance' }).hover() await openFile(page, 'main.c') await sidebarSignin(page, sidebar) const chatFrame = await newChat(page) @@ -137,7 +137,7 @@ test 'CodyVSCodeExtension:chat-question:executed', ], })('should be able to index, then search, a git repository', async ({ page, sidebar }) => { - await sidebar.getByRole('button', { name: 'Sign In to Your Enterprise Instance' }).hover() + await sidebar?.getByRole('button', { name: 'Sign In to Your Enterprise Instance' }).hover() await openFile(page, 'main.c') await sidebarSignin(page, sidebar) const chatFrame = await newChat(page) diff --git a/vscode/test/e2e/troubleshooting-authConnection.test.ts b/vscode/test/e2e/troubleshooting-authConnection.test.ts new file mode 100644 index 000000000000..06d6063e7d81 --- /dev/null +++ b/vscode/test/e2e/troubleshooting-authConnection.test.ts @@ -0,0 +1,55 @@ +// import { expect } from '@playwright/test' + +// import { SERVER_URL, VALID_TOKEN } from '../fixtures/mock-server' +// import * as assert from 'node:assert' +// import { expect } from '@playwright/test' +// import { sidebarSignin } from './common' + +import assert from 'node:assert' +import { expect } from 'playwright/test' +import * as mockServer from '../fixtures/mock-server' +import { expectAuthenticated } from './common' +import { + type DotcomUrlOverride, + type ExpectedEvents, + type TestConfiguration, + executeCommandInPalette, + test as baseTest, +} from './helpers' + +const test = baseTest + .extend({ dotcomUrl: mockServer.SERVER_URL }) + .extend({ preAuthenticate: true }) + +test.extend({ + // list of events we expect this test to log, add to this list as needed + expectedEvents: [ + 'CodyInstalled', + 'CodyVSCodeExtension:CodySavedLogin:executed', + 'CodyVSCodeExtension:Auth:failed', + 'CodyVSCodeExtension:troubleshoot:reloadAuth', + 'CodyVSCodeExtension:Auth:connected', + ], +})('allow retrying on connection issues', async ({ page, getCodySidebar }) => { + // After Cody has loaded we prevent the server from accepting connections. On reloading + // Cody this will now cause a connection issue when checking the currentUser. + // After "fixing" the server Cody should be able to connect again. + + let res = await fetch(`${mockServer.SERVER_URL}/.test/connectionIssue/enable?issue=ECONNREFUSED`, { + method: 'POST', + }) + assert.equal(res.status, 200) + + await executeCommandInPalette(page, 'developer: reload window') + + await expect(page.getByText('connection issue', { exact: false })).toBeVisible({ + timeout: 10000, + }) + res = await fetch(`${mockServer.SERVER_URL}/.test/connectionIssue/disable`, { + method: 'POST', + }) + assert.equal(res.status, 200) + const sidebar = await getCodySidebar() + await sidebar.getByRole('button', { name: 'Retry Connection' }).click() + expectAuthenticated(page) +}) diff --git a/vscode/test/fixtures/mock-server.ts b/vscode/test/fixtures/mock-server.ts index 1885512f022b..95454a413b6d 100644 --- a/vscode/test/fixtures/mock-server.ts +++ b/vscode/test/fixtures/mock-server.ts @@ -153,18 +153,53 @@ export class MockServer { public static async run(around: (server: MockServer) => Promise): Promise { const app = express() const controller = new MockServer(app) - app.use(express.json()) + // Add connection issue middleware to simulate things going wrong. Right now it's very basic but we could extend this with specific + // network issue, latencies or errors we see in broken deployments to ensure we robustly handle them in the client. + const VALID_CONNECTION_ISSUES = ['ECONNREFUSED', 'ENOTFOUND'] as const + // this gets set by calling /.test/connectionIssues/enable\disable + let connectionIssue: (typeof VALID_CONNECTION_ISSUES)[number] | undefined = undefined + app.use((req, res, next) => { + if (connectionIssue && !req.url.startsWith('/.test')) { + switch (connectionIssue) { + default: { + //sending response like this prevents logging + res.statusMessage = connectionIssue + res.status(500) + res.send(connectionIssue) + } + } + } else { + next() + } + }) + app.post('/.test/connectionIssue/enable', (req, res) => { + // get the 'issue' field from the request body and check that it's one of the valid connectionIssues + const issue = req.query?.issue as unknown + if (issue && VALID_CONNECTION_ISSUES.includes(issue as any)) { + connectionIssue = issue as (typeof VALID_CONNECTION_ISSUES)[number] + res.sendStatus(200) + } else { + res.status(400).send( + `The issue <${issue}> must be one of [${VALID_CONNECTION_ISSUES.join(', ')}]` + ) + } + }) + app.post('/.test/connectionIssue/disable', (req, res) => { + connectionIssue = undefined + res.sendStatus(200) + }) + // endpoint which will accept the data that you want to send in that you will add your pubsub code - app.post('/.api/testLogging', (req, res) => { + app.post('/.test/testLogging', (req, res) => { void logTestingData('legacy', req.body) storeLoggedEvents(req.body) res.status(200) }) // matches @sourcegraph/cody-shared't work, so hardcode it here. - app.post('/.api/mockEventRecording', (req, res) => { + app.post('/.test/mockEventRecording', (req, res) => { const events = req.body as TelemetryEventInput[] for (const event of events) { void logTestingData('new', JSON.stringify(event)) @@ -253,7 +288,6 @@ export class MockServer { chatRateLimitPro = undefined res.sendStatus(200) }) - app.post('/.api/completions/code', (req, res) => { const OPENING_CODE_TAG = '' const request = req as MockRequest diff --git a/vscode/webviews/App.tsx b/vscode/webviews/App.tsx index 97bd808cdc8c..eb02c27085db 100644 --- a/vscode/webviews/App.tsx +++ b/vscode/webviews/App.tsx @@ -28,6 +28,7 @@ import { LoadingPage } from './LoadingPage' import type { View } from './NavBar' import { Notices } from './Notices' import { LoginSimplified } from './OnboardingExperiment' +import { ConnectionIssuesPage } from './Troubleshooting' import { type ChatModelContext, ChatModelContextProvider } from './chat/models/chatModelContext' import type { VSCodeWrapper } from './utils/VSCodeApi' import { updateDisplayPathEnvInfoForWebview } from './utils/displayPathEnvInfo' @@ -230,59 +231,71 @@ export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vsc return } - return ( -
- {view === 'login' || !authStatus.isLoggedIn || !userAccountInfo ? ( + if (authStatus.showNetworkError) { + return ( +
+ +
+ ) + } + + if (view === 'login' || !authStatus.isLoggedIn || !userAccountInfo) { + return ( +
- ) : ( - <> - {userHistory && } - {errorMessages && ( - - )} - {view === 'chat' && userHistory && ( - { - if (enabled !== enhancedContextEnabled) { - setEnhancedContextEnabled(enabled) - } - }, - onRemoveRemoteSearchRepo, - onShouldBuildSymfIndex, - }} - > - - - - - - - - - )} - +
+ ) + } + + return ( +
+ {userHistory && } + {errorMessages && } + {view === 'chat' && userHistory && ( + { + if (enabled !== enhancedContextEnabled) { + setEnhancedContextEnabled(enabled) + } + }, + onRemoveRemoteSearchRepo, + onShouldBuildSymfIndex, + }} + > + + + + + + + + )}
) diff --git a/vscode/webviews/Troubleshooting/ConnectionIssuesPage.module.css b/vscode/webviews/Troubleshooting/ConnectionIssuesPage.module.css new file mode 100644 index 000000000000..55f867999dc7 --- /dev/null +++ b/vscode/webviews/Troubleshooting/ConnectionIssuesPage.module.css @@ -0,0 +1,65 @@ +.container { + display: flex; + flex-direction: column; + height: 100%; + flex: 1 0; + color: var(--vscode-editor-foreground); +} + +.message { + margin: 0; +} + +ul.causes { + margin: 0; + padding: 0 0 0 1rem; + + & > li { + margin-bottom: 0.25rem; + } +} + +.message-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.content { + display: flex; + flex-direction: column; + padding: 1.25rem; + gap: 2rem; +} + +.icon { + display: flex; + + & > :global(i.codicon) { /* we need this specificity because codicons hardcodes the fontsize */ + display: block; + font-size: 32px; + } +} + +.actions { + display: flex; + flex-direction: column; + gap: 0.5rem 1rem; + max-width: 300px; + width: 100%; +} + +@media (width < 650px) { + .icon { + margin: 0 auto; + } + + .actions { + margin: 0 auto; + } +} + +.action-button { + flex: 1 1 auto; + min-width: max-content; +} diff --git a/vscode/webviews/Troubleshooting/ConnectionIssuesPage.story.tsx b/vscode/webviews/Troubleshooting/ConnectionIssuesPage.story.tsx new file mode 100644 index 000000000000..c74be9d01a33 --- /dev/null +++ b/vscode/webviews/Troubleshooting/ConnectionIssuesPage.story.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/cody-shared' +// import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/cody-shared' +import { VSCodeViewport } from '../storybook/VSCodeStoryDecorator' +// import type { VSCodeWrapper } from '../utils/VSCodeApi' +import { ConnectionIssuesPage } from './ConnectionIssuesPage' + +const meta: Meta = { + title: 'cody/Troubleshooting', + component: ConnectionIssuesPage, + decorators: [VSCodeViewport()], + parameters: { + layout: 'fullscreen', + }, + args: { + configuredEndpoint: 'https://sourcegraph.sourcegraph.com', + vscodeAPI: { + postMessage: () => {}, + }, + telemetryService: NOOP_TELEMETRY_SERVICE, + }, +} as Meta + +export default meta + +export const Default: StoryObj = {} diff --git a/vscode/webviews/Troubleshooting/ConnectionIssuesPage.tsx b/vscode/webviews/Troubleshooting/ConnectionIssuesPage.tsx new file mode 100644 index 000000000000..9ad3bd8ff639 --- /dev/null +++ b/vscode/webviews/Troubleshooting/ConnectionIssuesPage.tsx @@ -0,0 +1,82 @@ +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react' + +import type { TelemetryService } from '@sourcegraph/cody-shared' +import classNames from 'classnames' +import { useCallback, useState } from 'react' +import type { VSCodeWrapper } from '../utils/VSCodeApi' +import styles from './ConnectionIssuesPage.module.css' + +export const ConnectionIssuesPage: React.FunctionComponent< + React.PropsWithoutRef<{ + telemetryService: TelemetryService + vscodeAPI: VSCodeWrapper + configuredEndpoint: string | undefined | null + }> +> = ({ vscodeAPI, configuredEndpoint }) => { + const [cooldown, setCooldown] = useState(false) + const onRetry = useCallback(() => { + vscodeAPI.postMessage({ command: 'troubleshoot/reloadAuth' }) + + // we just set some visual indication here that something is happening. + setCooldown(true) + const cooldownTimeout = setTimeout(() => { + setCooldown(false) + }, 3000) + return () => { + setCooldown(false) + if (cooldownTimeout) { + clearTimeout(cooldownTimeout) + } + } + }, [vscodeAPI]) + + const onSignOut = useCallback(() => { + vscodeAPI.postMessage({ command: 'auth', authKind: 'signout' }) + }, [vscodeAPI]) + + return ( +
+
+
+ +
+
+

+ Cody could not start due to a possible connection issue. Possible causes: +

+
    +
  • You don't have internet access
  • +
  • + The configured endpoint{' '} + {configuredEndpoint && ( + + {configuredEndpoint} + + )}{' '} + is not reachable +
  • +
  • An internal error preventing the connection
  • +
+
+
+ + {cooldown ? 'Retrying...' : 'Retry Connection'} + + + Sign Out + +
+
+
+ ) +} diff --git a/vscode/webviews/Troubleshooting/index.ts b/vscode/webviews/Troubleshooting/index.ts new file mode 100644 index 000000000000..50eff7255094 --- /dev/null +++ b/vscode/webviews/Troubleshooting/index.ts @@ -0,0 +1 @@ +export { ConnectionIssuesPage } from './ConnectionIssuesPage' diff --git a/vscode/webviews/storybook/VSCodeStoryDecorator.module.css b/vscode/webviews/storybook/VSCodeStoryDecorator.module.css index 6a93e1739f02..08b34cbd9a29 100644 --- a/vscode/webviews/storybook/VSCodeStoryDecorator.module.css +++ b/vscode/webviews/storybook/VSCodeStoryDecorator.module.css @@ -38,3 +38,11 @@ vscode-button::part(control) { background-color: var(--vscode-sideBar-background); border: solid 1px var(--vscode-sideBar-border); } + +.container-viewport { + width: 100vw; + height: 100vh; + max-width: 100%; + max-height: 100%; + overflow: auto; +} diff --git a/vscode/webviews/storybook/VSCodeStoryDecorator.tsx b/vscode/webviews/storybook/VSCodeStoryDecorator.tsx index 537e2e7bc3c4..33a503b0510c 100644 --- a/vscode/webviews/storybook/VSCodeStoryDecorator.tsx +++ b/vscode/webviews/storybook/VSCodeStoryDecorator.tsx @@ -37,6 +37,12 @@ export const VSCodeSidebar: Decorator = VSCodeDecorator(styles.containerSidebar) */ export const VSCodeStandaloneComponent: Decorator = VSCodeDecorator(undefined) +/** + * A decorator that displays a story with VS Code theme colors applied and maximizes the viewport. + */ +export const VSCodeViewport: (style?: CSSProperties | undefined) => Decorator = style => + VSCodeDecorator(styles.containerViewport, style) + /** * A customizable decorator for components with VS Code theme colors applied. */