import * as assert from 'assert'; import * as path from 'path'; import * as vscode from 'vscode'; import { closeOthers, closeTab, togglePin } from '../../actions'; import { ProjectDecorations, badgeFor } from '../../decorations'; import { projectColorIndex, projectOf } from '../../projects'; import { TabEntry, buildEntries, describeEntry, duplicateLabels, sortEntries, tabUri, } from '../../tabs'; /** Files in three different projects of the test solution. */ const FILES = [ path.join('Nerfed.Runtime', 'Profiler.cs'), path.join('Nerfed.Editor', 'Systems', 'EditorProfilerWindow.cs'), path.join('Nerfed.Builder', 'Program.cs'), ]; const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); function root(): vscode.Uri { const folder = vscode.workspace.workspaceFolders?.[0]; assert.ok(folder, 'no workspace folder'); return folder.uri; } function uriFor(relative: string): vscode.Uri { return vscode.Uri.joinPath(root(), ...relative.split(path.sep)); } async function closeEverything(): Promise { // Not closeAllEditors: that command leaves *pinned* editors open, which would leak // into the next test. The tab API closes them regardless. const tabs = vscode.window.tabGroups.all.flatMap(group => group.tabs); if (tabs.length > 0) { await vscode.window.tabGroups.close(tabs, true); } await sleep(200); const left = vscode.window.tabGroups.all.flatMap(group => group.tabs); assert.deepStrictEqual(left.map(tab => tab.label), [], 'tabs survived the cleanup'); } /** Resolves the project for every open tab, the way the provider does. */ async function projectMap(): Promise> { const map = new Map(); for (const group of vscode.window.tabGroups.all) { for (const tab of group.tabs) { const uri = tabUri(tab); if (uri) { map.set(uri.toString(), await projectOf(uri)); } } } return map; } async function entries(order: 'open' | 'project' | 'name' = 'open', pinnedFirst = true) { return buildEntries(vscode.window.tabGroups.all, await projectMap(), { order, pinnedFirst }); } suite('Project resolution', () => { test('a file resolves to its nearest .csproj', async () => { assert.strictEqual(await projectOf(uriFor(FILES[0])), 'Nerfed.Runtime'); assert.strictEqual(await projectOf(uriFor(FILES[1])), 'Nerfed.Editor'); }); test('a file outside any project resolves to nothing', async () => { // README.md sits at the solution root, above every .csproj. assert.strictEqual(await projectOf(uriFor('README.md')), undefined); }); test('project colours are stable and spread across the palette', () => { const first = projectColorIndex('Nerfed.Runtime'); assert.strictEqual(projectColorIndex('Nerfed.Runtime'), first, 'the same project must always get the same colour'); assert.notStrictEqual(projectColorIndex('Nerfed.Editor'), first, 'these two projects should not collide'); assert.ok(first >= 1 && first <= 12, `palette slot out of range: ${first}`); const slots = new Set(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L'] .map(name => projectColorIndex(`Project.${name}`))); assert.ok(slots.size >= 6, `12 projects only used ${slots.size} slots`); assert.strictEqual(projectColorIndex(undefined), 0, 'no project means slot 0'); }); }); suite('File decorations', () => { test('a badge keeps sibling projects apart', () => { assert.strictEqual(badgeFor('Acme.Shop.Client'), 'AC'); assert.strictEqual(badgeFor('Acme.Shop.Server'), 'AS'); assert.strictEqual(badgeFor('Nerfed.Runtime'), 'NR'); assert.strictEqual(badgeFor('Nerfed.Editor'), 'NE'); // A single-word project has no last segment to borrow from. assert.strictEqual(badgeFor('MoonWorks'), 'Mo'); assert.strictEqual(badgeFor('a'), 'A', 'badges are uppercased'); assert.strictEqual(badgeFor(''), '?'); }); test('decorations name the project and pick its palette colour', async () => { const decorations = new ProjectDecorations(); decorations.setMode('colorAndBadge'); const inProject = uriFor(FILES[0]); const decoration = await decorations.provideFileDecoration(inProject); assert.ok(decoration, 'a file inside a project should be decorated'); assert.strictEqual(decoration.badge, 'NR'); assert.strictEqual(decoration.tooltip, 'Project: Nerfed.Runtime'); assert.deepStrictEqual(decoration.color, new vscode.ThemeColor(`verticalTabs.project${projectColorIndex('Nerfed.Runtime')}`)); // Colouring parents would repaint most of the Explorer. assert.strictEqual(decoration.propagate, false); // Nothing outside a project, and nothing when switched off. assert.strictEqual(await decorations.provideFileDecoration(uriFor('README.md')), undefined); decorations.setMode('off'); assert.strictEqual(await decorations.provideFileDecoration(inProject), undefined); decorations.dispose(); }); }); suite('Tab list', () => { suiteSetup(async () => { const ext = vscode.extensions.getExtension('local.vertical-tabs'); assert.ok(ext, 'extension not found'); await ext.activate(); }); setup(closeEverything); suiteTeardown(closeEverything); test('every open editor becomes one entry, with its project', async () => { for (const file of FILES) { await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)), { preview: false }); } await sleep(300); const list = await entries(); assert.strictEqual(list.length, FILES.length, 'one entry per open editor'); assert.deepStrictEqual(list.map(e => e.label), FILES.map(f => path.basename(f))); assert.deepStrictEqual(list.map(e => e.project), ['Nerfed.Runtime', 'Nerfed.Editor', 'Nerfed.Builder']); assert.ok(list.every(e => e.uri && e.canActivate), 'text tabs must be activatable'); assert.strictEqual(list.filter(e => e.isActive).length, 1, 'exactly one active tab'); }); test('open order matches the tab order, and the other orders sort', async () => { for (const file of FILES) { await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)), { preview: false }); } await sleep(300); const byName = await entries('name'); assert.deepStrictEqual(byName.map(e => e.label), [...byName.map(e => e.label)].sort((a, b) => a.localeCompare(b))); const byProject = await entries('project'); assert.deepStrictEqual(byProject.map(e => e.project), ['Nerfed.Builder', 'Nerfed.Editor', 'Nerfed.Runtime']); }); test('pinnedFirst lifts a pinned tab above the sort order', async () => { for (const file of FILES) { await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)), { preview: false }); } await sleep(300); // Pin the *last* opened editor, which is the active one. Program.cs also happens // to sort last by name, so pinnedFirst has something to prove. await vscode.commands.executeCommand('workbench.action.pinEditor'); await sleep(300); const list = await entries(); assert.ok(list[0].isPinned, `first entry should be pinned, got ${list[0].label}`); assert.strictEqual(list[0].label, path.basename(FILES[2])); assert.strictEqual(list.filter(e => e.isPinned).length, 1); // VS Code moves a pinned tab to the front of its group, so `open` order shows it // first whether or not we sort pinned tabs up. Name order is the honest test. assert.strictEqual((await entries('name', true))[0].label, path.basename(FILES[2])); assert.deepStrictEqual((await entries('name', false)).map(e => e.label), ['EditorProfilerWindow.cs', 'Profiler.cs', 'Program.cs']); }); test('togglePin pins and unpins a tab that is not the active one', async () => { for (const file of FILES) { await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)), { preview: false }); } await sleep(300); const target = (await entries()).find(e => e.label === path.basename(FILES[0])); assert.ok(target, 'target tab not in the list'); assert.ok(!target.tab.isPinned); await togglePin(target.tab); await sleep(400); assert.deepStrictEqual((await entries()).filter(e => e.isPinned).map(e => e.label), [path.basename(FILES[0])]); // The second call has to read the *live* pinned state, not the stale snapshot in // `target` — getting that wrong made unpin a no-op. await togglePin(target.tab); await sleep(400); assert.deepStrictEqual((await entries()).filter(e => e.isPinned).map(e => e.label), []); }); test('close closes the given tab, and close others closes the rest', async () => { for (const file of FILES) { await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)), { preview: false }); } await sleep(300); const first = (await entries()).find(e => e.label === path.basename(FILES[1])); assert.ok(first); await closeTab(first.tab); await sleep(300); assert.deepStrictEqual((await entries()).map(e => e.label), [path.basename(FILES[0]), path.basename(FILES[2])]); const keep = (await entries()).find(e => e.label === path.basename(FILES[0])); assert.ok(keep); await closeOthers(keep.tab); await sleep(300); assert.deepStrictEqual((await entries()).map(e => e.label), [path.basename(FILES[0])]); }); test('close others keeps pinned tabs', async () => { for (const file of FILES) { await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)), { preview: false }); } await sleep(300); // Pin one tab, then close others from a different one. const pin = (await entries()).find(e => e.label === path.basename(FILES[1])); assert.ok(pin); await togglePin(pin.tab); await sleep(400); const from = (await entries()).find(e => e.label === path.basename(FILES[0])); assert.ok(from, 'the tab to keep is gone'); await closeOthers(from.tab); await sleep(400); const left = (await entries()).map(e => e.label).sort(); assert.deepStrictEqual(left, [path.basename(FILES[1]), path.basename(FILES[0])].sort(), 'the pinned tab and the kept tab should both survive'); }); test('a diff tab reports its modified side and stays activatable', async () => { await vscode.commands.executeCommand('vscode.diff', uriFor(FILES[0]), uriFor(FILES[1]), 'Profiler vs Editor'); await sleep(400); const list = await entries(); assert.strictEqual(list.length, 1, 'expected only the diff tab'); assert.ok(list[0].uri, 'a diff tab should expose a resource'); assert.strictEqual(list[0].uri.fsPath, uriFor(FILES[1]).fsPath, 'the modified side identifies the diff'); assert.ok(list[0].canActivate); }); test('a webview tab is listed but reports that it cannot be activated', async () => { await vscode.commands.executeCommand('workbench.action.openSettings'); await sleep(600); const list = await entries(); const settings = list.find(e => !e.uri); assert.ok(settings, `no non-file tab found among ${list.map(e => e.label).join(', ')}`); assert.ok(!settings.canActivate, 'a webview tab must not claim to be activatable'); assert.ok(settings.id.startsWith('1:label:'), `unexpected id ${settings.id}`); }); test('the detail text never repeats the project inside the directory', () => { const all = { showProject: true, showDirectory: true, showColumn: false }; // A .csproj-per-directory layout: the directory already names the project. const same = { label: 'Profiler.cs', dir: 'Nerfed.Runtime', project: 'Nerfed.Runtime' } as TabEntry; assert.strictEqual(describeEntry(same, all), 'Nerfed.Runtime'); // A subdirectory of the project: still no need to repeat it. const nested = { label: 'Transform.cs', dir: 'Nerfed.Runtime/Util', project: 'Nerfed.Runtime', } as TabEntry; assert.strictEqual(describeEntry(nested, all), 'Nerfed.Runtime/Util'); // A Unity-style layout, where the directory says nothing about the assembly. const unrelated = { label: 'ClientTranslator.cs', dir: 'Assets/Scripts/Client', project: 'Acme.Shop.Client', } as TabEntry; assert.strictEqual(describeEntry(unrelated, all), 'Assets/Scripts/Client Acme.Shop.Client'); }); test('the detail text can be reduced to nothing', () => { const entry = { label: 'Profiler.cs', dir: 'Nerfed.Runtime', project: 'Nerfed.Runtime', column: 1, } as TabEntry; assert.strictEqual( describeEntry(entry, { showProject: false, showDirectory: true, showColumn: false }), 'Nerfed.Runtime'); assert.strictEqual( describeEntry(entry, { showProject: true, showDirectory: false, showColumn: false }), 'Nerfed.Runtime'); assert.strictEqual( describeEntry(entry, { showProject: false, showDirectory: false, showColumn: true }), '#1'); assert.strictEqual( describeEntry(entry, { showProject: false, showDirectory: false, showColumn: false }), '', 'the file name should be able to have the whole row'); }); test('only names shared by more than one tab count as ambiguous', () => { const entry = (label: string) => ({ label } as TabEntry); assert.deepStrictEqual( [...duplicateLabels([entry('a.cs'), entry('b.cs'), entry('a.cs')])], ['a.cs']); assert.deepStrictEqual([...duplicateLabels([entry('a.cs'), entry('b.cs')])], []); assert.deepStrictEqual([...duplicateLabels([])], []); }); test('sorting is stable for entries that compare equal', () => { const make = (label: string, index: number) => ({ id: String(index), label, dir: '', project: 'P', isPinned: false, } as TabEntry); const list = [make('a', 0), make('a', 1), make('a', 2)]; const sorted = sortEntries(list, { order: 'project', pinnedFirst: true }); assert.deepStrictEqual(sorted.map(e => e.id), ['0', '1', '2']); }); });