Add unit tests for code_intelligence (#2396)

* Add unit tests for code_intelligence

Adds  a first set of jsdom-based tests for `handleCodeHost()`, testing the basic functionality of the browser extension (inject command palette, inject global debug palette, detect code views, decorate code views).

To allow this, some refactoring was done so that the extension controller and platform context can be injected in `handleCodeHost()`. `showGlobalDebug` is now also injected, instead of directly accessing localStorage in `GlobalDebug.tsx`.

This is just a starting point, these tests are not exhaustive.

* use window

* Clean up any

* use mutation-observer polyfill

* Properly mock platformContext

* Move mutation-observer module declaration into the types folder
This commit is contained in:
Loic Guychard 2019-03-01 12:54:48 +01:00 committed by GitHub
parent c56753b88e
commit 3da03efe69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 421 additions and 66 deletions

View File

@ -0,0 +1,300 @@
const RENDER = jest.fn()
jest.mock('react-dom', () => ({
createPortal: jest.fn(el => el),
render: RENDER,
unmountComponentAtNode: jest.fn(),
}))
import { uniqueId } from 'lodash'
import MutationObserver from 'mutation-observer'
import renderer from 'react-test-renderer'
import { from, NEVER, of, Subject, Subscription } from 'rxjs'
import { filter, map, skip, switchMap, take } from 'rxjs/operators'
import { Services } from '../../../../../shared/src/api/client/services'
import { Range } from '../../../../../shared/src/api/extension/types/range'
import { integrationTestContext } from '../../../../../shared/src/api/integration-test/testHelpers'
import { Controller } from '../../../../../shared/src/extensions/controller'
import { PlatformContextProps } from '../../../../../shared/src/platform/context'
import { isDefined } from '../../../../../shared/src/util/types'
import { FileInfo, handleCodeHost } from './code_intelligence'
const elementRenderedAtMount = (mount: Element): renderer.ReactTestRendererJSON | undefined => {
const call = RENDER.mock.calls.find(call => call[1] === mount)
return call && call[0]
}
jest.mock('uuid', () => ({
v4: () => 'uuid',
}))
const createMockController = (services: Services): Controller => ({
services,
notifications: NEVER,
executeCommand: jest.fn(),
unsubscribe: jest.fn(),
})
const createMockPlatformContext = (
partialMocks?: Partial<PlatformContextProps<'forceUpdateTooltip' | 'sideloadedExtensionURL' | 'urlToFile'>>
): PlatformContextProps<'forceUpdateTooltip' | 'sideloadedExtensionURL' | 'urlToFile'> => ({
platformContext: {
forceUpdateTooltip: jest.fn(),
urlToFile: jest.fn(),
sideloadedExtensionURL: new Subject<string | null>(),
...partialMocks,
},
})
describe('handleCodeHost()', () => {
beforeAll(() => {
// jsdom doesn't support MutationObserver or IntersectionObserver, so we need to mock them
;(window as any).MutationObserver = MutationObserver
;(window as any).IntersectionObserver = class {
constructor(
private callback: (entries: Pick<IntersectionObserverEntry, 'target' | 'isIntersecting'>[]) => void
) {}
public observe = (el: Element) => setTimeout(() => this.callback([{ isIntersecting: true, target: el }]), 0)
}
})
afterAll(() => {
delete (window as any).MutationObserver
delete (window as any).IntersectionObserver
})
let subscriptions = new Subscription()
afterEach(() => {
for (const el of document.querySelectorAll('.test')) {
el.remove()
}
RENDER.mockClear()
subscriptions.unsubscribe()
subscriptions = new Subscription()
})
const createTestElement = () => {
const el = document.createElement('div')
el.className = `test test-${uniqueId()}`
document.body.appendChild(el)
return el
}
test('renders the hoverlay container', async () => {
const { services } = await integrationTestContext()
subscriptions.add(
handleCodeHost({
codeHost: {
name: 'test',
check: () => true,
},
extensionsController: createMockController(services),
showGlobalDebug: false,
...createMockPlatformContext(),
})
)
const overlayMount = document.body.firstChild! as HTMLElement
expect(overlayMount.className).toBe('overlay-mount-container')
const renderedOverlay = elementRenderedAtMount(overlayMount)
expect(renderedOverlay).not.toBeUndefined()
})
test('renders the command palette if codeHost.getCommandPaletteMount is defined', async () => {
const { services } = await integrationTestContext()
const commandPaletteMount = createTestElement()
subscriptions.add(
handleCodeHost({
codeHost: {
name: 'test',
check: () => true,
getCommandPaletteMount: () => commandPaletteMount,
},
extensionsController: createMockController(services),
showGlobalDebug: false,
...createMockPlatformContext(),
})
)
const renderedCommandPalette = elementRenderedAtMount(commandPaletteMount)
expect(renderedCommandPalette).not.toBeUndefined()
})
test('creates a .global-debug element and renders the debug palette if showGlobalDebug is true', async () => {
const { services } = await integrationTestContext()
subscriptions.add(
handleCodeHost({
codeHost: {
name: 'test',
check: () => true,
},
extensionsController: createMockController(services),
showGlobalDebug: true,
...createMockPlatformContext(),
})
)
const globalDebugMount = document.querySelector('.global-debug')
expect(globalDebugMount).not.toBeUndefined()
const renderedDebugElement = elementRenderedAtMount(globalDebugMount!)
expect(renderedDebugElement).not.toBeUndefined()
})
test('renders the debug palette to the provided mount if codeHost.globalDebugMount is defined', async () => {
const { services } = await integrationTestContext()
const globalDebugMount = createTestElement()
subscriptions.add(
handleCodeHost({
codeHost: {
name: 'test',
check: () => true,
getGlobalDebugMount: () => globalDebugMount,
},
extensionsController: createMockController(services),
showGlobalDebug: true,
...createMockPlatformContext(),
})
)
const renderedDebugElement = elementRenderedAtMount(globalDebugMount)
expect(renderedDebugElement).not.toBeUndefined()
})
test('detects code views based on selectors', async () => {
const { services } = await integrationTestContext()
const codeView = createTestElement()
codeView.id = 'code'
const toolbarMount = document.createElement('div')
codeView.appendChild(toolbarMount)
const fileInfo: FileInfo = {
repoName: 'foo',
filePath: '/bar.ts',
commitID: '1',
}
subscriptions.add(
handleCodeHost({
codeHost: {
name: 'test',
check: () => true,
codeViews: [
{
selector: `#code`,
dom: {
getCodeElementFromTarget: jest.fn(),
getCodeElementFromLineNumber: jest.fn(),
getLineNumberFromCodeElement: jest.fn(),
},
resolveFileInfo: codeView => of(fileInfo),
getToolbarMount: () => toolbarMount,
},
],
selectionsChanges: () => of([]),
},
extensionsController: createMockController(services),
showGlobalDebug: true,
...createMockPlatformContext(),
})
)
const viewComponents = await from(services.model.model)
.pipe(
skip(1),
take(1),
map(({ visibleViewComponents }) => visibleViewComponents)
)
.toPromise()
expect(viewComponents).toEqual([
{
isActive: true,
item: {
languageId: 'typescript',
text: undefined,
uri: 'git://foo?1#/bar.ts',
},
selections: [],
type: 'textEditor',
},
])
expect(codeView.classList.contains('sg-mounted')).toBe(true)
const toolbar = elementRenderedAtMount(toolbarMount)
expect(toolbar).not.toBeUndefined()
})
test('decorates a code view', async () => {
const { extensionAPI, services } = await integrationTestContext(undefined, {
roots: [],
visibleViewComponents: [],
})
const codeView = createTestElement()
codeView.id = 'code'
const fileInfo: FileInfo = {
repoName: 'foo',
filePath: '/bar.ts',
commitID: '1',
}
const line = document.createElement('div')
codeView.appendChild(line)
subscriptions.add(
handleCodeHost({
codeHost: {
name: 'test',
check: () => true,
codeViews: [
{
selector: `#code`,
dom: {
getCodeElementFromTarget: jest.fn(),
getCodeElementFromLineNumber: () => line,
getLineNumberFromCodeElement: jest.fn(),
},
resolveFileInfo: codeView => of(fileInfo),
},
],
selectionsChanges: () => of([]),
},
extensionsController: createMockController(services),
showGlobalDebug: true,
...createMockPlatformContext(),
})
)
const activeEditor = await from(extensionAPI.app.activeWindowChanges)
.pipe(
filter(isDefined),
switchMap(window => window.activeViewComponentChanges),
filter(isDefined),
take(1)
)
.toPromise()
const decorationType = extensionAPI.app.createDecorationType()
const decorated = () =>
services.textDocumentDecoration
.getDecorations({ uri: 'git://foo?1#/bar.ts' })
.pipe(
filter(decorations => decorations !== []),
take(1)
)
.toPromise()
// Set decorations and verify that a decoration attachment has been added
activeEditor.setDecorations(decorationType, [
{
range: new Range(0, 0, 0, 0),
after: {
contentText: 'test decoration',
},
},
])
await decorated()
expect(line.querySelectorAll('.line-decoration-attachment').length).toBe(1)
expect(line.querySelector('.line-decoration-attachment')!.textContent).toEqual('test decoration')
// Decorate the code view again, and verify that previous decorations
// are cleaned up and replaced by the new decorations.
activeEditor.setDecorations(decorationType, [
{
range: new Range(0, 0, 0, 0),
after: {
contentText: 'test decoration 2',
},
},
])
await decorated()
expect(line.querySelectorAll('.line-decoration-attachment').length).toBe(1)
expect(line.querySelector('.line-decoration-attachment')!.textContent).toEqual('test decoration 2')
})
})

View File

@ -31,7 +31,7 @@ import { registerHighlightContributions } from '../../../../../shared/src/highli
import { ActionItemProps } from '../../../../../shared/src/actions/ActionItem'
import { Model, ViewComponentData } from '../../../../../shared/src/api/client/model'
import { HoverMerged } from '../../../../../shared/src/api/client/types/hover'
import { ExtensionsControllerProps } from '../../../../../shared/src/extensions/controller'
import { Controller } from '../../../../../shared/src/extensions/controller'
import { getHoverActions, registerHoverContributions } from '../../../../../shared/src/hover/actions'
import { HoverContext, HoverOverlay } from '../../../../../shared/src/hover/HoverOverlay'
import { getModeFromPath } from '../../../../../shared/src/languages'
@ -61,7 +61,7 @@ import { githubCodeHost } from '../github/code_intelligence'
import { gitlabCodeHost } from '../gitlab/code_intelligence'
import { phabricatorCodeHost } from '../phabricator/code_intelligence'
import { fetchFileContents, findCodeViews } from './code_views'
import { applyDecorations, initializeExtensions } from './extensions'
import { applyDecorations, initializeExtensions, injectCommandPalette, injectGlobalDebug } from './extensions'
import { injectViewContextOnSourcegraph } from './external_links'
registerHighlightContributions()
@ -162,7 +162,7 @@ export interface CodeHost {
/**
* Resolve `CodeView`s from the DOM. This is useful when each code view type
* doesn't have a distinct selector for
* doesn't have a distinct selector
*/
codeViewResolver?: CodeViewResolver
@ -239,23 +239,24 @@ export interface FileInfo {
baseContent?: string
}
interface CodeIntelligenceProps
extends PlatformContextProps<'forceUpdateTooltip' | 'urlToFile' | 'sideloadedExtensionURL'> {
codeHost: CodeHost
extensionsController: Controller
showGlobalDebug?: boolean
}
/**
* Prepares the page for code intelligence. It creates the hoverifier, injects
* and mounts the hover overlay and then returns the hoverifier.
*
* @param codeHost
*/
function initCodeIntelligence(
codeHost: CodeHost
): {
hoverifier: Hoverifier<RepoSpec & RevSpec & FileSpec & ResolvedRevSpec, HoverMerged, ActionItemProps>
controllers: ExtensionsControllerProps & PlatformContextProps
} {
const {
platformContext,
extensionsController,
}: PlatformContextProps & ExtensionsControllerProps = initializeExtensions(codeHost)
export function initCodeIntelligence({
codeHost,
platformContext,
extensionsController,
}: CodeIntelligenceProps): Hoverifier<RepoSpec & RevSpec & FileSpec & ResolvedRevSpec, HoverMerged, ActionItemProps> {
const { getHover } = createLSPFromExtensions(extensionsController)
/** Emits when the close button was clicked */
@ -398,7 +399,7 @@ function initCodeIntelligence(
overlayContainerMount
)
return { hoverifier, controllers: { platformContext, extensionsController } }
return hoverifier
}
/**
@ -410,16 +411,15 @@ export interface ResolvedCodeView extends CodeViewWithOutSelector {
codeView: HTMLElement
}
function handleCodeHost(codeHost: CodeHost): Subscription {
const {
hoverifier,
controllers: { platformContext, extensionsController },
} = initCodeIntelligence(codeHost)
export function handleCodeHost({
codeHost,
extensionsController,
platformContext,
showGlobalDebug,
}: CodeIntelligenceProps): Subscription {
const history = H.createBrowserHistory()
const subscriptions = new Subscription()
subscriptions.add(hoverifier)
const ensureRepoExists = (context: CodeHostContext) =>
resolveRev(context).pipe(
retryWhenCloneInProgressError(),
@ -439,6 +439,18 @@ function handleCodeHost(codeHost: CodeHost): Subscription {
})
}
const hoverifier = initCodeIntelligence({ codeHost, extensionsController, platformContext, showGlobalDebug })
subscriptions.add(hoverifier)
// Inject UI components
injectCommandPalette({ extensionsController, platformContext, history, getMount: codeHost.getCommandPaletteMount })
injectGlobalDebug({
extensionsController,
platformContext,
getMount: codeHost.getGlobalDebugMount,
history,
showGlobalDebug,
})
injectViewContextOnSourcegraph(sourcegraphUrl, codeHost, ensureRepoExists, isInPage ? undefined : openOptionsMenu)
// A stream of selections for the current code view. By default, selections
@ -586,13 +598,27 @@ function handleCodeHost(codeHost: CodeHost): Subscription {
return subscriptions
}
async function injectCodeIntelligenceToCodeHosts(codeHosts: CodeHost[]): Promise<Subscription> {
const SHOW_DEBUG = () => localStorage.getItem('debug') !== null
export async function injectCodeIntelligenceToCodeHosts(
codeHosts: CodeHost[],
showGlobalDebug = SHOW_DEBUG()
): Promise<Subscription> {
const subscriptions = new Subscription()
for (const codeHost of codeHosts) {
const isCodeHost = await Promise.resolve(codeHost.check())
if (isCodeHost) {
subscriptions.add(handleCodeHost(codeHost))
const { platformContext, extensionsController } = initializeExtensions(codeHost)
subscriptions.add(extensionsController)
subscriptions.add(
handleCodeHost({
codeHost,
extensionsController,
platformContext,
showGlobalDebug,
})
)
break
}
}

View File

@ -27,19 +27,24 @@ import { CodeHost } from './code_intelligence'
/**
* Initializes extensions for a page. It creates the {@link PlatformContext} and extensions controller.
*
* If the "Use extensions" feature flag is enabled (or always for Sourcegraph.com), it injects the command palette.
* If extensions are not supported by the associated Sourcegraph instance, the extensions controller will behave as
* though no individual extensions are enabled, which makes it effectively a noop.
*/
export function initializeExtensions({
getCommandPaletteMount,
urlToFile,
}: Pick<CodeHost, 'getCommandPaletteMount' | 'urlToFile'>): PlatformContextProps & ExtensionsControllerProps {
}: Pick<CodeHost, 'urlToFile'>): PlatformContextProps & ExtensionsControllerProps {
const platformContext = createPlatformContext({ urlToFile })
const extensionsController = createExtensionsController(platformContext)
const history = H.createBrowserHistory()
return { platformContext, extensionsController }
}
if (getCommandPaletteMount) {
interface InjectProps
extends PlatformContextProps<'forceUpdateTooltip' | 'sideloadedExtensionURL'>,
ExtensionsControllerProps {
getMount?: () => HTMLElement
history: H.History
}
export function injectCommandPalette({ extensionsController, platformContext, getMount, history }: InjectProps): void {
if (getMount) {
render(
<ShortcutProvider>
<TelemetryContext.Provider value={eventLogger}>
@ -52,20 +57,28 @@ export function initializeExtensions({
<Notifications extensionsController={extensionsController} />
</TelemetryContext.Provider>
</ShortcutProvider>,
getCommandPaletteMount()
getMount()
)
}
}
render(
<GlobalDebug
extensionsController={extensionsController}
location={history.location}
platformContext={platformContext}
/>,
getGlobalDebugMount()
)
return { platformContext, extensionsController }
export function injectGlobalDebug({
extensionsController,
platformContext,
history,
showGlobalDebug,
getMount = getGlobalDebugMount,
}: InjectProps & { showGlobalDebug?: boolean }): void {
if (showGlobalDebug) {
render(
<GlobalDebug
extensionsController={extensionsController}
location={history.location}
platformContext={platformContext}
/>,
getMount()
)
}
}
const IS_LIGHT_THEME = true // assume all code hosts have a light theme (correct for now)

View File

@ -19,7 +19,7 @@ export interface ButtonProps {
iconStyle?: React.CSSProperties
}
interface CodeViewToolbarProps extends PlatformContextProps, ExtensionsControllerProps, FileInfo {
interface CodeViewToolbarProps extends PlatformContextProps<'forceUpdateTooltip'>, ExtensionsControllerProps, FileInfo {
onEnabledChange?: (enabled: boolean) => void
buttonProps: ButtonProps

View File

@ -6,13 +6,11 @@ import { PlatformContextProps } from '../../../../../shared/src/platform/context
import { sourcegraphUrl } from '../util/context'
import { ShortcutProvider } from './ShortcutProvider'
interface Props extends PlatformContextProps {
interface Props extends PlatformContextProps<'sideloadedExtensionURL'> {
location: H.Location
extensionsController: ClientController
}
const SHOW_DEBUG = localStorage.getItem('debug') !== null
const ExtensionLink: React.FunctionComponent<{ id: string }> = props => {
const extensionURL = new URL(sourcegraphUrl)
extensionURL.pathname = `extensions/${props.id}`
@ -22,20 +20,19 @@ const ExtensionLink: React.FunctionComponent<{ id: string }> = props => {
/**
* A global debug toolbar shown in the bottom right of the window.
*/
export const GlobalDebug: React.FunctionComponent<Props> = props =>
SHOW_DEBUG ? (
<div className="global-debug navbar navbar-expand">
<div className="navbar-nav align-items-center">
<div className="nav-item">
<ShortcutProvider>
<ExtensionStatusPopover
location={props.location}
extensionsController={props.extensionsController}
link={ExtensionLink}
platformContext={props.platformContext}
/>
</ShortcutProvider>
</div>
export const GlobalDebug: React.FunctionComponent<Props> = props => (
<div className="global-debug navbar navbar-expand">
<div className="navbar-nav align-items-center">
<div className="nav-item">
<ShortcutProvider>
<ExtensionStatusPopover
location={props.location}
extensionsController={props.extensionsController}
link={ExtensionLink}
platformContext={props.platformContext}
/>
</ShortcutProvider>
</div>
</div>
) : null
</div>
)

View File

@ -0,0 +1,6 @@
/**
* A MutationObserver polyfill
*/
declare module 'mutation-observer' {
const MutationObserver: MutationObserver
}

View File

@ -18,11 +18,18 @@ const config = {
'/node_modules/(?!abortable-rx|@sourcegraph/react-loading-spinner|@sourcegraph/codeintellify|@sourcegraph/comlink)',
],
moduleNameMapper: { '\\.s?css$': 'identity-obj-proxy' },
moduleNameMapper: { '\\.s?css$': 'identity-obj-proxy', '^worker-loader': 'identity-obj-proxy' },
// By default, don't clutter `yarn test --watch` output with the full coverage table. To see it, use the
// `--coverageReporters text` jest option.
coverageReporters: ['json', 'lcov', 'text-summary'],
globals: {
'ts-jest': {
diagnostics: {
pathRegex: '(client/browser|shared|web)/src',
},
},
},
}
module.exports = config

View File

@ -142,6 +142,7 @@
"message-port-polyfill": "^0.1.0",
"mini-css-extract-plugin": "^0.5.0",
"mkdirp-promise": "^5.0.1",
"mutation-observer": "^1.0.3",
"mz": "^2.7.0",
"node-sass": "^4.11.0",
"optimize-css-assets-webpack-plugin": "^5.0.1",

View File

@ -10,7 +10,7 @@ import { ExtensionsControllerProps } from '../extensions/controller'
import { PlatformContextProps } from '../platform/context'
import { asError, ErrorLike, isErrorLike } from '../util/errors'
interface Props extends ExtensionsControllerProps, PlatformContextProps {
interface Props extends ExtensionsControllerProps, PlatformContextProps<'sideloadedExtensionURL'> {
link: React.ComponentType<{ id: string }>
}

View File

@ -35,7 +35,7 @@ const LOADING: 'loading' = 'loading'
* "Find references".
*/
export function getHoverActions(
{ extensionsController, platformContext }: ExtensionsControllerProps & PlatformContextProps,
{ extensionsController, platformContext }: ExtensionsControllerProps & PlatformContextProps<'urlToFile'>,
hoverContext: HoveredToken & HoverContext
): Observable<ActionItemProps[]> {
return getHoverActionsContext({ extensionsController, platformContext }, hoverContext).pipe(
@ -71,7 +71,7 @@ export function getHoverActionsContext(
extensionsController,
platformContext: { urlToFile },
}:
| (ExtensionsControllerProps & PlatformContextProps)
| (ExtensionsControllerProps & PlatformContextProps<'urlToFile'>)
| {
extensionsController: {
services: {

View File

@ -10464,6 +10464,11 @@ multicast-dns@^6.0.1:
dns-packet "^1.3.1"
thunky "^1.0.2"
mutation-observer@^1.0.3:
version "1.0.3"
resolved "https://registry.npmjs.org/mutation-observer/-/mutation-observer-1.0.3.tgz#42e9222b101bca82e5ba9d5a7acf4a14c0f263d0"
integrity sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA==
mute-stdout@^1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/mute-stdout/-/mute-stdout-1.0.1.tgz#acb0300eb4de23a7ddeec014e3e96044b3472331"