Repository: stateful/vscode-runme
Pull request #677 (2023-07-12 19:16:25)

Runme is getting panels
Bring panels (optional via auth) into the extension.


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7a550fa4..03f40225 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -156,6 +156,12 @@ jobs:
         if: ${{ github.event.inputs.releaseChannel == 'edge' }}
         run: node .github/scripts/updateEdgeVersion.js
 
+      - name: Deactivate panels for public releases
+        run: |
+          jq 'del( .contributes.viewsContainers ) | del( .contributes.views.runme )' package.json > _package.json
+          mv -f _package.json package.json
+        # if: ${{ github.event.inputs.releaseChannel == 'stable' }}
+
       # Linux X86
       - name: Package Extension Linux x86_64 (Edge)
         if: ${{ github.event.inputs.releaseChannel == 'edge' }}
diff --git a/__mocks__/vscode-telemetry.ts b/__mocks__/vscode-telemetry.ts
index 3fdee089..b536b44c 100644
--- a/__mocks__/vscode-telemetry.ts
+++ b/__mocks__/vscode-telemetry.ts
@@ -4,3 +4,8 @@ export const TelemetryReporter = {
   sendTelemetryEvent: vi.fn(),
   sendTelemetryErrorEvent: vi.fn()
 }
+
+export class TelemetryViewProvider {
+  constructor() {
+  }
+}
diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts
index acaf5fdc..63390209 100644
--- a/__mocks__/vscode.ts
+++ b/__mocks__/vscode.ts
@@ -87,6 +87,7 @@ export const window = {
   onDidChangeActiveNotebookEditor: vi.fn().mockReturnValue({ dispose: vi.fn() }),
   registerTreeDataProvider: vi.fn(),
   registerUriHandler: vi.fn(),
+  registerWebviewViewProvider: vi.fn(),
   onDidCloseTerminal: vi.fn(),
   withProgress: vi.fn(),
   onDidChangeActiveColorTheme: vi.fn().mockReturnValue({ dispose: vi.fn() }),
diff --git a/assets/runme-logo-sidebar.svg b/assets/runme-logo-sidebar.svg
new file mode 100644
index 00000000..73dc3255
--- /dev/null
+++ b/assets/runme-logo-sidebar.svg
@@ -0,0 +1,6 @@
+<svg width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path d="M16 1H2a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1Z" stroke="#fff"
+        stroke-width="1.2" />
+    <path d="M6.431 11.984V6.001c0-.073 6 2.92 6 2.992 0 .071-6 3.085-6 2.991z" fill="#fff"
+        stroke="#fff" stroke-width="1.2" />
+</svg>
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 98d55258..5a3e4d54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "runme",
-  "version": "1.8.0-edge.0",
+  "version": "1.8.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "runme",
-      "version": "1.8.0-edge.0",
+      "version": "1.8.0",
       "hasInstallScript": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -32,6 +32,7 @@
         "lit": "^2.7.4",
         "octokit": "^2.0.14",
         "preact": "^10.14.1",
+        "tangle": "^3.0.0",
         "undici": "^5.22.1",
         "uuid": "^9.0.0",
         "vercel": "^30.1.2",
diff --git a/package.json b/package.json
index ae8070c2..17091716 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "runme",
-  "version": "1.8.0-edge.0",
+  "version": "1.8.0",
   "publisher": "stateful",
   "displayName": "Runme runs Markdown",
   "description": "Execute commands inside your runbooks, docs, and READMEs.",
@@ -560,13 +560,31 @@
         }
       }
     ],
+    "viewsContainers": {
+      "activitybar": [
+        {
+          "id": "runme",
+          "title": "Runme",
+          "icon": "assets/runme-logo-sidebar.svg"
+        }
+      ]
+    },
     "views": {
       "explorer": [
         {
           "id": "runme.launcher",
+          "type": "tree",
           "name": "Runme Notebooks",
           "visibility": "collapsed"
         }
+      ],
+      "runme": [
+        {
+          "id": "runme.cloud",
+          "type": "webview",
+          "name": "Cloud",
+          "visibility": "visible"
+        }
       ]
     },
     "viewsWelcome": [
@@ -683,6 +701,7 @@
     "lit": "^2.7.4",
     "octokit": "^2.0.14",
     "preact": "^10.14.1",
+    "tangle": "^3.0.0",
     "undici": "^5.22.1",
     "uuid": "^9.0.0",
     "vercel": "^30.1.2",
diff --git a/src/client/components/terminal/index.ts b/src/client/components/terminal/index.ts
index e0ae9844..7c543892 100644
--- a/src/client/components/terminal/index.ts
+++ b/src/client/components/terminal/index.ts
@@ -437,7 +437,10 @@ export class TerminalView extends LitElement {
               this.isCloudApiLoading = false
               switch (answer) {
                 case MessageOptions.OpenLink: {
-                  return postClientMessage(ctx, ClientMessages.openExternalLink, this.shareUrl!)
+                  return postClientMessage(ctx, ClientMessages.openExternalLink, {
+                    link: this.shareUrl!,
+                    telemetryEvent: 'app.openLink',
+                  })
                 }
                 case MessageOptions.CopyToClipboard: {
                   return postClientMessage(ctx, ClientMessages.copyTextToClipboard, {
@@ -649,6 +652,7 @@ export class TerminalView extends LitElement {
       title: 'Share link created',
       options: Object.values(MessageOptions),
       uuid: this.uuid!,
+      telemetryEvent: 'app.share',
     })
   }
 
diff --git a/src/extension/extension.ts b/src/extension/extension.ts
index 202e83be..90927dd9 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -1,7 +1,8 @@
 import { Disposable, workspace, notebooks, commands, ExtensionContext, tasks, window } from 'vscode'
 import { TelemetryReporter } from 'vscode-telemetry'
+import Channel from 'tangle/webviews'
 
-import { Serializer } from '../types'
+import { Serializer, SyncSchema } from '../types'
 import { registerExtensionEnvironmentVariables } from '../utils/configuration'
 
 import { Kernel } from './kernel'
@@ -40,6 +41,7 @@ import { GrpcRunner, IRunner } from './runner'
 import { CliProvider } from './provider/cli'
 import * as survey from './survey'
 import { RunmeCodeLensProvider } from './provider/codelens'
+import Panel from './panels/panel'
 
 export class RunmeExtension {
   async initialize(context: ExtensionContext) {
@@ -118,6 +120,7 @@ export class RunmeExtension {
       kernel,
       serializer,
       server,
+      ...this.registerPanels(context),
       treeViewer,
       ...surveys,
       workspace.registerNotebookSerializer(Kernel.type, serializer, {
@@ -200,6 +203,15 @@ export class RunmeExtension {
     await bootFile()
   }
 
+  protected registerPanels(context: ExtensionContext): Disposable[] {
+    const id: string = 'runme.cloud' as const
+    const channel = new Channel<SyncSchema>('app')
+    const p = new Panel(context, id)
+    const bus$ = channel.register([p.webview])
+    p.registerBus(bus$)
+    return [window.registerWebviewViewProvider(id, p), p]
+  }
+
   static registerCommand(command: string, callback: (...args: any[]) => any, thisArg?: any) {
     return commands.registerCommand(
       command,
diff --git a/src/extension/kernel.ts b/src/extension/kernel.ts
index 01d34dfe..d00703e0 100644
--- a/src/extension/kernel.ts
+++ b/src/extension/kernel.ts
@@ -360,6 +360,9 @@ export class Kernel implements Disposable {
         kernel: this,
       })
     } else if (message.type === ClientMessages.optionsMessage) {
+      if (message.output.telemetryEvent) {
+        TelemetryReporter.sendTelemetryEvent(message.output.telemetryEvent)
+      }
       const answer = await window.showInformationMessage(
         message.output.title,
         ...message.output.options
@@ -374,7 +377,8 @@ export class Kernel implements Disposable {
         uuid: message.output.uuid,
       })
     } else if (message.type === ClientMessages.openExternalLink) {
-      return env.openExternal(Uri.parse(message.output))
+      TelemetryReporter.sendRawTelemetryEvent(message.output.telemetryEvent)
+      return env.openExternal(Uri.parse(message.output.link))
     } else if (message.type.startsWith('terminal:')) {
       return
     }
diff --git a/src/extension/messages/cloudApiRequest/saveCellExecution.ts b/src/extension/messages/cloudApiRequest/saveCellExecution.ts
index 87584117..a92b270f 100644
--- a/src/extension/messages/cloudApiRequest/saveCellExecution.ts
+++ b/src/extension/messages/cloudApiRequest/saveCellExecution.ts
@@ -1,11 +1,10 @@
-import { authentication } from 'vscode'
 import { TelemetryReporter } from 'vscode-telemetry'
 
-import { AuthenticationProviders, ClientMessages } from '../../../constants'
+import { ClientMessages } from '../../../constants'
 import { ClientMessage, IApiMessage } from '../../../types'
 import { InitializeClient } from '../../api/client'
 import { getCellByUuId } from '../../cell'
-import { getAnnotations, getCellRunmeId } from '../../utils'
+import { getAnnotations, getAuthSession, getCellRunmeId } from '../../utils'
 import { postClientMessage } from '../../../utils/messaging'
 import { RunmeService } from '../../services/runme'
 import { CreateCellExecutionDocument } from '../../__generated__/graphql'
@@ -20,13 +19,7 @@ export default async function saveCellExecution(
   const { messaging, message, editor } = requestMessage
 
   try {
-    const session = await authentication.getSession(
-      AuthenticationProviders.GitHub,
-      ['user:email'],
-      {
-        createIfNone: true,
-      }
-    )
+    const session = await getAuthSession()
 
     if (!session) {
       throw new Error('You must authenticate with your GitHub account')
@@ -51,7 +44,7 @@ export default async function saveCellExecution(
     const annotations = getAnnotations(cell)
     delete annotations['runme.dev/uuid']
     const runmeService = new RunmeService({ githubAccessToken: session.accessToken })
-    const runmeTokenResponse = await runmeService.getAccessToken()
+    const runmeTokenResponse = await runmeService.getUserToken()
     if (!runmeTokenResponse) {
       throw new Error('Unable to retrieve an access token')
     }
@@ -74,13 +67,13 @@ export default async function saveCellExecution(
         },
       },
     })
-    TelemetryReporter.sendTelemetryEvent('runme-app-share')
+    TelemetryReporter.sendTelemetryEvent('app.save')
     return postClientMessage(messaging, ClientMessages.cloudApiResponse, {
       data: result,
       uuid: message.output.uuid,
     })
   } catch (error) {
-    TelemetryReporter.sendTelemetryEvent('runme-app-error')
+    TelemetryReporter.sendTelemetryEvent('app.error')
     return postClientMessage(messaging, ClientMessages.cloudApiResponse, {
       data: (error as any).message,
       uuid: message.output.uuid,
diff --git a/src/extension/panels/panel.ts b/src/extension/panels/panel.ts
new file mode 100644
index 00000000..21e49e9d
--- /dev/null
+++ b/src/extension/panels/panel.ts
@@ -0,0 +1,126 @@
+import { Disposable, ExtensionContext, Webview, WebviewView, WebviewViewProvider } from 'vscode'
+import { TelemetryViewProvider } from 'vscode-telemetry'
+import { Subject } from 'rxjs'
+import { Observable, Subscription } from 'rxjs'
+
+import { fetchStaticHtml, getAuthSession } from '../utils'
+import { IAppToken, RunmeService } from '../services/runme'
+import { SyncSchemaBus } from '../../types'
+
+export type DefaultUx = 'panels'
+export interface InitPayload {
+  ide: 'code'
+  panelId: string
+  appToken: string | null
+  defaultUx: DefaultUx
+}
+
+class PanelBase extends TelemetryViewProvider implements Disposable {
+  protected readonly appUrl: string = 'http://localhost:3001'
+  protected readonly defaultUx: DefaultUx = 'panels'
+
+  constructor(protected readonly context: ExtensionContext) {
+    super()
+  }
+
+  public dispose() {}
+
+  public async getAppToken(createIfNone: boolean = true): Promise<IAppToken | null> {
+    const session = await getAuthSession(createIfNone)
+
+    if (session) {
+      const service = new RunmeService({ githubAccessToken: session.accessToken })
+      const userToken = await service.getUserToken()
+      return await service.getAppToken(userToken)
+    }
+
+    return null
+  }
+
+  public hydrateHtml(html: string, payload: InitPayload) {
+    let content = html
+    // eslint-disable-next-line quotes
+    content = html.replace(`'{ "appToken": null }'`, `'${JSON.stringify(payload)}'`)
+    content = content.replace(
+      '<script id="appAuthToken">',
+      `<base href="${this.appUrl}"><script id="appAuthToken">`
+    )
+    return content
+  }
+}
+
+export default class Panel extends PanelBase implements WebviewViewProvider {
+  public readonly webview = new Subject<Webview>()
+  protected readonly staticHtml
+
+  constructor(protected readonly context: ExtensionContext, public readonly identifier: string) {
+    super(context)
+
+    this.staticHtml = fetchStaticHtml(this.appUrl)
+  }
+
+  async resolveWebviewTelemetryView(webviewView: WebviewView): Promise<void> {
+    const webviewOptions = {
+      enableScripts: true,
+      retainContextWhenHidden: true,
+    }
+
+    console.log(`${this.identifier} webview resolving`)
+
+    const html = await this.getHydratedHtml()
+    webviewView.webview.html = html
+    webviewView.webview.options = {
+      ...webviewOptions,
+      localResourceRoots: [this.context.extensionUri],
+    }
+
+    this.webview.next(webviewView.webview)
+    console.log(`${this.identifier} webview resolved`)
+    return Promise.resolve()
+  }
+
+  private async getHydratedHtml(): Promise<string> {
+    let appToken: string | null
+    let staticHtml: string
+    try {
+      appToken = await this.getAppToken(false).then((appToken) => appToken?.token ?? null)
+      staticHtml = await this.staticHtml.then((r) => r.text())
+    } catch (err: any) {
+      console.error(err?.message || err)
+      throw err
+    }
+    return this.hydrateHtml(staticHtml, {
+      ide: 'code',
+      panelId: this.identifier,
+      appToken: appToken ?? 'EMPTY',
+      defaultUx: this.defaultUx,
+    })
+  }
+
+  // unnest existing type would be cleaner
+  private async onSignIn(bus: SyncSchemaBus) {
+    try {
+      const appToken = await this.getAppToken(true)
+      bus.emit('onAppToken', appToken!)
+    } catch (err: any) {
+      console.error(err?.message || err)
+    }
+  }
+
+  public registerBus(bus$: Observable<SyncSchemaBus>) {
+    bus$.subscribe((bus) => {
+      const subs: Subscription[] = [
+        bus.on('onCommand', (cmdEvent) => {
+          if (cmdEvent?.name !== 'signIn') {
+            return
+          }
+          this.onSignIn(bus)
+        }),
+      ]
+
+      return () => {
+        subs.forEach((s) => s.unsubscribe())
+      }
+    })
+  }
+}
diff --git a/src/extension/services/runme.ts b/src/extension/services/runme.ts
index 8a37ab23..5fd08b6a 100644
--- a/src/extension/services/runme.ts
+++ b/src/extension/services/runme.ts
@@ -2,7 +2,11 @@ import fetch from 'cross-fetch'
 
 import { getRunmeApiUrl } from '../../utils/configuration'
 
-export interface IRunmeToken {
+export interface IUserToken {
+  token: string
+}
+
+export interface IAppToken {
   token: string
 }
 
@@ -11,9 +15,10 @@ export class RunmeService {
   constructor({ githubAccessToken }: { githubAccessToken: string }) {
     this.githubAccessToken = githubAccessToken
   }
-  async getAccessToken(): Promise<IRunmeToken | undefined> {
+  async getUserToken(): Promise<IUserToken> {
+    let response
     try {
-      const response = await fetch(`${getRunmeApiUrl()}/auth/vscode`, {
+      response = await fetch(`${getRunmeApiUrl()}/auth/vscode`, {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json',
@@ -23,13 +28,36 @@ export class RunmeService {
         }),
       })
       if (response.status >= 400) {
-        throw new Error('Failed to get an authorization token')
+        throw new Error('Failed to get an user authorization token')
       }
-      if (response.ok) {
-        return response.json()
+      if (!response.ok) {
+        throw new Error('Request to user authorization endpoint failed')
       }
     } catch (error) {
       throw new Error((error as any).message)
     }
+
+    return response.json()
+  }
+  async getAppToken(userToken: IUserToken): Promise<IAppToken> {
+    let response
+    try {
+      response = await fetch(`${getRunmeApiUrl()}/auth/user/app`, {
+        method: 'POST',
+        headers: {
+          Authorization: `Bearer ${userToken.token}`,
+        },
+      })
+      if (response.status >= 400) {
+        throw new Error('Failed to get an app authorization token')
+      }
+      if (!response.ok) {
+        throw new Error('Request to app authorization endpoint failed')
+      }
+    } catch (error) {
+      throw new Error((error as any).message)
+    }
+
+    return response.json()
   }
 }
diff --git a/src/extension/utils.ts b/src/extension/utils.ts
index 5f52c0e0..452a8e3a 100644
--- a/src/extension/utils.ts
+++ b/src/extension/utils.ts
@@ -3,6 +3,7 @@ import util from 'node:util'
 import cp from 'node:child_process'
 import os from 'node:os'
 
+import { fetch } from 'cross-fetch'
 import vscode, {
   FileType,
   Uri,
@@ -14,6 +15,7 @@ import vscode, {
   commands,
   WorkspaceFolder,
   ExtensionContext,
+  authentication,
 } from 'vscode'
 import { v5 as uuidv5 } from 'uuid'
 import getPort from 'get-port'
@@ -27,7 +29,11 @@ import {
   ShellType,
 } from '../types'
 import { SafeCellAnnotationsSchema, CellAnnotationsSchema } from '../schema'
-import { NOTEBOOK_AVAILABLE_CATEGORIES, SERVER_ADDRESS } from '../constants'
+import {
+  AuthenticationProviders,
+  NOTEBOOK_AVAILABLE_CATEGORIES,
+  SERVER_ADDRESS,
+} from '../constants'
 import {
   getEnvLoadWorkspaceFiles,
   getEnvWorkspaceFileOrder,
@@ -554,3 +560,13 @@ export function convertEnvList(envs: string[]): Record<string, string | undefine
     return prev
   }, {} as Record<string, string | undefined>)
 }
+
+export function getAuthSession(createIfNone: boolean = true) {
+  return authentication.getSession(AuthenticationProviders.GitHub, ['user:email'], {
+    createIfNone,
+  })
+}
+
+export function fetchStaticHtml(appUrl: string) {
+  return fetch(appUrl)
+}
diff --git a/src/types.ts b/src/types.ts
index 4a546bd5..3f5255a6 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -9,6 +9,7 @@ import {
   NotebookEditor,
 } from 'vscode'
 import { z } from 'zod'
+import { Bus } from 'tangle'
 
 import { OutputType, ClientMessages } from './constants'
 import { SafeCellAnnotationsSchema } from './schema'
@@ -16,6 +17,17 @@ import type { IRunnerProgramSession } from './extension/runner'
 import type * as Grpc from './extension/grpc/serializerTypes'
 import { IWorkflowRun } from './extension/services/types'
 import { Kernel } from './extension/kernel'
+import { IAppToken } from './extension/services/runme'
+
+export interface SyncSchema {
+  onCommand?: {
+    panelId?: string
+    name: string
+  }
+  onAppToken?: IAppToken
+}
+
+export type SyncSchemaBus = Bus<SyncSchema>
 
 export namespace Serializer {
   export type Notebook = {
@@ -223,12 +235,16 @@ export interface ClientMessagePayload {
     title: string
     uuid: string
     options: any[]
+    telemetryEvent?: string
   }
   [ClientMessages.onOptionsMessage]: {
     uuid: string
     option: string | undefined
   }
-  [ClientMessages.openExternalLink]: string
+  [ClientMessages.openExternalLink]: {
+    link: string
+    telemetryEvent: string
+  }
   [ClientMessages.copyTextToClipboard]: {
     uuid: string
     text: string
diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts
index e82f7ef5..ffb51e0f 100644
--- a/src/utils/configuration.ts
+++ b/src/utils/configuration.ts
@@ -19,7 +19,7 @@ const APP_SECTION_NAME = 'runme.app'
 export const OpenViewInEditorAction = z.enum(['split', 'toggle'])
 export const DEFAULT_TLS_DIR = path.join(os.tmpdir(), 'runme', uuidv4(), 'tls')
 const DEFAULT_WORKSPACE_FILE_ORDER = ['.env.local', '.env']
-const DEFAULT_RUNME_APP_API_URL = 'https://app.runme.dev/graphql'
+const DEFAULT_RUNME_APP_API_URL = 'https://api.runme.dev/graphql'
 
 type NotebookTerminalValue = keyof typeof configurationSchema.notebookTerminal
 


Test patch:
diff --git a/tests/extension/extension.test.ts b/tests/extension/extension.test.ts
index 7dad08e6..ad285f30 100644
--- a/tests/extension/extension.test.ts
+++ b/tests/extension/extension.test.ts
@@ -12,6 +12,13 @@ import { testCertPEM, testPrivKeyPEM } from '../testTLSCert'
 vi.mock('vscode')
 vi.mock('vscode-telemetry')
 
+vi.mock('../../src/extension/panels/panel', () => {
+  class Panel {
+    registerBus = vi.fn()
+  }
+  return ({ default: Panel })
+})
+
 vi.mock('../../src/extension/grpc/client', () => {
   class MockedParserServiceClient {
     deserialize = vi.fn(() => {
diff --git a/tests/extension/panels/panel.test.ts b/tests/extension/panels/panel.test.ts
new file mode 100644
index 00000000..e214259e
--- /dev/null
+++ b/tests/extension/panels/panel.test.ts
@@ -0,0 +1,70 @@
+import { suite, test, expect, vi } from 'vitest'
+import { Uri, type ExtensionContext, type WebviewView } from 'vscode'
+
+import Panel from '../../../src/extension/panels/panel'
+
+vi.mock('vscode')
+vi.mock('vscode-telemetry')
+
+vi.mock('../../../src/extension/grpc/client', () => ({}))
+vi.mock('../../../src/extension/runner', () => ({}))
+
+vi.mock('../../../src/extension/utils', () => {
+  return {
+    fetchStaticHtml: vi.fn().mockResolvedValue({
+      text: vi
+        .fn()
+        .mockResolvedValue(
+          '<script id="appAuthToken">window.APP_STATE = JSON.parse(\'{ "appToken": null }\');</script>'
+        ),
+    }),
+  }
+})
+
+suite('Panel', () => {
+  const staticHtml =
+    '<script id="appAuthToken">window.APP_STATE = JSON.parse(\'{ "appToken": null }\');</script>'
+  const contextMock: ExtensionContext = {
+    extensionUri: Uri.parse('file:///Users/fakeUser/projects/vscode-runme'),
+  } as any
+  const view: WebviewView = { webview: { html: '' } } as any
+
+  test('hydrates HTML', () => {
+    const p = new Panel(contextMock, 'testing')
+    const hydrated = p.hydrateHtml(staticHtml, {
+      appToken: 'a.b.c',
+      ide: 'code',
+      panelId: 'main',
+      defaultUx: 'panels',
+    })
+
+    expect(hydrated).toContain('<base href="http://localhost:3001">')
+    expect(hydrated).toContain(
+      '{"appToken":"a.b.c","ide":"code","panelId":"main","defaultUx":"panels"}'
+    )
+  })
+
+  test('resovles authed', async () => {
+    const p = new Panel(contextMock, 'testing')
+    p.getAppToken = vi.fn().mockResolvedValue({ token: 'webview.auth.token' })
+
+    await p.resolveWebviewTelemetryView(view)
+
+    expect(view.webview.html).toContain('<base href="http://localhost:3001">')
+    expect(view.webview.html).toContain(
+      '{"ide":"code","panelId":"testing","appToken":"webview.auth.token","defaultUx":"panels"}'
+    )
+  })
+
+  test('resovles unauthed', async () => {
+    const p = new Panel(contextMock, 'testing')
+    p.getAppToken = vi.fn().mockResolvedValue(null)
+
+    await p.resolveWebviewTelemetryView(view)
+
+    expect(view.webview.html).toContain('<base href="http://localhost:3001">')
+    expect(view.webview.html).toContain(
+      '{"ide":"code","panelId":"testing","appToken":"EMPTY","defaultUx":"panels"}'
+    )
+  })
+})