diff --git a/jest.setup.ts b/jest.setup.ts index 8db731f817..fac94a044d 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -50,6 +50,12 @@ Object.defineProperty(window, 'ResizeObserver', { }, }); +Object.defineProperty(window, 'IntersectionObserver', { + value: function () { + return TestUtils.createMockProxy(); + }, +}); + Object.defineProperty(window, 'DOMRect', { value: function (x: number = 0, y: number = 0, width = 0, height = 0) { return TestUtils.createMockProxy({ diff --git a/packages/console/src/Console.tsx b/packages/console/src/Console.tsx index 5a5587b066..189f77fd73 100644 --- a/packages/console/src/Console.tsx +++ b/packages/console/src/Console.tsx @@ -4,9 +4,10 @@ import React, { DragEvent, PureComponent, - ReactElement, - ReactNode, - RefObject, + type ReactElement, + type ReactNode, + type RefObject, + type UIEvent, } from 'react'; import { ContextActions, @@ -182,6 +183,7 @@ export class Console extends PureComponent { this.handleLogMessage = this.handleLogMessage.bind(this); this.handleOverflowActions = this.handleOverflowActions.bind(this); this.handleScrollPaneScroll = this.handleScrollPaneScroll.bind(this); + this.handleHistoryResize = this.handleHistoryResize.bind(this); this.handleToggleAutoLaunchPanels = this.handleToggleAutoLaunchPanels.bind(this); this.handleToggleClosePanelsOnDisconnect = @@ -203,8 +205,10 @@ export class Console extends PureComponent { this.consolePane = React.createRef(); this.consoleInput = React.createRef(); this.consoleHistoryScrollPane = React.createRef(); + this.consoleHistoryContent = React.createRef(); this.pending = new Pending(); this.queuedLogMessages = []; + this.resizeObserver = new window.ResizeObserver(this.handleHistoryResize); const { objectMap, settings } = this.props; @@ -245,6 +249,14 @@ export class Console extends PureComponent { ); this.updateDimensions(); + + if ( + this.consoleHistoryScrollPane.current && + this.consoleHistoryContent.current + ) { + this.resizeObserver.observe(this.consoleHistoryScrollPane.current); + this.resizeObserver.observe(this.consoleHistoryContent.current); + } } componentDidUpdate(prevProps: ConsoleProps, prevState: ConsoleState): void { @@ -272,6 +284,7 @@ export class Console extends PureComponent { this.processLogMessageQueue.cancel(); this.deinitConsoleLogging(); + this.resizeObserver.disconnect(); } cancelListener?: () => void; @@ -282,10 +295,14 @@ export class Console extends PureComponent { consoleHistoryScrollPane: RefObject; + consoleHistoryContent: RefObject; + pending: Pending; queuedLogMessages: ConsoleHistoryActionItem[]; + resizeObserver: ResizeObserver; + initConsoleLogging(): void { const { session } = this.props; this.cancelListener = session.onLogMessage(this.handleLogMessage); @@ -662,7 +679,8 @@ export class Console extends PureComponent { }); } - handleScrollPaneScroll(): void { + handleScrollPaneScroll(event: UIEvent): void { + log.debug('handleScrollPaneScroll', event); const scrollPane = this.consoleHistoryScrollPane.current; assertNotNull(scrollPane); this.setState({ @@ -673,6 +691,17 @@ export class Console extends PureComponent { }); } + handleHistoryResize(entries: ResizeObserverEntry[]): void { + log.debug('handleHistoryResize', entries); + const entry = entries[0]; + if (entry.contentRect.height > 0 && entry.contentRect.width > 0) { + const { isStuckToBottom } = this.state; + if (isStuckToBottom && !this.isAtBottom()) { + this.scrollConsoleHistoryToBottom(); + } + } + } + handleToggleAutoLaunchPanels(): void { this.setState(state => ({ isAutoLaunchPanelsEnabled: !state.isAutoLaunchPanelsEnabled, @@ -1055,6 +1084,7 @@ export class Console extends PureComponent { disabled={disabled} supportsType={supportsType} iconForType={iconForType} + ref={this.consoleHistoryContent} /> {historyChildren} diff --git a/packages/console/src/console-history/ConsoleHistory.tsx b/packages/console/src/console-history/ConsoleHistory.tsx index caf2593509..67b022bdba 100644 --- a/packages/console/src/console-history/ConsoleHistory.tsx +++ b/packages/console/src/console-history/ConsoleHistory.tsx @@ -1,7 +1,7 @@ /** * Console display for use in the Iris environment. */ -import { type ReactElement } from 'react'; +import React, { type ReactElement } from 'react'; import type { dh } from '@deephaven/jsapi-types'; import ConsoleHistoryItem from './ConsoleHistoryItem'; @@ -23,7 +23,13 @@ function itemKey(i: number, item: ConsoleHistoryActionItem): string { }`; } -function ConsoleHistory(props: ConsoleHistoryProps): ReactElement { +/** + * Display the console history. + */ +const ConsoleHistory = React.forwardRef(function ConsoleHistory( + props: ConsoleHistoryProps, + ref: React.Ref +): ReactElement { const { disabled = false, items, @@ -50,8 +56,10 @@ function ConsoleHistory(props: ConsoleHistoryProps): ReactElement { } return ( -
{historyElements}
+
+ {historyElements} +
); -} +}); export default ConsoleHistory; diff --git a/tests/console.spec.ts b/tests/console.spec.ts index d0e27f7ced..110306d148 100644 --- a/tests/console.spec.ts +++ b/tests/console.spec.ts @@ -1,6 +1,31 @@ import { test, expect, Page, Locator } from '@playwright/test'; import shortid from 'shortid'; -import { generateVarName, pasteInMonaco, makeTableCommand } from './utils'; +import { + generateId, + generateVarName, + pasteInMonaco, + makeTableCommand, +} from './utils'; + +function logMessageLocator(page: Page, text?: string): Locator { + return page + .locator('.console-history .log-message') + .filter({ hasText: text }); +} + +function historyContentLocator(page: Page, text?: string): Locator { + return page + .locator('.console-history .console-history-content') + .filter({ hasText: text }); +} + +function panelTabLocator(page: Page, text: string): Locator { + return page.locator('.lm_tab .lm_title').filter({ hasText: text }); +} + +function scrollPanelLocator(page: Page): Locator { + return page.locator('.console-pane .scroll-pane'); +} let page: Page; let consoleInput: Locator; @@ -28,9 +53,8 @@ test.describe('console input tests', () => { await page.keyboard.press('Enter'); // Expect the output to show up in the log - await expect( - page.locator('.console-history .log-message').filter({ hasText: message }) - ).toHaveCount(1); + await expect(logMessageLocator(page, message)).toHaveCount(1); + await expect(logMessageLocator(page, message)).toBeVisible(); }); test('object button is created when creating a table', async ({ @@ -57,3 +81,67 @@ test.describe('console input tests', () => { await expect(btnLocator.nth(1)).not.toBeDisabled(); }); }); + +test.describe('console scroll tests', () => { + test.beforeEach(async () => { + // Whenever we start a session, the server sends it logs over + // We should wait for those to appear before running commands + await logMessageLocator(page).first().waitFor(); + }); + + test('scrolls to the bottom when command is executed', async () => { + // The command needs to be long, but it doesn't need to actually print anything + const ids = Array.from(Array(50).keys()).map(() => generateId()); + const command = ids.map(i => `# Really long command ${i}`).join('\n'); + + await pasteInMonaco(consoleInput, command); + await page.keyboard.press('Enter'); + + await historyContentLocator(page, ids[ids.length - 1]).waitFor({ + state: 'attached', + }); + + // Wait for the scroll to complete, since it starts on the next available animation frame + await page.waitForTimeout(500); + + // Expect the console to be scrolled to the bottom + const scrollPane = await scrollPanelLocator(page); + expect( + await scrollPane.evaluate(el => + Math.floor(el.scrollHeight - el.scrollTop - el.clientHeight) + ) + ).toBeLessThanOrEqual(0); + }); + + test('scrolls to the bottom when focus changed when command executed', async () => { + // The command needs to be long, but it doesn't need to actually print anything + const ids = Array.from(Array(50).keys()).map(() => generateId()); + const command = `import time\ntime.sleep(0.5)\n${ids + .map(i => `# Really long command ${i}`) + .join('\n')}`; + + await pasteInMonaco(consoleInput, command); + page.keyboard.press('Enter'); + + // Immediately open the log before the command code block has a chance to finish colorizing/rendering + await panelTabLocator(page, 'Log').click(); + + // wait for a bit for the code block to render + await historyContentLocator(page, ids[ids.length - 1]).waitFor({ + state: 'attached', + }); + + // Switch back to the console, and expect it to be scrolled to the bottom + await panelTabLocator(page, 'Console').click(); + + // Wait for the scroll to complete, since it starts on the next available animation frame + await page.waitForTimeout(500); + + const scrollPane = await scrollPanelLocator(page); + expect( + await scrollPane.evaluate(el => + Math.floor(el.scrollHeight - el.scrollTop - el.clientHeight) + ) + ).toBeLessThanOrEqual(0); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index fc0099c233..54bcb694da 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -125,6 +125,19 @@ export async function openPlot( } } +/** + * Generate a unique Id + * @param length Length to give id + * @returns A unique valid id + */ +export function generateId(length = 21): string { + let id = ''; + for (let i = 0; i < length; i += 1) { + id += Math.random().toString(36).substr(2, 1); + } + return id; +} + /** * Generate a unique python variable name * @param prefix Prefix to give the variable name