/**
* Unit tests for the public IColourfulTabs API as the plugin hands it out -
* the two paths that colour a tab (the context menu's stored map and
* `setColour`) must not fight over it (DEF-COLOUR-5), and `setColour` must not
* echo back through `colourChanged`.
*/
// the tsconfig only auto-includes jest types; pull in node's for `require`
///
import { JupyterFrontEnd } from '@jupyterlab/application';
import { Token } from '@lumino/coreutils';
import { Signal } from '@lumino/signaling';
import { Widget } from '@lumino/widgets';
import { IColourChoice, IColourfulTabs } from '../tokens';
// The real @jupyterlab/terminal drags in xterm's untransformed ESM; the plugin
// only lists its ITerminalTracker token as optional, so a stand-in suffices.
jest.mock('@jupyterlab/terminal', () => ({
ITerminalTracker: new Token('test:ITerminalTracker')
}));
// Likewise @jupyterlab/ui-components drags in @jupyter/react-components ESM;
// activation only constructs LabIcons and hands them to addCommand, so an
// inert stand-in class suffices.
jest.mock('@jupyterlab/ui-components', () => ({
LabIcon: class {
constructor(public options: unknown) {}
}
}));
// The fingerprint client is the plugin's entire server dependency; stubbed so
// a test states exactly what the server said.
jest.mock('../fingerprints', () => ({
fetchTerminalFingerprints: jest.fn()
}));
// jsdom ships no `CSS` object, and `releaseMenuColour` escapes the widget id
// with `CSS.escape`. The ids used here need no escaping.
if (typeof (globalThis as any).CSS === 'undefined') {
(globalThis as any).CSS = { escape: (value: string) => value };
}
const STORAGE_KEY = 'jupyterlab-colourful-tab-colours';
/** Drain the microtask queue so promise handlers settle. */
async function flushPromises(): Promise {
for (let i = 0; i < 5; i++) {
await Promise.resolve();
}
}
/** The dock panel and its tab bar, in the shape the plugin's selectors need. */
function makeDockPanel(): void {
document.body.innerHTML =
'';
}
/** A dock tab as the shell renders it - `data-id` is the widget id. */
function addTab(widgetId: string, titleAttr: string): HTMLElement {
const tab = document.createElement('div');
tab.className = 'lm-TabBar-tab';
tab.dataset.id = widgetId;
tab.setAttribute('title', titleAttr);
(document.querySelector('.lm-DockPanel-tabBar') as HTMLElement).appendChild(
tab
);
return tab;
}
/** A widget stand-in; `setColour` reads the id and the title and nothing else. */
function widget(id: string): Widget {
return {
id,
isDisposed: false,
title: { className: '' }
} as unknown as Widget;
}
interface IHarness {
api: IColourfulTabs;
/** Resolve `app.restored`, which is what triggers the first repaint. */
finishRestore: () => void;
}
/**
* Activate a fresh plugin instance - the plugin holds its colour map, claims
* and fingerprints at module scope, so each test needs its own module.
*/
function activatePlugin(options: {
fingerprints: Record | null;
terminals?: Array<{ widgetId: string; sessionName: string }>;
}): IHarness {
jest.resetModules();
(
require('../fingerprints').fetchTerminalFingerprints as jest.Mock
).mockResolvedValue(options.fingerprints);
const plugin = require('../index').default;
let finishRestore = (): void => undefined;
const restored = new Promise(resolve => {
finishRestore = resolve;
});
const app = {
serviceManager: {
terminals: { runningChanged: new Signal({}) }
},
commands: { addCommand: () => undefined },
restored
} as unknown as JupyterFrontEnd;
const tracker = {
forEach: (fn: (w: unknown) => void) =>
(options.terminals ?? []).forEach(t =>
fn({
id: t.widgetId,
content: { session: { model: { name: t.sessionName } } }
})
)
};
return { api: plugin.activate(app, tracker, null), finishRestore };
}
// The plugin debounces its DOM refresh on a 50ms timer and a module instance
// outlives the test that activated it; a fake clock keeps a late refresh out
// of the next test's DOM.
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(console, 'log').mockImplementation(() => undefined);
localStorage.clear();
document.body.innerHTML = '';
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
document.body.innerHTML = '';
});
describe('stored menu colour against an API colour', () => {
const terminals = [{ widgetId: 'terminal-1', sessionName: '3' }];
it('never repaints a stored colour over the colour an unclaimed tab shows', async () => {
// ACC-ACTIVE-12, DEF-COLOUR-5. The entry survives `setColour` whenever the
// user picks from the menu on an API-tinted tab, and Lumino then rewrites
// the class attribute from `title.className` on the next re-render.
// Repainting the stored colour over it is the brown -> blue -> brown fight
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ 'terminal:3': { colour: 2, fp: 'pty-a' } })
);
makeDockPanel();
const h = activatePlugin({ fingerprints: { '3': 'pty-a' }, terminals });
await flushPromises();
const tab = addTab('terminal-1', '');
// What Lumino renders onto the tab from `title.className` after setColour
tab.classList.add('jp-colourful-tab-sky');
h.finishRestore();
await flushPromises();
expect(tab.classList.contains('jp-colourful-tab-sky')).toBe(true);
expect(tab.classList.contains('jp-colourful-tab-lemon')).toBe(false);
});
});
describe('a claimed file tab', () => {
it('repaints a stored file colour onto a claimed file tab the owner has left blank', async () => {
// A path is a real identity, so its entry carries no terminal fingerprint.
// Requiring one skipped the claimed tab, and the user's colour vanished on
// reload even though the same file was still open at the same path
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ '/home/lab/x.ipynb': 2 })
);
makeDockPanel();
const h = activatePlugin({ fingerprints: {} });
await flushPromises();
h.api.claim(widget('file-1'));
const tab = addTab('file-1', 'Name: x.ipynb\nPath: /home/lab/x.ipynb');
h.finishRestore();
await flushPromises();
expect(tab.classList.contains('jp-colourful-tab-lemon')).toBe(true);
});
});
describe('colourChanged against setColour', () => {
it('announces nothing when a consumer sets or clears a colour', async () => {
// ACC-API-35. The signal carries the user's menu choices only; a consumer
// that answers a choice with `setColour` would otherwise hear its own call
// back as a choice and loop
makeDockPanel();
addTab('terminal-1', '');
const h = activatePlugin({
fingerprints: { '3': 'pty-a' },
terminals: [{ widgetId: 'terminal-1', sessionName: '3' }]
});
await flushPromises();
const seen: IColourChoice[] = [];
h.api.colourChanged.connect((_, choice) => seen.push(choice));
const tinted = widget('terminal-1');
h.api.setColour(tinted, 'sky');
h.api.setColour(tinted, null);
expect(seen).toEqual([]);
});
});