Unverified Commit 8bcf25ca authored by Denys Mishunov's avatar Denys Mishunov
Browse files

feat: switched chat to GlDuoChat component

- Updated the build process to post-process the webview resources
- Removed 'loading' assistant message
parent ab184c65
Loading
Loading
Loading
Loading
+16 −9
Original line number Diff line number Diff line
@@ -45,6 +45,10 @@ describe('GitLabChatController', () => {
    }));
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('resolveWebviewView', () => {
    const webview = {} as Partial<vscode.WebviewView> as vscode.WebviewView;

@@ -92,26 +96,29 @@ describe('GitLabChatController', () => {
      });
    });

    it('sends updates of the view when API response is received', async () => {
    it('adds both the user prompt and the temporary assistant record', async () => {
      await controller.processNewUserRecord(record);

      expect(viewMock.updateRecord).toHaveBeenCalledWith(
      expect(viewMock.addRecord).toHaveBeenCalledTimes(2);
      expect(viewMock.addRecord.mock.calls[0][0]).toEqual(record);
      expect(viewMock.addRecord.mock.calls[1][0]).toEqual(
        expect.objectContaining({
          content: 'api response user',
          contentHtml: 'html api response user',
          state: 'ready',
          role: 'user',
          role: 'assistant',
          requestId: 'uniqueId',
          timestamp: Date.parse('2023-01-01 01:01:01'),
        }),
      );
    });

    it('sends updates of the view when API response is received', async () => {
      await controller.processNewUserRecord(record);

      expect(viewMock.updateRecord).toHaveBeenCalledWith(
        expect.objectContaining({
          content: 'api response assistant',
          contentHtml: 'html api response assistant',
          content: 'api response user',
          contentHtml: 'html api response user',
          state: 'ready',
          role: 'assistant',
          role: 'user',
          requestId: 'uniqueId',
          timestamp: Date.parse('2023-01-01 01:01:01'),
        }),
+1 −2
Original line number Diff line number Diff line
@@ -59,7 +59,6 @@ export class GitLabChatController implements vscode.WebviewViewProvider {
      default: {
        const responseRecord = new GitLabChatRecord({
          role: 'assistant',
          content: '...',
          state: 'pending',
          requestId: record.requestId,
        });
@@ -89,7 +88,7 @@ export class GitLabChatController implements vscode.WebviewViewProvider {
    record.update(actionResponse.aiAction);
  }

  private async refreshRecord(record: GitLabChatRecord) {
  private async processRecord(record: GitLabChatRecord) {
    if (!record.requestId) {
      throw Error('requestId must be present!');
    }
+86 −19
Original line number Diff line number Diff line
import * as vscode from 'vscode';
import { prepareWebviewSource } from './prepare_webview_source';
import * as helpers from './prepare_webview_source';

jest.mock('vscode');
jest.mock('../generate_secret', () => ({
  generateSecret: jest.fn().mockReturnValue('123'),
}));

describe('prepareWebViewSource', () => {
describe('helpers', () => {
  let context: vscode.ExtensionContext;
  let webview: vscode.Webview;
  const webviewKey = 'gitlab_duo_chat';

  beforeEach(() => {
    context = {
      extensionUri: vscode.Uri.file('/foo/bar'),
    } as Partial<vscode.ExtensionContext> as vscode.ExtensionContext;
    webview = {
      asWebviewUri: jest.fn().mockImplementation(url => url),
    } as Partial<vscode.Webview> as vscode.Webview;
  });

  describe('prepareWebviewSource', () => {
    const inputSource = `
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
      <meta http-equiv="Content-Security-Policy" content="img-src vscode-resource: https:; script-src 'nonce-{{nonce}}';">
        <meta http-equiv="Content-Security-Policy" content="img-src vscode-resource: data: https:; script-src 'nonce-{{nonce}}';">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>GitLab Workflow</title>
        <script crossorigin type="module" src="/gitlab_duo_chat/assets/app.js"></script>
        <link rel="stylesheet" href="/gitlab_duo_chat/assets/index.css">
        {{svg}}
      </head>
      <body>
        <div id="app"></div>
@@ -28,38 +44,89 @@ describe('prepareWebViewSource', () => {
    <html lang="en">
      <head>
        <meta charset="UTF-8">
      <meta http-equiv="Content-Security-Policy" content="img-src vscode-resource: https:; script-src 'nonce-123';">
        <meta http-equiv="Content-Security-Policy" content="img-src vscode-resource: data: https:; script-src 'nonce-123';">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>GitLab Workflow</title>
        <script nonce="123" crossorigin type="module" src="file:///foo/bar/webviews/gitlab_duo_chat/assets/app.js"></script>
        <link rel="stylesheet" href="file:///foo/bar/webviews/gitlab_duo_chat/assets/index.css">
        <svg>...</svg>
      </head>
      <body>
        <div id="app"></div>
      </body>
    </html>
    `;
  let context: vscode.ExtensionContext;
  let webview: vscode.Webview;

    beforeEach(() => {
    context = {
      extensionUri: vscode.Uri.file('/foo/bar'),
    } as Partial<vscode.ExtensionContext> as vscode.ExtensionContext;
    webview = {
      asWebviewUri: jest.fn().mockImplementation(url => url),
    } as Partial<vscode.Webview> as vscode.Webview;

    (vscode.workspace.fs.readFile as jest.Mock).mockImplementation(file => {
      if (file.toString() === `${context.extensionUri}/webviews/gitlab_duo_chat/index.html`) {
        return Promise.resolve(new TextEncoder().encode(inputSource));
      }
    });
      jest
        .mocked(vscode.workspace.fs.readFile)
        .mockResolvedValue(new TextEncoder().encode(inputSource));
    });

    it('returns WebView source with inserted nonce and assets', async () => {
    const result = await prepareWebviewSource(webview, context, 'gitlab_duo_chat');
      // spy on dependencies
      const prepareJavascriptResourceSpy = jest.spyOn(helpers, 'prepareJavascriptResource');
      const getIconsSvgContentSpy = jest.spyOn(helpers, 'getIconsSvgContent');

      // mock implementations
      prepareJavascriptResourceSpy.mockResolvedValue();
      getIconsSvgContentSpy.mockResolvedValue('<svg>...</svg>');

      const result = await helpers.prepareWebviewSource(webview, context, webviewKey);

      expect(result).toStrictEqual(expectedHTML);
      prepareJavascriptResourceSpy.mockRestore();
      getIconsSvgContentSpy.mockRestore();
    });

    it('handles errors', async () => {
      jest.mocked(vscode.workspace.fs.readFile).mockRejectedValue(new Error('Boom!'));

      await expect(helpers.prepareWebviewSource(webview, context, webviewKey)).rejects.toThrow(
        'Boom!',
      );
    });
  });

  describe('getIconsSvgContent', () => {
    it('reads SVG content', async () => {
      const svgContent = '<svg>...</svg>';
      jest
        .mocked(vscode.workspace.fs.readFile)
        .mockResolvedValue(new TextEncoder().encode(svgContent));

      const result = await helpers.getIconsSvgContent(context, webviewKey);

      expect(result).toBe(svgContent);
    });
  });

  describe('prepareJavascriptResource', () => {
    const newEmptyStateUri = vscode.Uri.parse('test://empty-state');
    const inputSource = `
      import emptyStateIcon from '/gitlab_duo_chat/assets/empty-activity-md.svg';
      import icons from '/gitlab_duo_chat/assets/icons.svg';
    `;
    const expectedResultJS = `
      import emptyStateIcon from '${newEmptyStateUri}';
      import icons from '';
    `;

    beforeEach(() => {
      jest
        .mocked(vscode.workspace.fs.readFile)
        .mockResolvedValue(new TextEncoder().encode(inputSource));
    });

    it('replaces image URIs in the script', async () => {
      jest.mocked(webview.asWebviewUri).mockReturnValue(newEmptyStateUri);

      await helpers.prepareJavascriptResource(webview, context, webviewKey);

      expect(vscode.workspace.fs.writeFile).toBeCalledWith(
        expect.any(vscode.Uri),
        new TextEncoder().encode(expectedResultJS),
      );
    });
  });
});
+75 −4
Original line number Diff line number Diff line
import * as vscode from 'vscode';
import { generateSecret } from '../generate_secret';
import { mapValues } from 'lodash';
import { mapValues, update } from 'lodash';

const webviewResourcePaths = {
  appScriptUri: 'assets/app.js',
  styleUri: 'assets/index.css',
} as const;

type WebviewResources = Record<keyof typeof webviewResourcePaths, vscode.Uri>;
const imageResourcesPaths = {
  emptyStateIconUri: 'assets/empty-activity-md.svg',
} as const;

const iconsPath = 'assets/icons.svg';

const getWebviewUri = (
  path: string,
@@ -25,8 +29,66 @@ const getWebviewResources = (
  webview: vscode.Webview,
  context: vscode.ExtensionContext,
  webviewKey: string,
  resources: Record<string, string>,
) => {
  return mapValues(webviewResourcePaths, path => getWebviewUri(path, webview, context, webviewKey));
  return mapValues(resources, path => getWebviewUri(path, webview, context, webviewKey));
};

/**
 * Prepares a JavaScript resource by updating the URIs of certain assets.
 * @param webview - The webview instance.
 * @param context - The extension context.
 * @param webviewKey - The key identifying the webview.
 * @returns A promise that resolves when the resource is prepared.
 */
export const prepareJavascriptResource = async (
  webview: vscode.Webview,
  context: vscode.ExtensionContext,
  webviewKey: string,
): Promise<void> => {
  const { emptyStateIconUri } = getWebviewResources(
    webview,
    context,
    webviewKey,
    imageResourcesPaths,
  );

  const fileUri = vscode.Uri.joinPath(
    context.extensionUri,
    'webviews',
    webviewKey,
    webviewResourcePaths.appScriptUri,
  );
  const contentArray = await vscode.workspace.fs.readFile(fileUri);
  const fileContent = new TextDecoder().decode(contentArray);

  const updatedContent = fileContent
    .replace(/\/gitlab_duo_chat\/assets\/icons\.svg/g, '') // this is to make sure we reference icons only with hashes, without the file path
    .replace(/\/gitlab_duo_chat\/assets\/empty-activity-md\.svg/g, emptyStateIconUri.toString());

  return await vscode.workspace.fs.writeFile(fileUri, new TextEncoder().encode(updatedContent));
};

/**
 * Asynchronously retrieves the content of an SVG file located at the `iconsPath`
 * within the extension's `webviews` directory, and returns the content as a string.
 *
 * This function leverages the VS Code's FileSystem API to read the file.
 *
 * @param {vscode.ExtensionContext} context - The extension context.
 *
 * @param {string} webviewKey - The key identifying the webview.
 *
 * @returns {Promise<string>} A promise that resolves to a string containing the content
 * of the SVG file, or rejects if an error occurs during file read.
 */
export const getIconsSvgContent = async (
  context: vscode.ExtensionContext,
  webviewKey: string,
): Promise<string> => {
  const svgIconsUri = vscode.Uri.joinPath(context.extensionUri, 'webviews', webviewKey, iconsPath);
  const svgContentArray = await vscode.workspace.fs.readFile(svgIconsUri);
  return new TextDecoder().decode(svgContentArray);
};

export const prepareWebviewSource = async (
@@ -34,14 +96,23 @@ export const prepareWebviewSource = async (
  context: vscode.ExtensionContext,
  webviewKey: string,
): Promise<string> => {
  await prepareJavascriptResource(webview, context, webviewKey);
  const nonce = generateSecret();

  const { appScriptUri, styleUri } = getWebviewResources(webview, context, webviewKey);
  const { appScriptUri, styleUri } = getWebviewResources(
    webview,
    context,
    webviewKey,
    webviewResourcePaths,
  );
  const svgFileContent = await getIconsSvgContent(context, webviewKey);

  const fileUri = vscode.Uri.joinPath(context.extensionUri, 'webviews', webviewKey, 'index.html');
  const contentArray = await vscode.workspace.fs.readFile(fileUri);
  const fileContent = new TextDecoder().decode(contentArray);

  return fileContent
    .replace(/{{svg}}/, svgFileContent)
    .replace(/{{nonce}}/gm, nonce)
    .replace(/<script /g, `<script nonce="${nonce}" `)
    .replace(`/${webviewKey}/${webviewResourcePaths.styleUri}`, styleUri.toString())
+1 −0
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@
    />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>GitLab Duo Chat</title>
    {{svg}}
  </head>
  <body>
    <div id="app"></div>
Loading