From fbebabe2e62217b28a4b7f86c2f1afc7864c2f8d Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 27 Aug 2023 08:08:42 -0700 Subject: [PATCH] Add @playwright/test support --- .eslintrc.json | 1 + .../test/AttachAddon.api.ts | 2 +- addons/xterm-addon-fit/test/FitAddon.api.ts | 2 +- .../xterm-addon-image/test/ImageAddon.api.ts | 2 +- .../test/SearchAddon.api.ts | 2 +- .../test/SerializeAddon.api.ts | 2 +- .../test/Unicode11Addon.api.ts | 2 +- .../test/WebLinksAddon.api.ts | 2 +- .../test/WebglRenderer.api.ts | 2 +- demo/client.ts | 17 +- package.json | 4 +- src/common/EventEmitter.ts | 6 +- test/api/CharWidth.api.ts | 2 +- test/api/InputHandler.api.ts | 2 +- test/api/MouseTracking.api.ts | 2 +- test/api/Parser.api.ts | 2 +- test/api/Terminal.api.ts | 2 +- test/api/TestUtils.ts | 4 +- test/playwright/CharWidth.test.ts | 76 ++++ test/playwright/TestUtils.ts | 385 ++++++++++++++++++ test/playwright/playwright.config.ts | 34 ++ test/playwright/tsconfig.json | 42 ++ tsconfig.all.json | 1 + yarn.lock | 19 +- 24 files changed, 585 insertions(+), 30 deletions(-) create mode 100644 test/playwright/CharWidth.test.ts create mode 100644 test/playwright/TestUtils.ts create mode 100644 test/playwright/playwright.config.ts create mode 100644 test/playwright/tsconfig.json diff --git a/.eslintrc.json b/.eslintrc.json index 9287da5c..0626a446 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,6 +13,7 @@ "src/headless/tsconfig.json", "test/api/tsconfig.json", "test/benchmark/tsconfig.json", + "test/playwright/tsconfig.json", "addons/xterm-addon-attach/src/tsconfig.json", "addons/xterm-addon-attach/test/tsconfig.json", "addons/xterm-addon-canvas/src/tsconfig.json", diff --git a/addons/xterm-addon-attach/test/AttachAddon.api.ts b/addons/xterm-addon-attach/test/AttachAddon.api.ts index a4827656..230b9e2e 100644 --- a/addons/xterm-addon-attach/test/AttachAddon.api.ts +++ b/addons/xterm-addon-attach/test/AttachAddon.api.ts @@ -5,7 +5,7 @@ import WebSocket = require('ws'); import { openTerminal, pollFor, launchBrowser } from '../../../out-test/api/TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; const APP = 'http://127.0.0.1:3001/test'; diff --git a/addons/xterm-addon-fit/test/FitAddon.api.ts b/addons/xterm-addon-fit/test/FitAddon.api.ts index 36b44211..5611972e 100644 --- a/addons/xterm-addon-fit/test/FitAddon.api.ts +++ b/addons/xterm-addon-fit/test/FitAddon.api.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { openTerminal, launchBrowser } from '../../../out-test/api/TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; const APP = 'http://127.0.0.1:3001/test'; diff --git a/addons/xterm-addon-image/test/ImageAddon.api.ts b/addons/xterm-addon-image/test/ImageAddon.api.ts index 5b43eb35..43334bbb 100644 --- a/addons/xterm-addon-image/test/ImageAddon.api.ts +++ b/addons/xterm-addon-image/test/ImageAddon.api.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { openTerminal, launchBrowser, pollFor } from '../../../out-test/api/TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; import { IImageAddonOptions } from '../src/Types'; import { FINALIZER, introducer, sixelEncode } from 'sixel'; import { readFileSync } from 'fs'; diff --git a/addons/xterm-addon-search/test/SearchAddon.api.ts b/addons/xterm-addon-search/test/SearchAddon.api.ts index 0c20084a..92fd6b29 100644 --- a/addons/xterm-addon-search/test/SearchAddon.api.ts +++ b/addons/xterm-addon-search/test/SearchAddon.api.ts @@ -7,7 +7,7 @@ import { assert } from 'chai'; import { readFile } from 'fs'; import { resolve } from 'path'; import { openTerminal, writeSync, launchBrowser, timeout } from '../../../out-test/api/TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; const APP = 'http://127.0.0.1:3001/test'; diff --git a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts index 522d3010..c943e7b2 100644 --- a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts +++ b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { openTerminal, writeSync, launchBrowser } from '../../../out-test/api/TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; const APP = 'http://127.0.0.1:3001/test'; diff --git a/addons/xterm-addon-unicode11/test/Unicode11Addon.api.ts b/addons/xterm-addon-unicode11/test/Unicode11Addon.api.ts index 4c695b00..c7f3588f 100644 --- a/addons/xterm-addon-unicode11/test/Unicode11Addon.api.ts +++ b/addons/xterm-addon-unicode11/test/Unicode11Addon.api.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { openTerminal, launchBrowser } from '../../../out-test/api/TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; const APP = 'http://127.0.0.1:3001/test'; diff --git a/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts b/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts index a086e1d3..fb5be20b 100644 --- a/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts +++ b/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { openTerminal, pollFor, writeSync, launchBrowser } from '../../../out-test/api/TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; const APP = 'http://127.0.0.1:3001/test'; diff --git a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts index 5d9cc0f0..7db2de26 100644 --- a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts +++ b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts @@ -4,7 +4,7 @@ */ import { assert } from 'chai'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; import { ITheme } from 'xterm'; import { getBrowserType, launchBrowser, openTerminal, pollFor, writeSync } from '../../../out-test/api/TestUtils'; import { ITerminalOptions } from '../../../src/common/Types'; diff --git a/demo/client.ts b/demo/client.ts index d0b79c68..c643100e 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -13,7 +13,6 @@ import { Terminal } from '../out/browser/public/Terminal'; import { AttachAddon } from '../addons/xterm-addon-attach/out/AttachAddon'; import { CanvasAddon } from '../addons/xterm-addon-canvas/out/CanvasAddon'; import { FitAddon } from '../addons/xterm-addon-fit/out/FitAddon'; -import { ImageAddon, IImageAddonOptions } from '../addons/xterm-addon-image/out/ImageAddon'; import { SearchAddon, ISearchOptions } from '../addons/xterm-addon-search/out/SearchAddon'; import { SerializeAddon } from '../addons/xterm-addon-serialize/out/SerializeAddon'; import { WebLinksAddon } from '../addons/xterm-addon-web-links/out/WebLinksAddon'; @@ -21,6 +20,14 @@ import { WebglAddon } from '../addons/xterm-addon-webgl/out/WebglAddon'; import { Unicode11Addon } from '../addons/xterm-addon-unicode11/out/Unicode11Addon'; import { LigaturesAddon } from '../addons/xterm-addon-ligatures/out/LigaturesAddon'; +// Playwright/WebKit on Windows does not support WebAssembly https://stackoverflow.com/q/62311688/1156119 +import type { ImageAddonType, IImageAddonOptionsType } from '../addons/xterm-addon-image/out/ImageAddon'; +let ImageAddon: ImageAddonType | undefined; // eslint-disable-line @typescript-eslint/naming-convention +if (!navigator.userAgent.includes('AppleWebKit')) { + const imageAddon = require('../addons/xterm-addon-image/out/ImageAddon'); + ImageAddon = imageAddon.ImageAddon; +} + // Use webpacked version (yarn package) // import { Terminal } from '../lib/xterm'; // import { AttachAddon } from 'xterm-addon-attach'; @@ -42,7 +49,7 @@ export interface IWindowWithTerminal extends Window { Terminal?: typeof TerminalType; // eslint-disable-line @typescript-eslint/naming-convention AttachAddon?: typeof AttachAddon; // eslint-disable-line @typescript-eslint/naming-convention FitAddon?: typeof FitAddon; // eslint-disable-line @typescript-eslint/naming-convention - ImageAddon?: typeof ImageAddon; // eslint-disable-line @typescript-eslint/naming-convention + ImageAddon?: typeof ImageAddonType; // eslint-disable-line @typescript-eslint/naming-convention SearchAddon?: typeof SearchAddon; // eslint-disable-line @typescript-eslint/naming-convention SerializeAddon?: typeof SerializeAddon; // eslint-disable-line @typescript-eslint/naming-convention WebLinksAddon?: typeof WebLinksAddon; // eslint-disable-line @typescript-eslint/naming-convention @@ -68,7 +75,7 @@ interface IDemoAddon { T extends 'attach' ? typeof AttachAddon : T extends 'canvas' ? typeof CanvasAddon : T extends 'fit' ? typeof FitAddon : - T extends 'image' ? typeof ImageAddon : + T extends 'image' ? typeof ImageAddonType : T extends 'search' ? typeof SearchAddon : T extends 'serialize' ? typeof SerializeAddon : T extends 'webLinks' ? typeof WebLinksAddon : @@ -80,7 +87,7 @@ interface IDemoAddon { T extends 'attach' ? AttachAddon : T extends 'canvas' ? CanvasAddon : T extends 'fit' ? FitAddon : - T extends 'image' ? ImageAddon : + T extends 'image' ? ImageAddonType : T extends 'search' ? SearchAddon : T extends 'serialize' ? SerializeAddon : T extends 'webLinks' ? WebLinksAddon : @@ -1256,7 +1263,7 @@ Test BG-colored Erase (BCE): function initImageAddonExposed(): void { - const DEFAULT_OPTIONS: IImageAddonOptions = (addons.image.instance as any)._defaultOpts; + const DEFAULT_OPTIONS: IImageAddonOptionsType = (addons.image.instance as any)._defaultOpts; const limitStorageElement = document.querySelector('#image-storagelimit'); limitStorageElement.valueAsNumber = addons.image.instance.storageLimit; addDomListener(limitStorageElement, 'change', () => { diff --git a/package.json b/package.json index 7ad87d81..89bd072e 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "test-api-chromium": "node ./bin/test_api.js --browser=chromium --timeout=20000", "test-api-firefox": "node ./bin/test_api.js --browser=firefox --timeout=20000", "test-api-webkit": "node ./bin/test_api.js --browser=webkit --timeout=20000", + "test-playwright": "playwright test -c ./out-test/playwright/playwright.config.js --workers 4", + "test-playwright-debug": "playwright test -c ./out-test/playwright/playwright.config.js --headed --workers 1 --timeout 30000", "test-unit": "node ./bin/test.js", "test-unit-coverage": "node ./bin/test.js --coverage", "test-unit-dev": "cross-env NODE_PATH='./out' mocha", @@ -53,6 +55,7 @@ "vtfeatures": "node bin/extract_vtfeatures.js src/**/*.ts src/*.ts" }, "devDependencies": { + "@playwright/test": "^1.37.1", "@types/chai": "^4.2.22", "@types/debug": "^4.1.7", "@types/deep-equal": "^1.0.1", @@ -80,7 +83,6 @@ "mustache": "^4.2.0", "node-pty": "^0.10.1", "nyc": "^15.1.0", - "playwright": "^1.37.1", "source-map-loader": "^3.0.0", "source-map-support": "^0.5.20", "ts-loader": "^9.3.1", diff --git a/src/common/EventEmitter.ts b/src/common/EventEmitter.ts index 4684809f..fd959042 100644 --- a/src/common/EventEmitter.ts +++ b/src/common/EventEmitter.ts @@ -57,10 +57,14 @@ export class EventEmitter implements IEventEmitter { } public dispose(): void { + this.clearListeners(); + this._disposed = true; + } + + public clearListeners(): void { if (this._listeners) { this._listeners.length = 0; } - this._disposed = true; } } diff --git a/test/api/CharWidth.api.ts b/test/api/CharWidth.api.ts index 83067149..43d5ab20 100644 --- a/test/api/CharWidth.api.ts +++ b/test/api/CharWidth.api.ts @@ -4,7 +4,7 @@ */ import { pollFor, openTerminal, launchBrowser } from './TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; const APP = 'http://127.0.0.1:3001/test'; diff --git a/test/api/InputHandler.api.ts b/test/api/InputHandler.api.ts index c894a4d8..4059edae 100644 --- a/test/api/InputHandler.api.ts +++ b/test/api/InputHandler.api.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { pollFor, openTerminal, getBrowserType, launchBrowser, writeSync } from './TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; import { IRenderDimensions } from 'browser/renderer/shared/Types'; const APP = 'http://127.0.0.1:3001/test'; diff --git a/test/api/MouseTracking.api.ts b/test/api/MouseTracking.api.ts index 8a64a59a..84b4402f 100644 --- a/test/api/MouseTracking.api.ts +++ b/test/api/MouseTracking.api.ts @@ -4,7 +4,7 @@ */ import { pollFor, writeSync, openTerminal, getBrowserType, launchBrowser } from './TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; const APP = 'http://127.0.0.1:3001/test'; diff --git a/test/api/Parser.api.ts b/test/api/Parser.api.ts index ada28adf..94d4a54a 100644 --- a/test/api/Parser.api.ts +++ b/test/api/Parser.api.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { writeSync, openTerminal, launchBrowser } from './TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; const APP = 'http://127.0.0.1:3001/test'; diff --git a/test/api/Terminal.api.ts b/test/api/Terminal.api.ts index 01716223..d0ba1dd6 100644 --- a/test/api/Terminal.api.ts +++ b/test/api/Terminal.api.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { pollFor, timeout, writeSync, openTerminal, launchBrowser } from './TestUtils'; -import { Browser, Page } from 'playwright'; +import { Browser, Page } from '@playwright/test'; import { fail } from 'assert'; import { IRenderDimensions } from 'browser/renderer/shared/Types'; diff --git a/test/api/TestUtils.ts b/test/api/TestUtils.ts index 3fd0648e..702fcb2d 100644 --- a/test/api/TestUtils.ts +++ b/test/api/TestUtils.ts @@ -3,10 +3,10 @@ * @license MIT */ -import * as playwright from 'playwright'; +import * as playwright from '@playwright/test'; import deepEqual = require('deep-equal'); import { ITerminalInitOnlyOptions, ITerminalOptions } from 'xterm'; -import { deepStrictEqual, fail } from 'assert'; +import { deepStrictEqual } from 'assert'; export async function pollFor(page: playwright.Page, evalOrFn: string | (() => Promise), val: T, preFn?: () => Promise, maxDuration?: number): Promise { if (preFn) { diff --git a/test/playwright/CharWidth.test.ts b/test/playwright/CharWidth.test.ts new file mode 100644 index 00000000..fbc06697 --- /dev/null +++ b/test/playwright/CharWidth.test.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { test } from '@playwright/test'; +import { createTestContext, ITestContext, openTerminal, pollFor } from './TestUtils'; + +let ctx: ITestContext; +test.beforeAll(async ({ browser }) => { + ctx = await createTestContext(browser); + await openTerminal(ctx); +}); +test.afterAll(async () => await ctx.page.close()); + +test.beforeEach(async () => await ctx.proxy.reset()); + +test.describe('CharWidth Integration Tests', function(): void { + test.describe('getStringCellWidth', () => { + test('ASCII chars', async () => { + await ctx.proxy.write('This is just ASCII text.#'); + await pollFor(ctx.page, () => sumWidths(0, 1, '#'), 25); + }); + + test('combining chars', async () => { + await ctx.proxy.write('e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301#'); + await pollFor(ctx.page, () => sumWidths(0, 1, '#'), 10); + }); + + test('surrogate chars', async () => { + await ctx.proxy.write('𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞#'); + await pollFor(ctx.page, () => sumWidths(0, 1, '#'), 28); + }); + + test('surrogate combining chars', async () => { + await ctx.proxy.write('𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301#'); + await pollFor(ctx.page, () => sumWidths(0, 1, '#'), 12); + }); + + test('fullwidth chars', async () => { + await ctx.proxy.write('1234567890#'); + await pollFor(ctx.page, () => sumWidths(0, 1, '#'), 21); + }); + + test('fullwidth chars offset 1', async () => { + await ctx.proxy.write('a1234567890#'); + await pollFor(ctx.page, () => sumWidths(0, 1, '#'), 22); + }); + + // TODO: multiline tests once #1685 is resolved + }); +}); + +async function sumWidths(start: number, end: number, sentinel: string): Promise { + await ctx.page.evaluate(` + (function() { + window.result = 0; + const buffer = window.term.buffer.active; + for (let i = ${start}; i < ${end}; i++) { + const line = buffer.getLine(i); + let j = 0; + while (true) { + const cell = line.getCell(j++); + if (!cell) { + break; + } + window.result += cell.getWidth(); + if (cell.getChars() === '${sentinel}') { + return; + } + } + } + })(); + `); + return await ctx.page.evaluate(`window.result`); +} diff --git a/test/playwright/TestUtils.ts b/test/playwright/TestUtils.ts new file mode 100644 index 00000000..1fb54178 --- /dev/null +++ b/test/playwright/TestUtils.ts @@ -0,0 +1,385 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Browser, JSHandle, Page } from '@playwright/test'; +import { deepStrictEqual } from 'assert'; +import type { IRenderDimensions } from 'browser/renderer/shared/Types'; +import type { IRenderService } from 'browser/services/Services'; +import type { ICoreTerminal, IMarker } from 'common/Types'; +import * as playwright from '@playwright/test'; +import { PageFunction } from 'playwright-core/types/structs'; +import { IBuffer, IBufferCell, IBufferLine, IBufferRange, IModes, ITerminalOptions, Terminal } from 'xterm'; +import { EventEmitter } from '../../out/common/EventEmitter'; +// TODO: We could avoid needing this +import deepEqual = require('deep-equal'); + +export interface ITestContext { + page: Page; + termHandle: JSHandle; + proxy: TerminalProxy; +} + +export async function createTestContext(browser: Browser): Promise { + const page = await browser.newPage(); + page.on('console', e => console.log(`[${browser.browserType().name()}:${e.type()}]`, e)); + page.on('pageerror', e => console.error(`[${browser.browserType().name()}]`, e)); + await page.goto('/test'); + const proxy = new TerminalProxy(page); + proxy.initPage(); + return { + page, + termHandle: await page.evaluateHandle('window.term'), + proxy + }; +} + +type EnsureAsync = T extends PromiseLike ? T : Promise; +type EnsureAsyncProperties = { + [Key in keyof T]: EnsureAsync +}; + +interface ITerminalProxy extends + // Interfaces that the proxy implements with async values + EnsureAsyncProperties> { + + // Custom proxy methods + evaluate(pageFunction: PageFunction[], T>): Promise; + write(data: string | Uint8Array): Promise; +} + +export class TerminalProxy implements ITerminalProxy { + constructor(private readonly _page: Page) { + } + + /** + * Initialize the proxy for a new playwright page. + */ + public async initPage(): Promise { + await this._page.exposeFunction('onBell', () => this._onBell.fire()); + await this._page.exposeFunction('onBinary', (e: string) => this._onBinary.fire(e)); + await this._page.exposeFunction('onCursorMove', () => this._onCursorMove.fire()); + await this._page.exposeFunction('onData', (e: string) => this._onData.fire(e)); + await this._page.exposeFunction('onKey', (e: { key: string, domEvent: KeyboardEvent }) => this._onKey.fire(e)); + await this._page.exposeFunction('onLineFeed', () => this._onLineFeed.fire()); + await this._page.exposeFunction('onRender', (e: { start: number, end: number }) => this._onRender.fire(e)); + await this._page.exposeFunction('onResize', (e: { cols: number, rows: number }) => this._onResize.fire(e)); + await this._page.exposeFunction('onScroll', (e: number) => this._onScroll.fire(e)); + await this._page.exposeFunction('onSelectionChange', () => this._onSelectionChange.fire()); + await this._page.exposeFunction('onTitleChange', (e: string) => this._onTitleChange.fire(e)); + } + + /** + * Initialize the proxy for a new terminal object. + */ + public async initTerm(): Promise { + for (const emitter of [ + this._onBell, + this._onBinary, + this._onCursorMove, + this._onData, + this._onKey, + this._onLineFeed, + this._onRender, + this._onResize, + this._onScroll, + this._onSelectionChange, + this._onTitleChange + ]) { + emitter.clearListeners(); + } + await this.evaluate(([term]) => term.onBell((window as any).onBell)); + await this.evaluate(([term]) => term.onBinary((window as any).onBinary)); + await this.evaluate(([term]) => term.onCursorMove((window as any).onCursorMove)); + await this.evaluate(([term]) => term.onData((window as any).onData)); + await this.evaluate(([term]) => term.onKey((window as any).onKey)); + await this.evaluate(([term]) => term.onLineFeed((window as any).onLineFeed)); + await this.evaluate(([term]) => term.onRender((window as any).onRender)); + await this.evaluate(([term]) => term.onResize((window as any).onResize)); + await this.evaluate(([term]) => term.onScroll((window as any).onScroll)); + await this.evaluate(([term]) => term.onSelectionChange((window as any).onSelectionChange)); + await this.evaluate(([term]) => term.onTitleChange((window as any).onTitleChange)); + } + + // #region Events + private _onBell = new EventEmitter(); + public readonly onBell = this._onBell.event; + private _onBinary = new EventEmitter(); + public readonly onBinary = this._onBinary.event; + private _onCursorMove = new EventEmitter(); + public readonly onCursorMove = this._onCursorMove.event; + private _onData = new EventEmitter(); + public readonly onData = this._onData.event; + private _onKey = new EventEmitter<{ key: string, domEvent: KeyboardEvent }>(); + public readonly onKey = this._onKey.event; + private _onLineFeed = new EventEmitter(); + public readonly onLineFeed = this._onLineFeed.event; + private _onRender = new EventEmitter<{ start: number, end: number }>(); + public readonly onRender = this._onRender.event; + private _onResize = new EventEmitter<{ cols: number, rows: number }>(); + public readonly onResize = this._onResize.event; + private _onScroll = new EventEmitter(); + public readonly onScroll = this._onScroll.event; + private _onSelectionChange = new EventEmitter(); + public readonly onSelectionChange = this._onSelectionChange.event; + private _onTitleChange = new EventEmitter(); + public readonly onTitleChange = this._onTitleChange.event; + // #endregion + + // #region Simple properties + public get cols(): Promise { return this.evaluate(([term]) => term.cols); } + public get rows(): Promise { return this.evaluate(([term]) => term.rows); } + public get modes(): Promise { return this.evaluate(([term]) => term.modes); } + // #endregion + + // #region Complex properties + public get buffer(): TerminalBufferNamespaceProxy { return new TerminalBufferNamespaceProxy(this._page, this); } + /** + * Exposes somewhat unsafe access to internals for testing things difficult to do with the regular + * API + */ + public get core(): TerminalCoreProxy { return new TerminalCoreProxy(this._page, this); } + // #endregion + + // #region Proxied methods + public async dispose(): Promise { return this.evaluate(([term]) => term.dispose()); } + public async reset(): Promise { return this.evaluate(([term]) => term.reset()); } + public async clear(): Promise { return this.evaluate(([term]) => term.clear()); } + public async focus(): Promise { return this.evaluate(([term]) => term.focus()); } + public async blur(): Promise { return this.evaluate(([term]) => term.blur()); } + public async hasSelection(): Promise { return this.evaluate(([term]) => term.hasSelection()); } + public async getSelection(): Promise { return this.evaluate(([term]) => term.getSelection()); } + public async getSelectionPosition(): Promise { return this.evaluate(([term]) => term.getSelectionPosition()); } + public async selectAll(): Promise { return this.evaluate(([term]) => term.selectAll()); } + public async clearSelection(): Promise { return this.evaluate(([term]) => term.clearSelection()); } + public async select(column: number, row: number, length: number): Promise { return this._page.evaluate(([term, column, row, length]) => term.select(column, row, length), [await this.getHandle(), column, row, length] as const); } + public async paste(data: string): Promise { return this._page.evaluate(([term, data]) => term.paste(data), [await this.getHandle(), data] as const); } + public async getOption(key: T): Promise { return this._page.evaluate(([term, key]) => term.options[key as T], [await this.getHandle(), key] as const); } + public async setOption(key: T, value: ITerminalOptions[T]): Promise { return this._page.evaluate(([term, key, value]) => term.options[key as T] = (value as ITerminalOptions[T]), [await this.getHandle(), key, value] as const); } + public async setOptions(value: Partial): Promise { + return this._page.evaluate(([term, value]) => { + term.options = value; + }, [await this.getHandle(), value] as const); + } + public async scrollToTop(): Promise { return this.evaluate(([term]) => term.scrollToTop()); } + public async scrollToBottom(): Promise { return this.evaluate(([term]) => term.scrollToBottom()); } + public async scrollPages(pageCount: number): Promise { return this._page.evaluate(([term, pageCount]) => term.scrollPages(pageCount), [await this.getHandle(), pageCount] as const); } + public async scrollToLine(line: number): Promise { return this._page.evaluate(([term, line]) => term.scrollToLine(line), [await this.getHandle(), line] as const); } + public async scrollLines(amount: number): Promise { return this._page.evaluate(([term, amount]) => term.scrollLines(amount), [await this.getHandle(), amount] as const); } + public async write(data: string | Uint8Array): Promise { + return this._page.evaluate(([term, data]) => { + return new Promise(r => term.write(typeof data === 'string' ? data : new Uint8Array(data), r)); + }, [await this.getHandle(), typeof data === 'string' ? data : Array.from(data)] as const); + } + public async writeln(data: string | Uint8Array): Promise { + return this._page.evaluate(([term, data]) => { + return new Promise(r => term.writeln(typeof data === 'string' ? data : new Uint8Array(data), r)); + }, [await this.getHandle(), typeof data === 'string' ? data : Array.from(data)] as const); + } + public async resize(cols: number, rows: number): Promise { return this._page.evaluate(([term, cols, rows]) => term.resize(cols, rows), [await this.getHandle(), cols, rows] as const); } + public async registerMarker(y: number): Promise { return this._page.evaluate(([term, y]) => term.registerMarker(y), [await this.getHandle(), y] as const); } + // #endregion + + public async evaluate(pageFunction: PageFunction[], T>): Promise { + return this._page.evaluate(pageFunction, [await this.getHandle()]); + } + + public async evaluateHandle(pageFunction: PageFunction[], T>): Promise> { + return this._page.evaluateHandle(pageFunction, [await this.getHandle()]); + } + + public async getHandle(): Promise> { + // This is async because it must be evaluated each time it is called since term may have changed + return this._page.evaluateHandle('window.term'); + } +} + +class TerminalBufferNamespaceProxy { + constructor( + private readonly _page: Page, + private readonly _proxy: TerminalProxy + ) { + + } + + public get active(): TerminalBufferProxy { return new TerminalBufferProxy(this._page, this._proxy, this._proxy.evaluateHandle(([term]) => term.buffer.active)); } + public get normal(): TerminalBufferProxy { return new TerminalBufferProxy(this._page, this._proxy, this._proxy.evaluateHandle(([term]) => term.buffer.normal)); } + public get alternate(): TerminalBufferProxy { return new TerminalBufferProxy(this._page, this._proxy, this._proxy.evaluateHandle(([term]) => term.buffer.alternate)); } +} + +class TerminalBufferProxy { + constructor( + private readonly _page: Page, + private readonly _proxy: TerminalProxy, + private readonly _handle: Promise> + ) { + } + + public get type(): Promise<'normal' | 'alternate'> { return this.evaluate(([buffer]) => buffer.type); } + public get cursorY(): Promise { return this.evaluate(([buffer]) => buffer.cursorY); } + public get cursorX(): Promise { return this.evaluate(([buffer]) => buffer.cursorX); } + public get viewportY(): Promise { return this.evaluate(([buffer]) => buffer.viewportY); } + public get baseY(): Promise { return this.evaluate(([buffer]) => buffer.baseY); } + public get length(): Promise { return this.evaluate(([buffer]) => buffer.length); } + public async getLine(y: number): Promise { + const lineHandle = await this._page.evaluateHandle(([buffer, y]) => buffer.getLine(y), [await this._handle, y] as const); + const value = await lineHandle.jsonValue(); + if (value) { + return new TerminalBufferLine(this._page, lineHandle as JSHandle); + } + return undefined; + } + + public async evaluate(pageFunction: PageFunction[], T>): Promise { + return this._page.evaluate(pageFunction, [await this._handle]); + } +} + +class TerminalBufferLine { + constructor( + private readonly _page: Page, + private readonly _handle: JSHandle + ) { + } + + public get length(): Promise { return this.evaluate(([bufferLine]) => bufferLine.length); } + public get isWrapped(): Promise { return this.evaluate(([bufferLine]) => bufferLine.isWrapped); } + + public translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): Promise { + return this._page.evaluate(([bufferLine, trimRight, startColumn, endColumn]) => { + return bufferLine.translateToString(trimRight, startColumn, endColumn); + }, [this._handle, trimRight, startColumn, endColumn] as const); + } + + public async getCell(x: number): Promise { + const cellHandle = await this._page.evaluateHandle(([bufferLine, x]) => bufferLine.getCell(x), [this._handle, x] as const); + const value = await cellHandle.jsonValue(); + if (value) { + return new TerminalBufferCell(this._page, cellHandle as JSHandle); + } + return undefined; + } + + public async evaluate(pageFunction: PageFunction[], T>): Promise { + return this._page.evaluate(pageFunction, [this._handle]); + } +} + +class TerminalBufferCell { + constructor( + private readonly _page: Page, + private readonly _handle: JSHandle + ) { + } + + public getWidth(): Promise { return this.evaluate(([line]) => line.getWidth()); } + public getChars(): Promise { return this.evaluate(([line]) => line.getChars()); } + + public async evaluate(pageFunction: PageFunction[], T>): Promise { + return this._page.evaluate(pageFunction, [this._handle]); + } +} + +class TerminalCoreProxy { + constructor( + private readonly _page: Page, + private readonly _proxy: TerminalProxy + ) { + } + + public get isDisposed(): Promise { return this.evaluate(([core]) => (core as any)._isDisposed); } + public get renderDimensions(): Promise { return this.evaluate(([core]) => ((core as any)._renderService as IRenderService).dimensions); } + + public async triggerBinaryEvent(data: string): Promise { + return this._page.evaluate(([core, data]) => core.coreService.triggerBinaryEvent(data), [await this._getCoreHandle(), data] as const); + } + + + private async _getCoreHandle(): Promise> { + return this._proxy.evaluateHandle(([term]) => (term as any)._core as ICoreTerminal); + } + + public async evaluate(pageFunction: PageFunction[], T>): Promise { + return this._page.evaluate(pageFunction, [await this._getCoreHandle()]); + } +} + +export async function openTerminal(ctx: ITestContext, options: ITerminalOptions = {}): Promise { + await ctx.page.evaluate(` + if ('term' in window) { + window.term.dispose(); + } + window.term = new Terminal(${JSON.stringify(options)}); + window.term.open(document.querySelector('#terminal-container')); + `); + await ctx.page.waitForSelector('.xterm-rows'); + ctx.termHandle = await ctx.page.evaluateHandle('window.term'); + await ctx.proxy.initTerm(); +} + + + +export async function pollFor(page: playwright.Page, evalOrFn: string | (() => Promise), val: T, preFn?: () => Promise, maxDuration?: number): Promise { + if (preFn) { + await preFn(); + } + const result = typeof evalOrFn === 'string' ? await page.evaluate(evalOrFn) : await evalOrFn(); + + if (process.env.DEBUG) { + console.log('pollFor result: ', result); + } + + if (!deepEqual(result, val)) { + if (maxDuration === undefined) { + maxDuration = 2000; + } + if (maxDuration <= 0) { + deepStrictEqual(result, val, 'pollFor max duration exceeded'); + } + return new Promise(r => { + setTimeout(() => r(pollFor(page, evalOrFn, val, preFn, maxDuration! - 10)), 10); + }); + } +} + +export async function writeSync(page: playwright.Page, data: string): Promise { + await page.evaluate(` + window.ready = false; + window.term.write('${data}', () => window.ready = true); + `); + await pollFor(page, 'window.ready', true); +} + +export async function timeout(ms: number): Promise { + return new Promise(r => setTimeout(r, ms)); +} + +export function getBrowserType(): playwright.BrowserType | playwright.BrowserType | playwright.BrowserType { + // Default to chromium + let browserType: playwright.BrowserType | playwright.BrowserType | playwright.BrowserType = playwright['chromium']; + + const index = process.argv.indexOf('--browser'); + if (index !== -1 && process.argv.length > index + 1 && typeof process.argv[index + 1] === 'string') { + const string = process.argv[index + 1]; + if (string === 'firefox' || string === 'webkit') { + browserType = playwright[string]; + } + } + + return browserType; +} + +export function launchBrowser(): Promise { + const browserType = getBrowserType(); + const options: Record = { + headless: process.argv.includes('--headless') + }; + + const index = process.argv.indexOf('--executablePath'); + if (index > 0 && process.argv.length > index + 1 && typeof process.argv[index + 1] === 'string') { + options.executablePath = process.argv[index + 1]; + } + + return browserType.launch(options); +} diff --git a/test/playwright/playwright.config.ts b/test/playwright/playwright.config.ts new file mode 100644 index 00000000..57b2f1e2 --- /dev/null +++ b/test/playwright/playwright.config.ts @@ -0,0 +1,34 @@ +import { PlaywrightTestConfig } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + testDir: '.', + timeout: 10000, + projects: [ + { + name: 'Chrome Stable', + use: { + browserName: 'chromium', + channel: 'chrome' + } + }, + { + name: 'Firefox Stable', + use: { + browserName: 'firefox' + } + }, + { + name: 'WebKit', + use: { + browserName: 'webkit' + } + } + ], + webServer: { + command: 'npm start', + port: 3000, + timeout: 120000, + reuseExistingServer: !process.env.CI + } +}; +export default config; diff --git a/test/playwright/tsconfig.json b/test/playwright/tsconfig.json new file mode 100644 index 00000000..2cea437a --- /dev/null +++ b/test/playwright/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "es6" + ], + "rootDir": ".", + "outDir": "../../out-test/playwright", + "types": [ + "../../node_modules/@types/mocha", + "../../node_modules/@types/node" + ], + "sourceMap": true, + "removeComments": true, + "pretty": true, + "strict": true, + "declaration": true, + "baseUrl": ".", + "paths": { + "browser/*": [ + "../../src/browser/*" + ], + "common/*": [ + "../../src/common/*" + ] + }, + "target": "es2016", + "module": "commonjs", + }, + "include": [ + "./**/*", + "../../typings/xterm.d.ts" + ], + "references": [ + { + "path": "../../src/browser" + }, + { + "path": "../../src/common" + } + ] +} diff --git a/tsconfig.all.json b/tsconfig.all.json index 7b577b07..79ced9cc 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -6,6 +6,7 @@ { "path": "./src/headless" }, { "path": "./test/api" }, { "path": "./test/benchmark" }, + { "path": "./test/playwright" }, { "path": "./addons/xterm-addon-attach" }, { "path": "./addons/xterm-addon-canvas" }, { "path": "./addons/xterm-addon-fit" }, diff --git a/yarn.lock b/yarn.lock index 206445f5..2945d97b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -347,6 +347,16 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@playwright/test@^1.37.1": + version "1.37.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.37.1.tgz#e7f44ae0faf1be52d6360c6bbf689fd0057d9b6f" + integrity sha512-bq9zTli3vWJo8S3LwB91U0qDNQDpEXnw7knhxLM0nwDvexQAwx9tO8iKDZSqqneVq+URd/WIoz+BALMqUTgdSg== + dependencies: + "@types/node" "*" + playwright-core "1.37.1" + optionalDependencies: + fsevents "2.3.2" + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -1920,7 +1930,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@~2.3.2: +fsevents@2.3.2, fsevents@~2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -3066,13 +3076,6 @@ playwright-core@1.37.1: resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.37.1.tgz#cb517d52e2e8cb4fa71957639f1cd105d1683126" integrity sha512-17EuQxlSIYCmEMwzMqusJ2ztDgJePjrbttaefgdsiqeLWidjYz9BxXaTaZWxH1J95SHGk6tjE+dwgWILJoUZfA== -playwright@^1.37.1: - version "1.37.1" - resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.37.1.tgz#6e488d82d7d98b9127c5db9c701f9c956ab47e76" - integrity sha512-bgUXRrQKhT48zHdxDYQTpf//0xDfDd5hLeEhjuSw8rXEGoT9YeElpfvs/izonTNY21IQZ7d3s22jLxYaAnubbQ== - dependencies: - playwright-core "1.37.1" - prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"