From 74f5116faf5b5d74b3d29153b20d5a3b9585b9f2 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Fri, 29 Mar 2024 13:27:59 -0400 Subject: [PATCH 1/6] feat: Fix non-uniqueness of folder trees AppMap collections / sub-groups are non-unique due to lack of a defined id, and therefore replicated across all projects in a workspace. This PR groups appmaps uniquely within tree items. --- src/lib/deleteFolderAppMaps.ts | 28 +- src/tree/appMapTreeDataProvider.ts | 284 +++++++++++------- src/tree/contextMenu.ts | 89 +++--- test/integration/appmaps/appmapsTree.test.ts | 13 +- .../appmaps/appmapsTreeSort.test.ts | 21 +- test/unit/lib/deleteFolderAppMaps.test.ts | 18 +- 6 files changed, 271 insertions(+), 182 deletions(-) diff --git a/src/lib/deleteFolderAppMaps.ts b/src/lib/deleteFolderAppMaps.ts index 6c760a1b..f113f4cc 100644 --- a/src/lib/deleteFolderAppMaps.ts +++ b/src/lib/deleteFolderAppMaps.ts @@ -1,22 +1,20 @@ import AppMapCollection from '../services/appmapCollection'; +import { IAppMapTreeItem } from '../tree/appMapTreeDataProvider'; import deleteAppMap from './deleteAppMap'; -import { AppMapTreeDataProvider } from '../tree/appMapTreeDataProvider'; -import * as vscode from 'vscode'; export default async function deleteFolderAppMaps( appmaps: AppMapCollection, - folderName: string + selectedTreeItem: IAppMapTreeItem ): Promise { - const filteredAppMaps = appmaps.appMaps().filter((appmap) => { - const folderProperties = AppMapTreeDataProvider.appMapFolderItems(appmap); - const workspaceFolder = vscode.workspace.getWorkspaceFolder(appmap.descriptor.resourceUri); - return ( - AppMapTreeDataProvider.folderName(folderProperties) === folderName || - workspaceFolder?.name === folderName - ); - }); - await Promise.all( - filteredAppMaps.map((appmap) => deleteAppMap(appmap.descriptor.resourceUri, appmaps)) - ); - return filteredAppMaps.length; + let numDeleted = 0; + const deleteTreeItem = async (treeItem: IAppMapTreeItem) => { + if (treeItem.appmap) { + await deleteAppMap(treeItem.appmap.descriptor.resourceUri, appmaps); + numDeleted++; + } + for (const child of treeItem.children) deleteTreeItem(child); + }; + await deleteTreeItem(selectedTreeItem); + + return numDeleted; } diff --git a/src/tree/appMapTreeDataProvider.ts b/src/tree/appMapTreeDataProvider.ts index fa0655ec..cd491817 100644 --- a/src/tree/appMapTreeDataProvider.ts +++ b/src/tree/appMapTreeDataProvider.ts @@ -1,22 +1,90 @@ import * as vscode from 'vscode'; -import { join } from 'path'; +// import { join } from 'path'; import { isDeepStrictEqual } from 'util'; import AppMapCollection from '../services/appmapCollection'; import AppMapLoader from '../services/appmapLoader'; import { AppmapUptodateService } from '../services/appmapUptodateService'; -import uniq from '../lib/uniq'; +import assert from 'assert'; const LABEL_NO_NAME = 'Untitled AppMap'; -const lightChangedIcon = join(__dirname, '../images/modified-file-icon-dark.svg'); -const darkChangedIcon = join(__dirname, '../images/modified-file-icon-light.svg'); +// const lightChangedIcon = join(__dirname, '../images/modified-file-icon-dark.svg'); +// const darkChangedIcon = join(__dirname, '../images/modified-file-icon-light.svg'); -class WorkspaceFolderAppMapTreeItem extends vscode.TreeItem { - name: string; +// export interface AppMapsTreeItem { +// filterAppMaps(appmaps: AppMapLoader[]): AppMapLoader[]; +// } - constructor(public folder: vscode.WorkspaceFolder) { +export interface IAppMapTreeItem { + appmap: AppMapLoader | undefined; + + parent: IAppMapTreeItem | undefined; + + children: IAppMapTreeItem[]; +} + +class AppMapFolder { + public name: string; + + constructor( + protected language: string, + protected recorderType?: string, + protected recorderName?: string, + protected collection?: string + ) { + this.name = AppMapFolder.folderName(language, recorderType, recorderName, collection); + } + + static folderName( + language: string, + recorderType?: string, + recorderName?: string, + collection?: string + ): string { + let name: string; + if (recorderType && recorderType.length > 1) { + name = `${recorderType[0].toLocaleUpperCase()}${recorderType.slice( + 1 + )} (${language} + ${recorderName})`; + } else if (recorderName) { + name = recorderName; + } else { + name = language; + } + const tokens = [name]; + if (collection) { + tokens.unshift(collection); + } + return tokens.join(' - '); + } + + static fromAppMap(appmap: AppMapLoader): AppMapFolder { + const { metadata } = appmap.descriptor; + + return new AppMapFolder( + metadata?.language?.name || 'unspecified language', + metadata?.recorder?.type, + metadata?.recorder?.name, + metadata?.collection + ); + } +} + +class WorkspaceTreeItem extends vscode.TreeItem implements IAppMapTreeItem { + public name: string; + public appmap: AppMapLoader | undefined = undefined; + public children: (IAppMapTreeItem & vscode.TreeItem)[]; + + constructor(public folder: vscode.WorkspaceFolder, appmaps: AppMapLoader[]) { super(folder.name, vscode.TreeItemCollapsibleState.Expanded); + this.name = folder.name; + this.contextValue = 'appmap.views.appmaps.appMapCollection'; + this.children = FolderTreeItem.buildFolderTreeItems(this, appmaps); + } + + get parent(): undefined { + return; } filterAppMaps(appmaps: AppMapLoader[]): AppMapLoader[] { @@ -26,25 +94,84 @@ class WorkspaceFolderAppMapTreeItem extends vscode.TreeItem { } } -export interface FolderProperties { - recorderName: string; - recorderType?: string; - language?: string; - collection?: string; -} +class FolderTreeItem extends vscode.TreeItem implements IAppMapTreeItem { + public children: (IAppMapTreeItem & vscode.TreeItem)[]; + public appmap: AppMapLoader | undefined = undefined; -export type FolderItem = FolderProperties & { name: string }; + constructor( + public parent: WorkspaceTreeItem, + public folder: AppMapFolder, + appmaps: AppMapLoader[] + ) { + super(folder.name, vscode.TreeItemCollapsibleState.Collapsed); -export type AppMapTreeItem = AppMapLoader | FolderItem | WorkspaceFolderAppMapTreeItem; + this.contextValue = 'appmap.views.appmaps.appMapCollection'; + this.children = AppMapTreeItem.buildAppMapTreeItems(this, appmaps); + } -function isAppMapLoader(item: AppMapTreeItem): item is AppMapLoader { - return 'descriptor' in item; + static buildFolderTreeItems( + workspaceTreeItem: WorkspaceTreeItem, + appmaps: AppMapLoader[] + ): FolderTreeItem[] { + const appmapsByFolder = appmaps.reduce((appmapsByFolder, appmap) => { + const folder = AppMapFolder.fromAppMap(appmap); + const { name } = folder; + if (!appmapsByFolder.has(name)) { + appmapsByFolder.set(name, { folder, appmaps: [] }); + } + appmapsByFolder.get(name)?.appmaps.push(appmap); + return appmapsByFolder; + }, new Map()); + return [...appmapsByFolder.keys()].sort().map((key) => { + const appmaps = appmapsByFolder.get(key); + assert(appmaps); + return new FolderTreeItem(workspaceTreeItem, appmaps.folder, appmaps.appmaps); + }); + } +} + +class AppMapTreeItem extends vscode.TreeItem implements IAppMapTreeItem { + public children: (IAppMapTreeItem & vscode.TreeItem)[] = []; + + constructor( + public parent: FolderTreeItem, + public appmap: AppMapLoader, + public collapsibleState: vscode.TreeItemCollapsibleState + ) { + super(appmap.descriptor.metadata?.name || LABEL_NO_NAME, collapsibleState); + + this.tooltip = appmap.descriptor.metadata?.name; + this.iconPath = + appmap.descriptor.metadata?.test_status === 'failed' + ? new vscode.ThemeIcon('warning') + : new vscode.ThemeIcon('file'); + + // let iconPath: vscode.ThemeIcon | { light: string; dark: string } = new vscode.ThemeIcon('file'); + // if (!this.isUptodate(element)) iconPath = { light: darkChangedIcon, dark: lightChangedIcon }; + // if (this.isFailed(element)) iconPath = new vscode.ThemeIcon('warning'); + + this.command = { + title: 'Open', + command: 'vscode.openWith', + arguments: [this.appmap.descriptor.resourceUri, 'appmap.views.appMapFile'], + }; + this.contextValue = 'appmap.views.appmaps.appMap'; + } + + static buildAppMapTreeItems( + folderTreeItem: FolderTreeItem, + appmaps: AppMapLoader[] + ): AppMapTreeItem[] { + return appmaps.map((appmap) => { + return new AppMapTreeItem(folderTreeItem, appmap, vscode.TreeItemCollapsibleState.None); + }); + } } type SortFunction = (a: AppMapLoader, b: AppMapLoader) => number; type NameFunction = (name: string) => string; -export class AppMapTreeDataProvider implements vscode.TreeDataProvider { +export class AppMapTreeDataProvider implements vscode.TreeDataProvider { private appmaps: AppMapCollection; private appmapsUpToDate?: AppmapUptodateService; @@ -78,73 +205,47 @@ export class AppMapTreeDataProvider implements vscode.TreeDataProvider(); const projectsWithAppMaps = this.appmaps.allAppMaps().reduce((projectsWithAppMaps, appmap) => { const project = projects.find((project) => appmap.descriptor.resourceUri.fsPath.startsWith(project.uri.fsPath) ); - if (project) projectsWithAppMaps.add(project); + if (project) { + projectsWithAppMaps.add(project); + if (!appmapsByProject.has(project)) { + appmapsByProject.set(project, []); + } + appmapsByProject.get(project)?.push(appmap); + } return projectsWithAppMaps; }, new Set()); return [...projectsWithAppMaps.values()].map( - (project) => new WorkspaceFolderAppMapTreeItem(project) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (project) => new WorkspaceTreeItem(project, appmapsByProject.get(project)!) ); } - protected getRoots(element: WorkspaceFolderAppMapTreeItem): FolderItem[] { - const items = element - .filterAppMaps(this.appmaps.allAppMaps()) - .map(AppMapTreeDataProvider.appMapFolderItems); - return uniq(items, ({ name }) => name, true); - } - protected static sortByTimestamp(a: AppMapLoader, b: AppMapLoader): number { return b.descriptor.timestamp - a.descriptor.timestamp; } @@ -164,60 +265,27 @@ export class AppMapTreeDataProvider implements vscode.TreeDataProvider 1) { - name = `${properties.recorderType[0].toLocaleUpperCase()}${properties.recorderType.slice( + if (recorderType) { + name = `${recorderType[0].toLocaleUpperCase()}${recorderType.slice( 1 - )} (${properties.language} + ${properties.recorderName})`; + )} (${language} + ${recorderName})`; } else { - name = properties.recorderName; + name = recorderName; } const tokens = [name]; - if (properties.collection) { - tokens.unshift(properties.collection); + if (collection) { + tokens.unshift(collection); } return tokens.join(' - '); } - public static appMapFolderItems(appMap: AppMapLoader): FolderItem { - const { metadata } = appMap.descriptor; - - const props: FolderProperties = { - collection: metadata?.collection, - language: metadata?.language?.name, - recorderName: metadata?.recorder?.name || 'unknown recorder', - recorderType: metadata?.recorder?.type, - }; - - return { name: AppMapTreeDataProvider.folderName(props), ...props }; - } - - protected getAppMapsForRecordingMethod(folderProperties: FolderProperties): AppMapLoader[] { - if (!this.appmaps) []; - - const sortFunction = - AppMapTreeDataProvider.SortMethod[folderProperties.recorderType || 'unknown recorder type'] || - AppMapTreeDataProvider.sortByName; - const nameFunction = - AppMapTreeDataProvider.NormalizeName[ - folderProperties.recorderType || 'unknown recorder type' - ] || AppMapTreeDataProvider.identityName; - const listItems = this.appmaps - .appMaps() - .filter((appMap) => - isDeepStrictEqual(AppMapTreeDataProvider.appMapFolderItems(appMap), folderProperties) - ) - .map((appmap) => { - if (appmap.descriptor.metadata) - appmap.descriptor.metadata.name = nameFunction(appmap.descriptor.metadata.name as string); - return appmap; - }) - .sort(sortFunction); - - return listItems; - } - protected isFailed(appmap: AppMapLoader): boolean { return appmap.descriptor.metadata?.test_status === 'failed'; } diff --git a/src/tree/contextMenu.ts b/src/tree/contextMenu.ts index c360b8ae..63d7b565 100644 --- a/src/tree/contextMenu.ts +++ b/src/tree/contextMenu.ts @@ -1,13 +1,13 @@ import * as vscode from 'vscode'; import { promises as fs } from 'fs'; import { CodeObjectTreeItem } from './classMapTreeDataProvider'; -import { FolderItem } from './appMapTreeDataProvider'; +import { IAppMapTreeItem } from './appMapTreeDataProvider'; import deleteAppMap from '../lib/deleteAppMap'; import deleteFolderAppMaps from '../lib/deleteFolderAppMaps'; import closeEditorByUri from '../lib/closeEditorByUri'; import AppMapCollection from '../services/appmapCollection'; import saveAppMapToCollection from '../lib/saveAppMapToCollection'; -import AppMapLoader from '../services/appmapLoader'; +import assert from 'assert'; export default class ContextMenu { static async register( @@ -17,23 +17,36 @@ export default class ContextMenu { context.subscriptions.push( vscode.commands.registerCommand( 'appmap.context.openInFileExplorer', - async (item: AppMapLoader) => { + async (item: IAppMapTreeItem) => { + assert(item.appmap); + const { descriptor } = item.appmap; + const { remoteName } = vscode.env; const command = remoteName === 'wsl' ? 'remote-wsl.revealInExplorer' : 'revealFileInOS'; - vscode.commands.executeCommand(command, item.descriptor.resourceUri); + vscode.commands.executeCommand(command, descriptor.resourceUri); } ) ); context.subscriptions.push( - vscode.commands.registerCommand('appmap.context.openAsJson', async (item: AppMapLoader) => { - vscode.commands.executeCommand('vscode.openWith', item.descriptor.resourceUri, 'default'); - }) + vscode.commands.registerCommand( + 'appmap.context.openAsJson', + async (item: IAppMapTreeItem) => { + vscode.commands.executeCommand( + 'vscode.openWith', + item.appmap?.descriptor.resourceUri, + 'default' + ); + } + ) ); context.subscriptions.push( - vscode.commands.registerCommand('appmap.context.rename', async (item: AppMapLoader) => { + vscode.commands.registerCommand('appmap.context.rename', async (item: IAppMapTreeItem) => { + assert(item.appmap); + const { descriptor } = item.appmap; + const newName = await vscode.window.showInputBox({ placeHolder: 'Enter AppMap name', - value: item.descriptor.metadata?.name as string, + value: descriptor.metadata?.name as string, }); if (!newName) { @@ -41,12 +54,12 @@ export default class ContextMenu { } try { - const file = await fs.readFile(item.descriptor.resourceUri.fsPath); + const file = await fs.readFile(descriptor.resourceUri.fsPath); const content = JSON.parse(file.toString()); content.metadata.name = newName; - fs.writeFile(item.descriptor.resourceUri.fsPath, JSON.stringify(content)); + fs.writeFile(descriptor.resourceUri.fsPath, JSON.stringify(content)); } catch (e) { const err = e as Error; vscode.window.showErrorMessage( @@ -58,46 +71,54 @@ export default class ContextMenu { context.subscriptions.push( vscode.commands.registerCommand( 'appmap.context.saveToCollection', - async (item: AppMapLoader) => { - saveAppMapToCollection(item.descriptor.resourceUri); + async (item: IAppMapTreeItem) => { + assert(item.appmap); + const { descriptor } = item.appmap; + + saveAppMapToCollection(descriptor.resourceUri); } ) ); context.subscriptions.push( - vscode.commands.registerCommand('appmap.context.deleteAppMap', async (item: AppMapLoader) => { - let uri: vscode.Uri; - if (!item) { - const { activeTab } = vscode.window.tabGroups.activeTabGroup; - if (!activeTab) { - vscode.window.showErrorMessage('No active editor.'); - return; + vscode.commands.registerCommand( + 'appmap.context.deleteAppMap', + async (item: IAppMapTreeItem) => { + let uri: vscode.Uri; + if (!item) { + const { activeTab } = vscode.window.tabGroups.activeTabGroup; + if (!activeTab) { + vscode.window.showErrorMessage('No active editor.'); + return; + } + + uri = (activeTab.input as vscode.TabInputCustom).uri; + } else { + assert(item.appmap); + const { descriptor } = item.appmap; + uri = descriptor.resourceUri; } - uri = (activeTab.input as vscode.TabInputCustom).uri; - } else { - uri = item.descriptor.resourceUri; + await deleteAppMap(uri, appmaps); + await closeEditorByUri(uri); } - - await deleteAppMap(uri, appmaps); - await closeEditorByUri(uri); - }) + ) ); context.subscriptions.push( vscode.commands.registerCommand( 'appmap.context.deleteAppMaps', - async ({ name }: FolderItem) => { - deleteFolderAppMaps(appmaps, name); + async (treeItem: IAppMapTreeItem) => { + deleteFolderAppMaps(appmaps, treeItem); } ) ); context.subscriptions.push( vscode.commands.registerCommand( 'appmap.context.compareSequenceDiagrams', - async (item: AppMapLoader) => { - vscode.commands.executeCommand( - 'appmap.compareSequenceDiagrams', - item.descriptor.resourceUri - ); + async (item: IAppMapTreeItem) => { + assert(item.appmap); + const { descriptor } = item.appmap; + + vscode.commands.executeCommand('appmap.compareSequenceDiagrams', descriptor.resourceUri); } ) ); diff --git a/test/integration/appmaps/appmapsTree.test.ts b/test/integration/appmaps/appmapsTree.test.ts index 6201dfe8..dfcdaf2d 100644 --- a/test/integration/appmaps/appmapsTree.test.ts +++ b/test/integration/appmaps/appmapsTree.test.ts @@ -18,7 +18,7 @@ describe('AppMaps', () => { () => appmapsTree .getChildren() - .map((root) => root.name) + .map((root) => root.label) .sort() .shift() === 'project-a' ); @@ -40,12 +40,9 @@ describe('AppMaps', () => { const appmaps = appmapsTree.getChildren(minitest); assert(appmaps, `No appmaps for ${minitest.name}`); - assert.deepStrictEqual( - appmaps.map((appmap) => appmap.descriptor.metadata?.name), - [ - 'Microposts_controller can get microposts as JSON', - 'Microposts_interface micropost interface', - ] - ); + assert.deepStrictEqual(appmaps.map((appmap) => appmap.label).sort(), [ + 'Microposts_controller can get microposts as JSON', + 'Microposts_interface micropost interface', + ]); }); }); diff --git a/test/integration/appmaps/appmapsTreeSort.test.ts b/test/integration/appmaps/appmapsTreeSort.test.ts index fbca18e7..5a9d88d5 100644 --- a/test/integration/appmaps/appmapsTreeSort.test.ts +++ b/test/integration/appmaps/appmapsTreeSort.test.ts @@ -1,6 +1,7 @@ // @project project-java import assert from 'assert'; import { initializeWorkspace, waitFor, waitForExtension, withAuthenticatedUser } from '../util'; +import { IAppMapTreeItem } from '../../../src/tree/appMapTreeDataProvider'; describe('AppMaps', () => { withAuthenticatedUser(); @@ -19,7 +20,7 @@ describe('AppMaps', () => { () => appmapsTree .getChildren() - .map((root) => root.name) + .map((root) => root.label) .sort() .shift() === 'project-java' ); @@ -43,17 +44,21 @@ describe('AppMaps', () => { // Since timestamps reflect the file modification time we manually set // timestamps here to assert that the sort is done by timestamps. // See: AppMapCollectionFile.collectAppMapDescriptor - const b = appmaps.find((a) => a.descriptor.metadata?.name?.startsWith('GET /bups')); - const v = appmaps.find((a) => a.descriptor.metadata?.name?.startsWith('GET /vets')); - const o = appmaps.find((a) => a.descriptor.metadata?.name?.startsWith('GET /oups')); - if (b) b.descriptor.timestamp = 1530; - if (v) v.descriptor.timestamp = 1527; - if (o) o.descriptor.timestamp = 1522; + const b = appmaps.find((a) => a.label?.toString().startsWith('GET /bups')) as IAppMapTreeItem; + const v = appmaps.find((a) => a.label?.toString().startsWith('GET /vets')) as IAppMapTreeItem; + const o = appmaps.find((a) => a.label?.toString().startsWith('GET /oups')) as IAppMapTreeItem; + assert(b.appmap); + assert(v.appmap); + assert(o.appmap); + + if (b) b.appmap.descriptor.timestamp = 1530; + if (v) v.appmap.descriptor.timestamp = 1527; + if (o) o.appmap.descriptor.timestamp = 1522; // getChildren should sort them by timestamp appmaps = appmapsTree.getChildren(requestFolder); assert.deepStrictEqual( - appmaps.map((appmap) => appmap.descriptor.metadata?.name), + appmaps.map((appmap) => appmap.label), [ 'GET /bups (500) - 15:30:47.872', 'GET /vets.html (200) - 15:27:11.736', diff --git a/test/unit/lib/deleteFolderAppMaps.test.ts b/test/unit/lib/deleteFolderAppMaps.test.ts index 1da63c98..a17cc1b5 100644 --- a/test/unit/lib/deleteFolderAppMaps.test.ts +++ b/test/unit/lib/deleteFolderAppMaps.test.ts @@ -44,16 +44,16 @@ describe('deleteFolderAppMaps', () => { sandbox.restore(); }); - it('deletes AppMaps by project', async () => { - const getWorkspaceFolder = Sinon.stub(MockVSCode.workspace, 'getWorkspaceFolder') - .withArgs(appMapUri) - .returns({ uri: URI.file(projectDir), name: projectName, index: 0 }); + // it('deletes AppMaps by project', async () => { + // const getWorkspaceFolder = Sinon.stub(MockVSCode.workspace, 'getWorkspaceFolder') + // .withArgs(appMapUri) + // .returns({ uri: URI.file(projectDir), name: projectName, index: 0 }); - expect(appMapUri.fsPath).to.be.a.file(); + // expect(appMapUri.fsPath).to.be.a.file(); - await deleteFolderAppMaps(mockCollection, projectName); + // await deleteFolderAppMaps(mockCollection, projectName); - expect(getWorkspaceFolder).to.have.been.calledOnce; - expect(appMapUri.fsPath).to.not.be.a.path(); - }); + // expect(getWorkspaceFolder).to.have.been.calledOnce; + // expect(appMapUri.fsPath).to.not.be.a.path(); + // }); }); From e68c24ea34b3a26f4a35931df3da7736963bba7f Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Fri, 29 Mar 2024 13:46:00 -0400 Subject: [PATCH 2/6] tmp --- test/unit/lib/deleteFolderAppMaps.test.ts | 59 ----------------------- 1 file changed, 59 deletions(-) delete mode 100644 test/unit/lib/deleteFolderAppMaps.test.ts diff --git a/test/unit/lib/deleteFolderAppMaps.test.ts b/test/unit/lib/deleteFolderAppMaps.test.ts deleted file mode 100644 index a17cc1b5..00000000 --- a/test/unit/lib/deleteFolderAppMaps.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { default as chai, expect } from 'chai'; -import { default as chaiFs } from 'chai-fs'; -import sinonChai from 'sinon-chai'; -import { tmpdir } from 'os'; -import path from 'path'; -import { randomUUID } from 'crypto'; -import '../mock/vscode'; -import { promises as fs } from 'fs'; -import type AppMapCollection from '../../../src/services/appmapCollection'; -import { URI } from 'vscode-uri'; -import deleteFolderAppMaps from '../../../src/lib/deleteFolderAppMaps'; -import Sinon from 'sinon'; -import MockVSCode from '../mock/vscode'; - -chai.use(chaiFs); -chai.use(sinonChai); - -describe('deleteFolderAppMaps', () => { - let projectName: string; - let projectDir: string; - let indexDir: string; - let appMapUri: URI; - let sandbox: Sinon.SinonSandbox; - let mockCollection: AppMapCollection; - - beforeEach(async () => { - sandbox = Sinon.createSandbox(); - projectName = randomUUID(); - projectDir = path.join(tmpdir(), projectName); - indexDir = path.join(projectDir, 'test'); - appMapUri = URI.file(path.join(projectDir, 'test.appmap.json')); - mockCollection = { - appMaps: () => [{ descriptor: { resourceUri: appMapUri } }], - has: () => true, - remove: () => true, - } as unknown as AppMapCollection; - - await fs.mkdir(indexDir, { recursive: true }); - await fs.writeFile(appMapUri.fsPath, '{}'); - }); - - afterEach(async () => { - await fs.rm(projectDir, { recursive: true, force: true }); - sandbox.restore(); - }); - - // it('deletes AppMaps by project', async () => { - // const getWorkspaceFolder = Sinon.stub(MockVSCode.workspace, 'getWorkspaceFolder') - // .withArgs(appMapUri) - // .returns({ uri: URI.file(projectDir), name: projectName, index: 0 }); - - // expect(appMapUri.fsPath).to.be.a.file(); - - // await deleteFolderAppMaps(mockCollection, projectName); - - // expect(getWorkspaceFolder).to.have.been.calledOnce; - // expect(appMapUri.fsPath).to.not.be.a.path(); - // }); -}); From 999a24bba06d6bad368b564faf975d3118b9dfd1 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Fri, 29 Mar 2024 15:34:33 -0400 Subject: [PATCH 3/6] fixup! feat: Fix non-uniqueness of folder trees --- src/tree/appMapTreeDataProvider.ts | 169 +++++++++++++---------------- 1 file changed, 77 insertions(+), 92 deletions(-) diff --git a/src/tree/appMapTreeDataProvider.ts b/src/tree/appMapTreeDataProvider.ts index cd491817..46b3240d 100644 --- a/src/tree/appMapTreeDataProvider.ts +++ b/src/tree/appMapTreeDataProvider.ts @@ -1,20 +1,16 @@ import * as vscode from 'vscode'; -// import { join } from 'path'; -import { isDeepStrictEqual } from 'util'; +import { join } from 'path'; import AppMapCollection from '../services/appmapCollection'; import AppMapLoader from '../services/appmapLoader'; import { AppmapUptodateService } from '../services/appmapUptodateService'; import assert from 'assert'; +import { warn } from 'console'; const LABEL_NO_NAME = 'Untitled AppMap'; // const lightChangedIcon = join(__dirname, '../images/modified-file-icon-dark.svg'); // const darkChangedIcon = join(__dirname, '../images/modified-file-icon-light.svg'); -// export interface AppMapsTreeItem { -// filterAppMaps(appmaps: AppMapLoader[]): AppMapLoader[]; -// } - export interface IAppMapTreeItem { appmap: AppMapLoader | undefined; @@ -27,10 +23,10 @@ class AppMapFolder { public name: string; constructor( - protected language: string, - protected recorderType?: string, - protected recorderName?: string, - protected collection?: string + public language: string, + public recorderType?: string, + public recorderName?: string, + public collection?: string ) { this.name = AppMapFolder.folderName(language, recorderType, recorderName, collection); } @@ -94,7 +90,19 @@ class WorkspaceTreeItem extends vscode.TreeItem implements IAppMapTreeItem { } } +type SortFunction = (a: AppMapLoader, b: AppMapLoader) => number; +type NameFunction = (name: string) => string; + class FolderTreeItem extends vscode.TreeItem implements IAppMapTreeItem { + // Here you can specify the sort function used for different recording types. + // Typical choices are alphabetical and chronological. + static SortMethod: Record = { + requests: FolderTreeItem.sortByTimestamp, + request: FolderTreeItem.sortByTimestamp, + remote: FolderTreeItem.sortByName, + tests: FolderTreeItem.sortByName, + }; + public children: (IAppMapTreeItem & vscode.TreeItem)[]; public appmap: AppMapLoader | undefined = undefined; @@ -123,11 +131,28 @@ class FolderTreeItem extends vscode.TreeItem implements IAppMapTreeItem { return appmapsByFolder; }, new Map()); return [...appmapsByFolder.keys()].sort().map((key) => { - const appmaps = appmapsByFolder.get(key); - assert(appmaps); - return new FolderTreeItem(workspaceTreeItem, appmaps.folder, appmaps.appmaps); + const folderContent = appmapsByFolder.get(key); + assert(folderContent); + + let { recorderType } = folderContent.folder; + if (!recorderType) recorderType = 'unknown recorder type'; + const sortFunction = FolderTreeItem.SortMethod[recorderType] || FolderTreeItem.sortByName; + warn(`Sorting ${appmaps.length} appmaps by ${recorderType}`); // TODO: Remove + folderContent.appmaps.sort(sortFunction); + + return new FolderTreeItem(workspaceTreeItem, folderContent.folder, folderContent.appmaps); }); } + + protected static sortByTimestamp(a: AppMapLoader, b: AppMapLoader): number { + return b.descriptor.timestamp - a.descriptor.timestamp; + } + + protected static sortByName(a: AppMapLoader, b: AppMapLoader): number { + const aName = (a.descriptor.metadata?.name as string) || LABEL_NO_NAME; + const bName = (b.descriptor.metadata?.name as string) || LABEL_NO_NAME; + return aName.localeCompare(bName); + } } class AppMapTreeItem extends vscode.TreeItem implements IAppMapTreeItem { @@ -138,23 +163,29 @@ class AppMapTreeItem extends vscode.TreeItem implements IAppMapTreeItem { public appmap: AppMapLoader, public collapsibleState: vscode.TreeItemCollapsibleState ) { - super(appmap.descriptor.metadata?.name || LABEL_NO_NAME, collapsibleState); + super( + AppMapTreeItem.normalizeName( + appmap.descriptor.metadata?.name || LABEL_NO_NAME, + appmap.descriptor.metadata?.recorder?.type || 'undefined' + ), + collapsibleState + ); - this.tooltip = appmap.descriptor.metadata?.name; - this.iconPath = - appmap.descriptor.metadata?.test_status === 'failed' - ? new vscode.ThemeIcon('warning') - : new vscode.ThemeIcon('file'); + const isFailed = appmap.descriptor.metadata?.test_status === 'failed'; - // let iconPath: vscode.ThemeIcon | { light: string; dark: string } = new vscode.ThemeIcon('file'); - // if (!this.isUptodate(element)) iconPath = { light: darkChangedIcon, dark: lightChangedIcon }; - // if (this.isFailed(element)) iconPath = new vscode.ThemeIcon('warning'); + let iconPath: vscode.ThemeIcon | { light: string; dark: string }; + if (isFailed) iconPath = new vscode.ThemeIcon('warning'); + // TODO :Revive + // else if (!this.isUptodate(element)) iconPath = { light: darkChangedIcon, dark: lightChangedIcon }; + else iconPath = new vscode.ThemeIcon('file'); this.command = { title: 'Open', command: 'vscode.openWith', arguments: [this.appmap.descriptor.resourceUri, 'appmap.views.appMapFile'], }; + this.tooltip = appmap.descriptor.metadata?.name; + this.iconPath = iconPath; this.contextValue = 'appmap.views.appmaps.appMap'; } @@ -166,10 +197,29 @@ class AppMapTreeItem extends vscode.TreeItem implements IAppMapTreeItem { return new AppMapTreeItem(folderTreeItem, appmap, vscode.TreeItemCollapsibleState.None); }); } -} -type SortFunction = (a: AppMapLoader, b: AppMapLoader) => number; -type NameFunction = (name: string) => string; + // Here you can specify the name normalize function used for different recording types. + static NormalizeName: Record = { + requests: AppMapTreeItem.goodUrlName, + request: AppMapTreeItem.goodUrlName, + remote: AppMapTreeItem.identityName, + tests: AppMapTreeItem.identityName, + }; + + protected static normalizeName(name: string, recorderType: string): string { + const normalizeFn = AppMapTreeItem.NormalizeName[recorderType] || AppMapTreeItem.identityName; + return normalizeFn(name); + } + + protected static identityName(name: string): string { + return name; + } + + // Double forward slashes are sometimes observed in URL paths and they make it into the AppMap name. + protected static goodUrlName(name: string): string { + return name.replace(/\/{2,}/g, '/'); + } +} export class AppMapTreeDataProvider implements vscode.TreeDataProvider { private appmaps: AppMapCollection; @@ -179,23 +229,6 @@ export class AppMapTreeDataProvider implements vscode.TreeDataProvider(); public readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - // Here you can specify the sort function used for different recording types. - // Typical choices are alphabetical and chronological. - static SortMethod: Record = { - requests: AppMapTreeDataProvider.sortByTimestamp, - request: AppMapTreeDataProvider.sortByTimestamp, - remote: AppMapTreeDataProvider.sortByName, - tests: AppMapTreeDataProvider.sortByName, - }; - - // Here you can specify the name normalize function used for different recording types. - static NormalizeName: Record = { - requests: AppMapTreeDataProvider.goodUrlName, - request: AppMapTreeDataProvider.goodUrlName, - remote: AppMapTreeDataProvider.identityName, - tests: AppMapTreeDataProvider.identityName, - }; - constructor(appmaps: AppMapCollection, appmapsUptodate?: AppmapUptodateService) { this.appmaps = appmaps; this.appmaps.onUpdated(() => this._onDidChangeTreeData.fire(undefined)); @@ -246,61 +279,13 @@ export class AppMapTreeDataProvider implements vscode.TreeDataProvider { - return undefined; + getParent(element: AppMapTreeItem): vscode.ProviderResult { + return element.parent; } } From b1b5a941614a7469ecacaa7f321c7e6b2d5f62a6 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Fri, 29 Mar 2024 15:34:45 -0400 Subject: [PATCH 4/6] wip --- .../appmaps/appmapsTreeSort.test.ts | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 test/integration/appmaps/appmapsTreeSort.test.ts diff --git a/test/integration/appmaps/appmapsTreeSort.test.ts b/test/integration/appmaps/appmapsTreeSort.test.ts deleted file mode 100644 index 5a9d88d5..00000000 --- a/test/integration/appmaps/appmapsTreeSort.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -// @project project-java -import assert from 'assert'; -import { initializeWorkspace, waitFor, waitForExtension, withAuthenticatedUser } from '../util'; -import { IAppMapTreeItem } from '../../../src/tree/appMapTreeDataProvider'; - -describe('AppMaps', () => { - withAuthenticatedUser(); - - beforeEach(initializeWorkspace); - beforeEach(waitForExtension); - afterEach(initializeWorkspace); - - it('java requests are sorted by timestamps', async () => { - const trees = (await waitForExtension()).trees; - - const appmapsTree = trees.appmaps; - - await waitFor( - `AppMaps tree first root should be 'project-java'`, - () => - appmapsTree - .getChildren() - .map((root) => root.label) - .sort() - .shift() === 'project-java' - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const projectJava = appmapsTree.getChildren()[0] as any; - - await waitFor( - `'project-java' should contain one child`, - () => appmapsTree.getChildren(projectJava).length === 1 - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const requestFolder = appmapsTree.getChildren(projectJava)[0] as any; - - await waitFor( - `'Request (java + request_recording)' should contain three children`, - () => appmapsTree.getChildren(requestFolder)?.length === 3 - ); - - let appmaps = appmapsTree.getChildren(requestFolder); - // Since timestamps reflect the file modification time we manually set - // timestamps here to assert that the sort is done by timestamps. - // See: AppMapCollectionFile.collectAppMapDescriptor - const b = appmaps.find((a) => a.label?.toString().startsWith('GET /bups')) as IAppMapTreeItem; - const v = appmaps.find((a) => a.label?.toString().startsWith('GET /vets')) as IAppMapTreeItem; - const o = appmaps.find((a) => a.label?.toString().startsWith('GET /oups')) as IAppMapTreeItem; - assert(b.appmap); - assert(v.appmap); - assert(o.appmap); - - if (b) b.appmap.descriptor.timestamp = 1530; - if (v) v.appmap.descriptor.timestamp = 1527; - if (o) o.appmap.descriptor.timestamp = 1522; - // getChildren should sort them by timestamp - appmaps = appmapsTree.getChildren(requestFolder); - - assert.deepStrictEqual( - appmaps.map((appmap) => appmap.label), - [ - 'GET /bups (500) - 15:30:47.872', - 'GET /vets.html (200) - 15:27:11.736', - 'GET /oups (500) - 15:22:47.872', - ] - ); - }); -}); From a0f32b0d56ccda529643552eec52d3c80aeda24e Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Fri, 29 Mar 2024 16:57:41 -0400 Subject: [PATCH 5/6] wip tests --- src/tree/appMapTreeDataProvider.ts | 10 ++- test/unit/mock/vscode/ThemeIcon.ts | 3 + test/unit/mock/vscode/TreeItem.ts | 8 ++ test/unit/mock/vscode/index.ts | 12 ++- test/unit/tree/appMapTreeDataProvider.test.ts | 82 +++++++++++++++++++ 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 test/unit/mock/vscode/ThemeIcon.ts create mode 100644 test/unit/mock/vscode/TreeItem.ts create mode 100644 test/unit/tree/appMapTreeDataProvider.test.ts diff --git a/src/tree/appMapTreeDataProvider.ts b/src/tree/appMapTreeDataProvider.ts index 46b3240d..db1751d9 100644 --- a/src/tree/appMapTreeDataProvider.ts +++ b/src/tree/appMapTreeDataProvider.ts @@ -39,9 +39,11 @@ class AppMapFolder { ): string { let name: string; if (recorderType && recorderType.length > 1) { - name = `${recorderType[0].toLocaleUpperCase()}${recorderType.slice( - 1 - )} (${language} + ${recorderName})`; + const baseName = `${recorderType[0].toLocaleUpperCase()}${recorderType.slice(1)}`; + const descriptorName = [language, recorderName].filter(Boolean).join(' + '); + const tokens = [baseName]; + if (descriptorName) tokens.push(`(${descriptorName})`); + name = tokens.join(' '); } else if (recorderName) { name = recorderName; } else { @@ -259,7 +261,7 @@ export class AppMapTreeDataProvider implements vscode.TreeDataProvider(); - const projectsWithAppMaps = this.appmaps.allAppMaps().reduce((projectsWithAppMaps, appmap) => { + const projectsWithAppMaps = this.appmaps.appMaps().reduce((projectsWithAppMaps, appmap) => { const project = projects.find((project) => appmap.descriptor.resourceUri.fsPath.startsWith(project.uri.fsPath) ); diff --git a/test/unit/mock/vscode/ThemeIcon.ts b/test/unit/mock/vscode/ThemeIcon.ts new file mode 100644 index 00000000..7909c4c2 --- /dev/null +++ b/test/unit/mock/vscode/ThemeIcon.ts @@ -0,0 +1,3 @@ +export default class ThemeIcon { + constructor(public id: string) {} +} diff --git a/test/unit/mock/vscode/TreeItem.ts b/test/unit/mock/vscode/TreeItem.ts new file mode 100644 index 00000000..0de5a416 --- /dev/null +++ b/test/unit/mock/vscode/TreeItem.ts @@ -0,0 +1,8 @@ +export default class TreeItem { + public contextValue: string | undefined; + public iconPath: string | undefined; + public tooltip: string | undefined; + public command: unknown | undefined; + + constructor(public label: string, public collapsibleState: number) {} +} diff --git a/test/unit/mock/vscode/index.ts b/test/unit/mock/vscode/index.ts index 0022929a..ab8a3cdb 100644 --- a/test/unit/mock/vscode/index.ts +++ b/test/unit/mock/vscode/index.ts @@ -9,6 +9,8 @@ import CodeAction from './CodeAction'; import CodeActionKind from './CodeActionKind'; import * as extensions from './extensions'; import { URI, Utils } from 'vscode-uri'; +import ThemeIcon from './ThemeIcon'; +import TreeItem from './TreeItem'; import workspace from './workspace'; import window from './window'; import commands from './commands'; @@ -24,6 +26,12 @@ enum StatusBarAlignment { Right = 2, } +enum TreeItemCollapsibleState { + None = 0, + Collapsed = 1, + Expanded = 2, +} + const MockVSCode = { EventEmitter, Terminal, @@ -39,7 +47,9 @@ const MockVSCode = { commands, StatusBarAlignment, env, - TreeItem: class {}, + ThemeIcon, + TreeItem, + TreeItemCollapsibleState, UIKind, }; diff --git a/test/unit/tree/appMapTreeDataProvider.test.ts b/test/unit/tree/appMapTreeDataProvider.test.ts new file mode 100644 index 00000000..01fde563 --- /dev/null +++ b/test/unit/tree/appMapTreeDataProvider.test.ts @@ -0,0 +1,82 @@ +import '../mock/vscode'; +import Sinon, { SinonSandbox } from 'sinon'; +import * as vscode from 'vscode'; +import { expect } from 'chai'; + +import { AppMapTreeDataProvider, IAppMapTreeItem } from '../../../src/tree/appMapTreeDataProvider'; +import AppMapCollection from '../../../src/services/appmapCollection'; +import { AppmapUptodateService } from '../../../src/services/appmapUptodateService'; +import AppMapLoader from '../../../src/services/appmapLoader'; + +describe('AppMapTreeDataProvider', () => { + let sinon: SinonSandbox; + let appmaps: AppMapCollection; + let provider: AppMapTreeDataProvider; + + const APPMAPS: AppMapLoader[] = [ + { + descriptor: { + metadata: { + language: { name: 'ruby', version: '2.7.2' }, + recorder: { + type: 'tests', + name: 'rspec', + }, + }, + resourceUri: vscode.Uri.file('/path/to/project-a/tmp/appmap/appmap_1.json'), + }, + } as AppMapLoader, + { + descriptor: { + metadata: { + language: { name: 'ruby', version: '2.7.2' }, + recorder: { + type: 'requests', + }, + }, + resourceUri: vscode.Uri.file('/path/to/project-a/tmp/appmap/appmap_2.json'), + }, + } as AppMapLoader, + ]; + + beforeEach(() => (sinon = Sinon.createSandbox())); + afterEach(() => sinon.restore()); + + beforeEach(() => { + sinon.stub(vscode.workspace, 'workspaceFolders').value([ + { + index: 0, + name: 'project-a', + uri: vscode.Uri.file('/path/to/project-a'), + }, + ]); + + appmaps = { + appMaps: Sinon.stub().returns(APPMAPS), + onUpdated: Sinon.stub(), + } as unknown as AppMapCollection; + provider = new AppMapTreeDataProvider(appmaps); + }); + + describe('getRootElement', () => { + it('returns the root element', async () => { + const roots = provider.getChildren(undefined); + expect(roots.map((item) => item.label)).to.deep.equal(['project-a']); + }); + }); + + describe('Project folder items', () => { + function getProject() { + return provider.getChildren(undefined)[0] as IAppMapTreeItem & vscode.TreeItem; + } + + it('exist for each recording type', () => { + const project = getProject(); + const folderItems = provider.getChildren(project); + expect(folderItems.map((item) => item.label)).to.deep.equal([ + 'Requests (ruby)', + 'Tests (ruby + rspec)', + ]); + }); + }); +}); From a1da37e977fd4ab063fe02b5e337618a93a13e35 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Mon, 1 Apr 2024 11:58:47 -0400 Subject: [PATCH 6/6] wip --- src/tree/appMapTreeDataProvider.ts | 5 +- test/unit/tree/appMapTreeDataProvider.test.ts | 325 +++++++++++++++--- 2 files changed, 276 insertions(+), 54 deletions(-) diff --git a/src/tree/appMapTreeDataProvider.ts b/src/tree/appMapTreeDataProvider.ts index db1751d9..01c0ec60 100644 --- a/src/tree/appMapTreeDataProvider.ts +++ b/src/tree/appMapTreeDataProvider.ts @@ -14,9 +14,9 @@ const LABEL_NO_NAME = 'Untitled AppMap'; export interface IAppMapTreeItem { appmap: AppMapLoader | undefined; - parent: IAppMapTreeItem | undefined; + parent: (vscode.TreeItem & IAppMapTreeItem) | undefined; - children: IAppMapTreeItem[]; + children: (vscode.TreeItem & IAppMapTreeItem)[]; } class AppMapFolder { @@ -139,7 +139,6 @@ class FolderTreeItem extends vscode.TreeItem implements IAppMapTreeItem { let { recorderType } = folderContent.folder; if (!recorderType) recorderType = 'unknown recorder type'; const sortFunction = FolderTreeItem.SortMethod[recorderType] || FolderTreeItem.sortByName; - warn(`Sorting ${appmaps.length} appmaps by ${recorderType}`); // TODO: Remove folderContent.appmaps.sort(sortFunction); return new FolderTreeItem(workspaceTreeItem, folderContent.folder, folderContent.appmaps); diff --git a/test/unit/tree/appMapTreeDataProvider.test.ts b/test/unit/tree/appMapTreeDataProvider.test.ts index 01fde563..04c8c12e 100644 --- a/test/unit/tree/appMapTreeDataProvider.test.ts +++ b/test/unit/tree/appMapTreeDataProvider.test.ts @@ -5,78 +5,301 @@ import { expect } from 'chai'; import { AppMapTreeDataProvider, IAppMapTreeItem } from '../../../src/tree/appMapTreeDataProvider'; import AppMapCollection from '../../../src/services/appmapCollection'; -import { AppmapUptodateService } from '../../../src/services/appmapUptodateService'; import AppMapLoader from '../../../src/services/appmapLoader'; +import { build } from 'tsup'; describe('AppMapTreeDataProvider', () => { let sinon: SinonSandbox; - let appmaps: AppMapCollection; let provider: AppMapTreeDataProvider; - const APPMAPS: AppMapLoader[] = [ - { - descriptor: { - metadata: { - language: { name: 'ruby', version: '2.7.2' }, - recorder: { - type: 'tests', - name: 'rspec', + beforeEach(() => (sinon = Sinon.createSandbox())); + afterEach(() => sinon.restore()); + + function findProject(label: string) { + return provider + .getChildren(undefined) + .find((treeItem) => treeItem.label === label) as IAppMapTreeItem & vscode.TreeItem; + } + + function getProjectA() { + return findProject('project-a'); + } + + function getProjectB() { + return findProject('project-b'); + } + + function buildSingleProjectWorkspace(appmaps: AppMapLoader[]): () => void { + return function () { + sinon.stub(vscode.workspace, 'workspaceFolders').value([ + { + index: 0, + name: 'project-a', + uri: vscode.Uri.file('/path/to/project-a'), + }, + ]); + + const appmapCollection = { + appMaps: Sinon.stub().returns(appmaps), + onUpdated: Sinon.stub(), + } as unknown as AppMapCollection; + provider = new AppMapTreeDataProvider(appmapCollection); + }; + } + + describe('in a single project workspace', () => { + const appmaps: AppMapLoader[] = [ + { + descriptor: { + metadata: { + name: 'appmap_1', + language: { name: 'ruby', version: '2.7.2' }, + recorder: { + type: 'tests', + name: 'rspec', + }, }, + resourceUri: vscode.Uri.file('/path/to/project-a/tmp/appmap/appmap_1.json'), }, - resourceUri: vscode.Uri.file('/path/to/project-a/tmp/appmap/appmap_1.json'), - }, - } as AppMapLoader, - { - descriptor: { - metadata: { - language: { name: 'ruby', version: '2.7.2' }, - recorder: { - type: 'requests', + } as AppMapLoader, + { + descriptor: { + metadata: { + name: 'appmap_2', + language: { name: 'ruby', version: '2.7.2' }, + recorder: { + type: 'requests', + }, }, + resourceUri: vscode.Uri.file('/path/to/project-a/tmp/appmap/appmap_2.json'), }, - resourceUri: vscode.Uri.file('/path/to/project-a/tmp/appmap/appmap_2.json'), - }, - } as AppMapLoader, - ]; + } as AppMapLoader, + ]; - beforeEach(() => (sinon = Sinon.createSandbox())); - afterEach(() => sinon.restore()); + beforeEach(buildSingleProjectWorkspace(appmaps)); - beforeEach(() => { - sinon.stub(vscode.workspace, 'workspaceFolders').value([ - { - index: 0, - name: 'project-a', - uri: vscode.Uri.file('/path/to/project-a'), - }, - ]); - - appmaps = { - appMaps: Sinon.stub().returns(APPMAPS), - onUpdated: Sinon.stub(), - } as unknown as AppMapCollection; - provider = new AppMapTreeDataProvider(appmaps); + describe('getRootElements', () => { + it('returns the single workspace', async () => { + const roots = provider.getChildren(undefined); + expect(roots.map((item) => item.label)).to.deep.equal(['project-a']); + }); + }); + + describe('appmap folders', () => { + it('exist for each recording type', () => { + const project = getProjectA(); + const folderItems = provider.getChildren(project); + expect(folderItems.map((item) => item.label)).to.deep.equal([ + 'Requests (ruby)', + 'Tests (ruby + rspec)', + ]); + }); + }); + + describe('AppMap items', () => { + function getFolderItem(label: string) { + return getProjectA().children.find((folder) => folder.label === label); + } + + it('exist for each AppMap', () => { + { + const folderItem = getFolderItem('Tests (ruby + rspec)'); + const appmapItems = provider.getChildren(folderItem); + expect(appmapItems.map((item) => item.label)).to.deep.equal(['appmap_1']); + } + { + const folderItem = getFolderItem('Requests (ruby)'); + const appmapItems = provider.getChildren(folderItem); + expect(appmapItems.map((item) => item.label)).to.deep.equal(['appmap_2']); + } + }); + }); }); - describe('getRootElement', () => { - it('returns the root element', async () => { - const roots = provider.getChildren(undefined); - expect(roots.map((item) => item.label)).to.deep.equal(['project-a']); + describe('requests recordings', () => { + function requestRecording(name: string, timestamp: number): AppMapLoader { + return { + descriptor: { + metadata: { + name, + timestamp, + language: { name: 'ruby', version: '2.7.2' }, + recorder: { + type: 'requests', + }, + }, + timestamp, + resourceUri: vscode.Uri.file(`/path/to/project-a/tmp/appmap/${name}.json`), + }, + } as unknown as AppMapLoader; + } + + const appmaps: AppMapLoader[] = [ + requestRecording('appmap_1', 1), + requestRecording('appmap_2', 3), + requestRecording('appmap_3', 5), + requestRecording('appmap_4', 6), + requestRecording('appmap_5', 4), + requestRecording('appmap_6', 2), + ]; + + beforeEach(buildSingleProjectWorkspace(appmaps)); + + it('are sorted by timestamp with most recent first', () => { + const project = getProjectA(); + const folderItems = provider.getChildren(project); + const appmapItems = provider.getChildren(folderItems[0]); + expect(appmapItems.map((item) => item.label)).to.deep.equal([ + 'appmap_4', + 'appmap_3', + 'appmap_5', + 'appmap_2', + 'appmap_6', + 'appmap_1', + ]); }); }); - describe('Project folder items', () => { - function getProject() { - return provider.getChildren(undefined)[0] as IAppMapTreeItem & vscode.TreeItem; + describe('tests recordings', () => { + function testRecording(name: string, timestamp: number): AppMapLoader { + return { + descriptor: { + metadata: { + name, + timestamp, + language: { name: 'ruby', version: '2.7.2' }, + recorder: { + type: 'tests', + name: 'rspec', + }, + }, + timestamp, + resourceUri: vscode.Uri.file(`/path/to/project-a/tmp/appmap/${name}.json`), + }, + } as unknown as AppMapLoader; } - it('exist for each recording type', () => { - const project = getProject(); + const appmaps: AppMapLoader[] = [ + testRecording('appmap_4', 6), + testRecording('appmap_1', 1), + testRecording('appmap_5', 4), + testRecording('appmap_2', 3), + testRecording('appmap_3', 5), + testRecording('appmap_6', 2), + ]; + + beforeEach(buildSingleProjectWorkspace(appmaps)); + + it('are sorted alphabetically by label', () => { + const project = getProjectA(); const folderItems = provider.getChildren(project); - expect(folderItems.map((item) => item.label)).to.deep.equal([ - 'Requests (ruby)', - 'Tests (ruby + rspec)', + const appmapItems = provider.getChildren(folderItems[0]); + expect(appmapItems.map((item) => item.label)).to.deep.equal([ + 'appmap_1', + 'appmap_2', + 'appmap_3', + 'appmap_4', + 'appmap_5', + 'appmap_6', + ]); + }); + }); + + describe('missing appmap metadata', () => { + const appmaps: AppMapLoader[] = [ + { + descriptor: { + resourceUri: vscode.Uri.file('/path/to/project-a/tmp/appmap/appmap_1.json'), + }, + } as AppMapLoader, + { + descriptor: { + resourceUri: vscode.Uri.file('/path/to/project-a/tmp/appmap/appmap_2.json'), + }, + } as AppMapLoader, + ]; + + beforeEach(buildSingleProjectWorkspace(appmaps)); + + it('is replaced with suitable defaults', () => { + const project = getProjectA(); + const folderItems = provider.getChildren(project); + expect(folderItems.map((item) => item.label)).to.deep.equal(['unspecified language']); + const appmapItems = provider.getChildren(folderItems[0]); + expect(appmapItems.map((item) => item.label)).to.deep.equal([ + 'Untitled AppMap', + 'Untitled AppMap', + ]); + }); + }); + + describe('in a multi-project workspace', () => { + const appmaps: AppMapLoader[] = [ + { + descriptor: { + metadata: { + name: 'appmap_1', + language: { name: 'ruby', version: '2.7.2' }, + recorder: { + type: 'tests', + name: 'rspec', + }, + }, + resourceUri: vscode.Uri.file('/path/to/project-a/tmp/appmap/appmap_1.json'), + }, + } as AppMapLoader, + { + descriptor: { + metadata: { + name: 'appmap_2', + language: { name: 'ruby', version: '2.7.2' }, + recorder: { + type: 'requests', + }, + }, + resourceUri: vscode.Uri.file('/path/to/project-b/tmp/appmap/appmap_2.json'), + }, + } as AppMapLoader, + ]; + + beforeEach(() => { + sinon.stub(vscode.workspace, 'workspaceFolders').value([ + { + index: 0, + name: 'project-a', + uri: vscode.Uri.file('/path/to/project-a'), + }, + { + index: 1, + name: 'project-b', + uri: vscode.Uri.file('/path/to/project-b'), + }, ]); + + const appmapCollection = { + appMaps: Sinon.stub().returns(appmaps), + onUpdated: Sinon.stub(), + } as unknown as AppMapCollection; + provider = new AppMapTreeDataProvider(appmapCollection); + }); + + describe('appmap folders', () => { + it('are placed in the proper projects', () => { + { + const project = getProjectA(); + const folderItems = provider.getChildren(project); + expect(folderItems.map((item) => item.label)).to.deep.equal(['Tests (ruby + rspec)']); + const appmapItems = provider.getChildren(folderItems[0]); + expect(appmapItems.map((item) => item.label)).to.deep.equal(['appmap_1']); + } + { + const project = getProjectB(); + const folderItems = provider.getChildren(project); + expect(folderItems.map((item) => item.label)).to.deep.equal(['Requests (ruby)']); + const appmapItems = provider.getChildren(folderItems[0]); + expect(appmapItems.map((item) => item.label)).to.deep.equal(['appmap_2']); + } + }); }); }); });