toolbar.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. CommandToolbarButton,
  5. ReactiveToolbar,
  6. SessionContext,
  7. Toolbar,
  8. ToolbarButton
  9. } from '@jupyterlab/apputils';
  10. import {
  11. createSessionContext,
  12. framePromise,
  13. JupyterServer
  14. } from '@jupyterlab/testutils';
  15. import { toArray } from '@lumino/algorithm';
  16. import { CommandRegistry } from '@lumino/commands';
  17. import { ReadonlyPartialJSONObject } from '@lumino/coreutils';
  18. import { PanelLayout, Widget } from '@lumino/widgets';
  19. import { simulate } from 'simulate-event';
  20. import { bugDotIcon, bugIcon } from '@jupyterlab/ui-components';
  21. const server = new JupyterServer();
  22. beforeAll(async () => {
  23. await server.start();
  24. });
  25. afterAll(async () => {
  26. await server.shutdown();
  27. });
  28. describe('@jupyterlab/apputils', () => {
  29. let widget: Toolbar<Widget>;
  30. beforeEach(async () => {
  31. jest.setTimeout(20000);
  32. widget = new Toolbar();
  33. });
  34. afterEach(async () => {
  35. widget.dispose();
  36. });
  37. describe('Toolbar', () => {
  38. describe('#constructor()', () => {
  39. it('should construct a new toolbar widget', () => {
  40. const widget = new Toolbar();
  41. expect(widget).toBeInstanceOf(Toolbar);
  42. });
  43. it('should add the `jp-Toolbar` class', () => {
  44. const widget = new Toolbar();
  45. expect(widget.hasClass('jp-Toolbar')).toBe(true);
  46. });
  47. });
  48. describe('#names()', () => {
  49. it('should get an ordered list the toolbar item names', () => {
  50. widget.addItem('foo', new Widget());
  51. widget.addItem('bar', new Widget());
  52. widget.addItem('baz', new Widget());
  53. expect(toArray(widget.names())).toEqual(['foo', 'bar', 'baz']);
  54. });
  55. });
  56. describe('#addItem()', () => {
  57. it('should add an item to the toolbar', () => {
  58. const item = new Widget();
  59. expect(widget.addItem('test', item)).toBe(true);
  60. expect(toArray(widget.names())).toContain('test');
  61. });
  62. it('should add the `jp-Toolbar-item` class to the widget', () => {
  63. const item = new Widget();
  64. widget.addItem('test', item);
  65. expect(item.hasClass('jp-Toolbar-item')).toBe(true);
  66. });
  67. it('should return false if the name is already used', () => {
  68. widget.addItem('test', new Widget());
  69. expect(widget.addItem('test', new Widget())).toBe(false);
  70. });
  71. });
  72. describe('#insertItem()', () => {
  73. it('should insert the item into the toolbar', () => {
  74. widget.addItem('a', new Widget());
  75. widget.addItem('b', new Widget());
  76. widget.insertItem(1, 'c', new Widget());
  77. expect(toArray(widget.names())).toEqual(['a', 'c', 'b']);
  78. });
  79. it('should clamp the bounds', () => {
  80. widget.addItem('a', new Widget());
  81. widget.addItem('b', new Widget());
  82. widget.insertItem(10, 'c', new Widget());
  83. expect(toArray(widget.names())).toEqual(['a', 'b', 'c']);
  84. });
  85. });
  86. describe('#insertAfter()', () => {
  87. it('should insert an item into the toolbar after `c`', () => {
  88. widget.addItem('a', new Widget());
  89. widget.addItem('b', new Widget());
  90. widget.insertItem(1, 'c', new Widget());
  91. widget.insertAfter('c', 'd', new Widget());
  92. expect(toArray(widget.names())).toEqual(['a', 'c', 'd', 'b']);
  93. });
  94. it('should return false if the target item does not exist', () => {
  95. widget.addItem('a', new Widget());
  96. widget.addItem('b', new Widget());
  97. const value = widget.insertAfter('c', 'd', new Widget());
  98. expect(value).toBe(false);
  99. });
  100. });
  101. describe('#insertBefore()', () => {
  102. it('should insert an item into the toolbar before `c`', () => {
  103. widget.addItem('a', new Widget());
  104. widget.addItem('b', new Widget());
  105. widget.insertItem(1, 'c', new Widget());
  106. widget.insertBefore('c', 'd', new Widget());
  107. expect(toArray(widget.names())).toEqual(['a', 'd', 'c', 'b']);
  108. });
  109. it('should return false if the target item does not exist', () => {
  110. widget.addItem('a', new Widget());
  111. widget.addItem('b', new Widget());
  112. const value = widget.insertBefore('c', 'd', new Widget());
  113. expect(value).toBe(false);
  114. });
  115. });
  116. describe('.createFromCommand', () => {
  117. const commands = new CommandRegistry();
  118. const testLogCommandId = 'test:toolbar-log';
  119. const logArgs: ReadonlyPartialJSONObject[] = [];
  120. let enabled = false;
  121. let toggled = true;
  122. let visible = false;
  123. commands.addCommand(testLogCommandId, {
  124. execute: args => {
  125. logArgs.push(args);
  126. },
  127. label: 'Test log command label',
  128. caption: 'Test log command caption',
  129. usage: 'Test log command usage',
  130. iconClass: 'test-icon-class',
  131. className: 'test-log-class',
  132. isEnabled: () => enabled,
  133. isToggled: () => toggled,
  134. isVisible: () => visible
  135. });
  136. async function render(button: CommandToolbarButton) {
  137. button.update();
  138. await framePromise();
  139. expect(button.renderPromise).toBeDefined();
  140. await button.renderPromise;
  141. }
  142. it('should create a button', () => {
  143. const button = new CommandToolbarButton({
  144. commands,
  145. id: testLogCommandId
  146. });
  147. expect(button).toBeInstanceOf(CommandToolbarButton);
  148. button.dispose();
  149. });
  150. it('should add main class', async () => {
  151. const button = new CommandToolbarButton({
  152. commands,
  153. id: testLogCommandId
  154. });
  155. await render(button);
  156. const buttonNode = button.node.firstChild as HTMLButtonElement;
  157. expect(buttonNode.classList.contains('test-log-class')).toBe(true);
  158. button.dispose();
  159. });
  160. it('should add an icon with icon class and label', async () => {
  161. const button = new CommandToolbarButton({
  162. commands,
  163. id: testLogCommandId
  164. });
  165. await render(button);
  166. const buttonNode = button.node.firstChild as HTMLButtonElement;
  167. expect(buttonNode.title).toBe('Test log command caption');
  168. const wrapperNode = buttonNode.firstChild as HTMLElement;
  169. const iconNode = wrapperNode.firstChild as HTMLElement;
  170. expect(iconNode.classList.contains('test-icon-class')).toBe(true);
  171. button.dispose();
  172. });
  173. it('should apply state classes', async () => {
  174. enabled = false;
  175. toggled = true;
  176. visible = false;
  177. const button = new CommandToolbarButton({
  178. commands,
  179. id: testLogCommandId
  180. });
  181. await render(button);
  182. const buttonNode = button.node.firstChild as HTMLButtonElement;
  183. expect(buttonNode.disabled).toBe(true);
  184. expect(buttonNode.classList.contains('lm-mod-toggled')).toBe(true);
  185. expect(buttonNode.classList.contains('lm-mod-hidden')).toBe(true);
  186. button.dispose();
  187. });
  188. it('should update state classes', async () => {
  189. enabled = false;
  190. toggled = true;
  191. visible = false;
  192. const button = new CommandToolbarButton({
  193. commands,
  194. id: testLogCommandId
  195. });
  196. await render(button);
  197. const buttonNode = button.node.firstChild as HTMLButtonElement;
  198. expect(buttonNode.disabled).toBe(true);
  199. expect(buttonNode.classList.contains('lm-mod-toggled')).toBe(true);
  200. expect(buttonNode.classList.contains('lm-mod-hidden')).toBe(true);
  201. enabled = true;
  202. visible = true;
  203. commands.notifyCommandChanged(testLogCommandId);
  204. expect(buttonNode.disabled).toBe(false);
  205. expect(buttonNode.classList.contains('lm-mod-toggled')).toBe(true);
  206. expect(buttonNode.classList.contains('lm-mod-hidden')).toBe(false);
  207. enabled = false;
  208. visible = false;
  209. button.dispose();
  210. });
  211. it('should use the command label if no icon class/label', async () => {
  212. const id = 'to-be-removed';
  213. const cmd = commands.addCommand(id, {
  214. execute: () => {
  215. return;
  216. },
  217. label: 'Label-only button'
  218. });
  219. const button = new CommandToolbarButton({
  220. commands,
  221. id
  222. });
  223. await render(button);
  224. const buttonNode = button.node.firstChild as HTMLButtonElement;
  225. expect(buttonNode.textContent).toBe('Label-only button');
  226. cmd.dispose();
  227. });
  228. it('should update the node content on command change event', async () => {
  229. const id = 'to-be-removed';
  230. let iconClassValue: string = '';
  231. const cmd = commands.addCommand(id, {
  232. execute: () => {
  233. /* no op */
  234. },
  235. label: 'Label-only button',
  236. iconClass: () => iconClassValue ?? ''
  237. });
  238. const button = new CommandToolbarButton({
  239. commands,
  240. id
  241. });
  242. await render(button);
  243. const buttonNode = button.node.firstChild as HTMLButtonElement;
  244. expect(buttonNode.textContent).toBe('Label-only button');
  245. expect(buttonNode.classList.contains(iconClassValue)).toBe(false);
  246. iconClassValue = 'updated-icon-class';
  247. commands.notifyCommandChanged(id);
  248. await render(button);
  249. const wrapperNode = buttonNode.firstChild as HTMLElement;
  250. const iconNode = wrapperNode.firstChild as HTMLElement;
  251. expect(iconNode.classList.contains(iconClassValue)).toBe(true);
  252. cmd.dispose();
  253. });
  254. });
  255. describe('Kernel buttons', () => {
  256. let sessionContext: SessionContext;
  257. beforeEach(async () => {
  258. sessionContext = await createSessionContext();
  259. });
  260. afterEach(async () => {
  261. await sessionContext.shutdown();
  262. sessionContext.dispose();
  263. });
  264. describe('.createInterruptButton()', () => {
  265. it("should add an inline svg node with the 'stop' icon", async () => {
  266. const button = Toolbar.createInterruptButton(sessionContext);
  267. Widget.attach(button, document.body);
  268. await framePromise();
  269. expect(
  270. button.node.querySelector("[data-icon$='stop']")
  271. ).toBeDefined();
  272. });
  273. });
  274. describe('.createRestartButton()', () => {
  275. it("should add an inline svg node with the 'refresh' icon", async () => {
  276. const button = Toolbar.createRestartButton(sessionContext);
  277. Widget.attach(button, document.body);
  278. await framePromise();
  279. expect(
  280. button.node.querySelector("[data-icon$='refresh']")
  281. ).toBeDefined();
  282. });
  283. });
  284. describe('.createKernelNameItem()', () => {
  285. it("should display the `'display_name'` of the kernel", async () => {
  286. const item = Toolbar.createKernelNameItem(sessionContext);
  287. await sessionContext.initialize();
  288. Widget.attach(item, document.body);
  289. await framePromise();
  290. const node = item.node.querySelector(
  291. '.jp-ToolbarButtonComponent-label'
  292. )!;
  293. expect(node.textContent).toBe(sessionContext.kernelDisplayName);
  294. });
  295. });
  296. describe('.createKernelStatusItem()', () => {
  297. beforeEach(async () => {
  298. await sessionContext.initialize();
  299. await sessionContext.session?.kernel?.info;
  300. });
  301. it('should display a busy status if the kernel status is busy', async () => {
  302. const item = Toolbar.createKernelStatusItem(sessionContext);
  303. let called = false;
  304. sessionContext.statusChanged.connect((_, status) => {
  305. if (status === 'busy') {
  306. expect(
  307. item.node.querySelector("[data-icon$='circle']")
  308. ).toBeDefined();
  309. called = true;
  310. }
  311. });
  312. const future = sessionContext.session?.kernel?.requestExecute({
  313. code: 'a = 109\na'
  314. })!;
  315. await future.done;
  316. expect(called).toBe(true);
  317. });
  318. it('should show the current status in the node title', async () => {
  319. const item = Toolbar.createKernelStatusItem(sessionContext);
  320. const status = sessionContext.session?.kernel?.status;
  321. expect(item.node.title.toLowerCase()).toContain(status);
  322. let called = false;
  323. const future = sessionContext.session?.kernel?.requestExecute({
  324. code: 'a = 1'
  325. })!;
  326. future.onIOPub = msg => {
  327. if (sessionContext.session?.kernel?.status === 'busy') {
  328. expect(item.node.title.toLowerCase()).toContain('busy');
  329. called = true;
  330. }
  331. };
  332. await future.done;
  333. expect(called).toBe(true);
  334. });
  335. it('should handle a starting session', async () => {
  336. await sessionContext.session?.kernel?.info;
  337. await sessionContext.shutdown();
  338. sessionContext = await createSessionContext();
  339. await sessionContext.initialize();
  340. const item = Toolbar.createKernelStatusItem(sessionContext);
  341. expect(item.node.title).toBe('Kernel Connecting');
  342. expect(
  343. item.node.querySelector("[data-icon$='circle-empty']")
  344. ).toBeDefined();
  345. await sessionContext.initialize();
  346. await sessionContext.session?.kernel?.info;
  347. });
  348. });
  349. });
  350. });
  351. describe('ReactiveToolbar', () => {
  352. let toolbar: ReactiveToolbar;
  353. beforeEach(() => {
  354. toolbar = new ReactiveToolbar();
  355. Widget.attach(toolbar, document.body);
  356. });
  357. afterEach(() => {
  358. toolbar.dispose();
  359. });
  360. describe('#constructor()', () => {
  361. it('should append a node to body for the pop-up', () => {
  362. const popup = document.body.querySelector(
  363. '.jp-Toolbar-responsive-popup'
  364. );
  365. expect(popup).toBeDefined();
  366. expect(popup!.parentNode!.nodeName).toEqual('BODY');
  367. });
  368. });
  369. describe('#addItem()', () => {
  370. it('should insert item before the toolbar pop-up button', () => {
  371. const w = new Widget();
  372. toolbar.addItem('test', w);
  373. expect(
  374. (toolbar.layout as PanelLayout).widgets.findIndex(v => v === w)
  375. ).toEqual((toolbar.layout as PanelLayout).widgets.length - 2);
  376. });
  377. });
  378. describe('#insertItem()', () => {
  379. it('should insert item before the toolbar pop-up button', () => {
  380. const w = new Widget();
  381. toolbar.insertItem(2, 'test', w);
  382. expect(
  383. (toolbar.layout as PanelLayout).widgets.findIndex(v => v === w)
  384. ).toEqual((toolbar.layout as PanelLayout).widgets.length - 2);
  385. });
  386. });
  387. describe('#insertAfter()', () => {
  388. it('should not insert item after the toolbar pop-up button', () => {
  389. const w = new Widget();
  390. const r = toolbar.insertAfter('toolbar-popup-opener', 'test', w);
  391. expect(r).toEqual(false);
  392. expect(
  393. (toolbar.layout as PanelLayout).widgets.findIndex(v => v === w)
  394. ).toEqual(-1);
  395. });
  396. });
  397. });
  398. describe('ToolbarButton', () => {
  399. describe('#constructor()', () => {
  400. it('should accept no arguments', () => {
  401. const widget = new ToolbarButton();
  402. expect(widget).toBeInstanceOf(ToolbarButton);
  403. });
  404. it('should accept options', async () => {
  405. const widget = new ToolbarButton({
  406. className: 'foo',
  407. iconClass: 'iconFoo',
  408. onClick: () => {
  409. return void 0;
  410. },
  411. tooltip: 'bar'
  412. });
  413. Widget.attach(widget, document.body);
  414. await framePromise();
  415. const button = widget.node.firstChild as HTMLElement;
  416. expect(button.classList.contains('foo')).toBe(true);
  417. expect(button.querySelector('.iconFoo')).toBeDefined();
  418. expect(button.title).toBe('bar');
  419. });
  420. });
  421. describe('#dispose()', () => {
  422. it('should dispose of the resources used by the widget', () => {
  423. const button = new ToolbarButton();
  424. button.dispose();
  425. expect(button.isDisposed).toBe(true);
  426. });
  427. it('should be safe to call more than once', () => {
  428. const button = new ToolbarButton();
  429. button.dispose();
  430. button.dispose();
  431. expect(button.isDisposed).toBe(true);
  432. });
  433. });
  434. describe('#handleEvent()', () => {
  435. describe('click', () => {
  436. it('should activate the callback', async () => {
  437. let called = false;
  438. const button = new ToolbarButton({
  439. onClick: () => {
  440. called = true;
  441. }
  442. });
  443. Widget.attach(button, document.body);
  444. await framePromise();
  445. simulate(button.node.firstChild as HTMLElement, 'mousedown');
  446. expect(called).toBe(true);
  447. button.dispose();
  448. });
  449. });
  450. describe('keydown', () => {
  451. it('Enter should activate the callback', async () => {
  452. let called = false;
  453. const button = new ToolbarButton({
  454. onClick: () => {
  455. called = true;
  456. }
  457. });
  458. Widget.attach(button, document.body);
  459. await framePromise();
  460. simulate(button.node.firstChild as HTMLElement, 'keydown', {
  461. key: 'Enter'
  462. });
  463. expect(called).toBe(true);
  464. button.dispose();
  465. });
  466. it('Space should activate the callback', async () => {
  467. let called = false;
  468. const button = new ToolbarButton({
  469. onClick: () => {
  470. called = true;
  471. }
  472. });
  473. Widget.attach(button, document.body);
  474. await framePromise();
  475. simulate(button.node.firstChild as HTMLElement, 'keydown', {
  476. key: ' '
  477. });
  478. expect(called).toBe(true);
  479. button.dispose();
  480. });
  481. });
  482. });
  483. describe('#pressed()', () => {
  484. it('should update the pressed state', async () => {
  485. const widget = new ToolbarButton({
  486. icon: bugIcon,
  487. tooltip: 'tooltip',
  488. pressedTooltip: 'pressed tooltip',
  489. pressedIcon: bugDotIcon
  490. });
  491. Widget.attach(widget, document.body);
  492. await framePromise();
  493. const button = widget.node.firstChild as HTMLElement;
  494. expect(widget.pressed).toBe(false);
  495. expect(button.title).toBe('tooltip');
  496. expect(button.getAttribute('aria-pressed')).toEqual('false');
  497. let icon = button.querySelectorAll('svg');
  498. expect(icon[0].getAttribute('data-icon')).toEqual('ui-components:bug');
  499. widget.pressed = true;
  500. await framePromise();
  501. expect(widget.pressed).toBe(true);
  502. expect(button.title).toBe('pressed tooltip');
  503. expect(button.getAttribute('aria-pressed')).toEqual('true');
  504. icon = button.querySelectorAll('svg');
  505. expect(icon[0].getAttribute('data-icon')).toEqual(
  506. 'ui-components:bug-dot'
  507. );
  508. widget.dispose();
  509. });
  510. it('should not have the pressed state when not enabled', async () => {
  511. const widget = new ToolbarButton({
  512. icon: bugIcon,
  513. tooltip: 'tooltip',
  514. pressedTooltip: 'pressed tooltip',
  515. disabledTooltip: 'disabled tooltip',
  516. pressedIcon: bugDotIcon,
  517. enabled: false
  518. });
  519. Widget.attach(widget, document.body);
  520. await framePromise();
  521. const button = widget.node.firstChild as HTMLElement;
  522. expect(widget.pressed).toBe(false);
  523. expect(button.title).toBe('disabled tooltip');
  524. expect(button.getAttribute('aria-pressed')).toEqual('false');
  525. widget.pressed = true;
  526. await framePromise();
  527. expect(widget.pressed).toBe(false);
  528. expect(button.title).toBe('disabled tooltip');
  529. expect(button.getAttribute('aria-pressed')).toEqual('false');
  530. const icon = button.querySelectorAll('svg');
  531. expect(icon[0].getAttribute('data-icon')).toEqual('ui-components:bug');
  532. widget.dispose();
  533. });
  534. });
  535. describe('#enabled()', () => {
  536. it('should update the enabled state', async () => {
  537. const widget = new ToolbarButton({
  538. icon: bugIcon,
  539. tooltip: 'tooltip',
  540. pressedTooltip: 'pressed tooltip',
  541. disabledTooltip: 'disabled tooltip',
  542. pressedIcon: bugDotIcon
  543. });
  544. Widget.attach(widget, document.body);
  545. await framePromise();
  546. const button = widget.node.firstChild as HTMLElement;
  547. expect(widget.enabled).toBe(true);
  548. expect(widget.pressed).toBe(false);
  549. expect(button.getAttribute('aria-disabled')).toEqual('false');
  550. widget.pressed = true;
  551. await framePromise();
  552. expect(widget.pressed).toBe(true);
  553. widget.enabled = false;
  554. await framePromise();
  555. expect(widget.enabled).toBe(false);
  556. expect(widget.pressed).toBe(false);
  557. expect(button.getAttribute('aria-disabled')).toEqual('true');
  558. widget.dispose();
  559. });
  560. });
  561. describe('#onClick()', () => {
  562. it('should update the onClick state', async () => {
  563. let mockCalled = false;
  564. const mockOnClick = () => {
  565. mockCalled = true;
  566. };
  567. const widget = new ToolbarButton({
  568. icon: bugIcon,
  569. tooltip: 'tooltip',
  570. onClick: mockOnClick
  571. });
  572. Widget.attach(widget, document.body);
  573. await framePromise();
  574. simulate(widget.node.firstChild as HTMLElement, 'mousedown');
  575. expect(mockCalled).toBe(true);
  576. mockCalled = false;
  577. let mockUpdatedCalled = false;
  578. const mockOnClickUpdated = () => {
  579. mockUpdatedCalled = true;
  580. };
  581. widget.onClick = mockOnClickUpdated;
  582. await framePromise();
  583. simulate(widget.node.firstChild as HTMLElement, 'mousedown');
  584. expect(mockCalled).toBe(false);
  585. expect(mockUpdatedCalled).toBe(true);
  586. widget.dispose();
  587. });
  588. });
  589. });
  590. });