From cf991afb33c6d75ed031ce9c330388ead229b30b Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 17 Feb 2022 20:14:10 +0000 Subject: [PATCH 1/8] Add helper for navigating the quick picker This has problems similar to the menu except instead of closing it gets re-created which interrupts the hover call and causes the test to fail. Now it will keep trying just like the menu. --- test/e2e/models/CodeServer.ts | 121 ++++++++++++++++++++++++---------- 1 file changed, 87 insertions(+), 34 deletions(-) diff --git a/test/e2e/models/CodeServer.ts b/test/e2e/models/CodeServer.ts index 62d0218e3a53..6c32a37f69b1 100644 --- a/test/e2e/models/CodeServer.ts +++ b/test/e2e/models/CodeServer.ts @@ -3,7 +3,7 @@ import * as cp from "child_process" import { promises as fs } from "fs" import * as path from "path" import { Page } from "playwright" -import { logError } from "../../../src/common/util" +import { logError, plural } from "../../../src/common/util" import { onLine } from "../../../src/node/util" import { PASSWORD, workspaceDir } from "../../utils/constants" import { idleTimer, tmpdir } from "../../utils/helpers" @@ -13,14 +13,21 @@ interface CodeServerProcess { address: string } -class CancelToken { +class Context { private _canceled = false + private _done = false public canceled(): boolean { return this._canceled } + public done(): void { + this._done = true + } public cancel(): void { this._canceled = true } + public finish(): boolean { + return this._done + } } /** @@ -287,13 +294,43 @@ export class CodeServerPage { } /** - * Navigate through the specified set of menus. If it fails it will keep - * trying. + * Navigate through the items in the selector. `open` is a function that will + * open the menu/popup containing the items through which to navigation. */ - async navigateMenus(menus: string[]) { - const navigate = async (cancelToken: CancelToken) => { - const steps: Array<() => Promise> = [() => this.page.waitForSelector(`${menuSelector}:focus-within`)] - for (const menu of menus) { + async navigateItems(items: string[], selector: string, open?: (selector: string) => void): Promise { + const logger = this.codeServer.logger.named(selector) + + /** + * If the selector loses focus or gets removed this will resolve with false, + * signaling we need to try again. + */ + const openThenWaitClose = async (ctx: Context) => { + if (open) { + await open(selector) + } + this.codeServer.logger.debug(`watching ${selector}`) + try { + await this.page.waitForSelector(`${selector}:not(:focus-within)`) + } catch (error) { + if (!ctx.done()) { + this.codeServer.logger.debug(`${selector} navigation: ${error.message || error}`) + } + } + return false + } + + /** + * This will step through each item, aborting and returning false if + * canceled or if any navigation step has an error which signals we need to + * try again. + */ + const navigate = async (ctx: Context) => { + const steps: Array<{fn: () => Promise, name: string}> = [{ + fn: () => this.page.waitForSelector(`${selector}:focus-within`), + name: "focus", + }] + + for (const item of items) { // Normally these will wait for the item to be visible and then execute // the action. The problem is that if the menu closes these will still // be waiting and continue to execute once the menu is visible again, @@ -301,43 +338,59 @@ export class CodeServerPage { // if the old promise clicks logout before the new one can). By // splitting them into two steps each we can cancel before running the // action. - steps.push(() => this.page.hover(`text=${menu}`, { trial: true })) - steps.push(() => this.page.hover(`text=${menu}`, { force: true })) - steps.push(() => this.page.click(`text=${menu}`, { trial: true })) - steps.push(() => this.page.click(`text=${menu}`, { force: true })) + steps.push({fn: () => this.page.hover(`${selector} :text("${item}")`, { trial: true }), name: `${item}:hover:trial`}) + steps.push({fn: () => this.page.hover(`${selector} :text("${item}")`, { force: true }), name: `${item}:hover:force`}) + steps.push({fn: () => this.page.click(`${selector} :text("${item}")`, { trial: true }), name: `${item}:click:trial`}) + steps.push({fn: () => this.page.click(`${selector} :text("${item}")`, { force: true }), name: `${item}:click:force`}) } + for (const step of steps) { - await step() - if (cancelToken.canceled()) { - this.codeServer.logger.debug("menu navigation canceled") + try { + logger.debug(`navigation step: ${step.name}`) + await step.fn() + if (ctx.canceled()) { + logger.debug("navigation canceled") + return false + } + } catch (error) { + logger.debug(`navigation: ${error.message || error}`) return false } } return true } - const menuSelector = '[aria-label="Application Menu"]' - const open = async () => { - await this.page.click(menuSelector) - await this.page.waitForSelector(`${menuSelector}:not(:focus-within)`) - return false + // We are seeing the menu closing after opening if we open it too soon and + // the picker getting recreated in the middle of trying to select an item. + // To counter this we will keep trying to navigate through the items every + // time we lose focus or there is an error. + let attempts = 1 + let context = new Context() + while (!(await Promise.race([openThenWaitClose(), navigate(context)]))) { + ++attempts + logger.debug("closed, retrying (${attempt}/∞)") + context.cancel() + context = new Context() } - // TODO: Starting in 1.57 something closes the menu after opening it if we - // open it too soon. To counter that we'll watch for when the menu loses - // focus and when/if it does we'll try again. - // I tried using the classic menu but it doesn't show up at all for some - // reason. I also tried toggle but the menu disappears after toggling. - let retryCount = 0 - let cancelToken = new CancelToken() - while (!(await Promise.race([open(), navigate(cancelToken)]))) { - this.codeServer.logger.debug("menu was closed, retrying") - ++retryCount - cancelToken.cancel() - cancelToken = new CancelToken() - } + context.finish() + logger.debug(`navigation took ${attempts} ${plural(attempts, "attempt")}`) + } - this.codeServer.logger.debug(`menu navigation retries: ${retryCount}`) + /** + * Navigate through a currently opened picker, retrying on failure. + */ + async navigatePicker(items: string[]): Promise { + await this.navigateItems(items, ".quick-input-widget") + } + + /** + * Navigate through the menu, retrying on failure. + */ + async navigateMenus(menus: string[]): Promise { + await this.navigateItems(menus, '[aria-label="Application Menu"]', async (selector) => { + await this.page.click(selector) + }) } /** From 8ce67308105d7f0bd5f2ef0f0487987f8c2fbe5b Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 17 Feb 2022 20:15:53 +0000 Subject: [PATCH 2/8] Add a test for opening a file --- test/e2e/codeServer.test.ts | 9 +++++++++ test/e2e/models/CodeServer.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/test/e2e/codeServer.test.ts b/test/e2e/codeServer.test.ts index 06eaaea0d8b9..38a6fbaf29a2 100644 --- a/test/e2e/codeServer.test.ts +++ b/test/e2e/codeServer.test.ts @@ -1,3 +1,5 @@ +import * as path from "path" +import { promises as fs } from "fs" import { describe, test, expect } from "./baseFixture" describe("CodeServer", true, [], () => { @@ -24,4 +26,11 @@ describe("CodeServer", true, [], () => { await codeServerPage.focusTerminal() expect(await codeServerPage.page.isVisible("#terminal")).toBe(true) }) + + test("should open a file", async ({ codeServerPage }) => { + const dir = await codeServerPage.dir() + const file = path.join(dir, "foo") + await fs.writeFile(file, "bar") + await codeServerPage.openFile(file) + }) }) diff --git a/test/e2e/models/CodeServer.ts b/test/e2e/models/CodeServer.ts index 6c32a37f69b1..25d36c859624 100644 --- a/test/e2e/models/CodeServer.ts +++ b/test/e2e/models/CodeServer.ts @@ -37,6 +37,7 @@ export class CodeServer { private process: Promise | undefined public readonly logger: Logger private closed = false + private workspaceDir: Promise | undefined constructor(name: string, private readonly codeServerArgs: string[]) { this.logger = logger.named(name) @@ -54,11 +55,18 @@ export class CodeServer { return address } + async dir(): Promise { + if (!this.workspaceDir) { + this.workspaceDir = tmpdir(workspaceDir) + } + return this.workspaceDir + } + /** * Create a random workspace and seed it with settings. */ private async createWorkspace(): Promise { - const dir = await tmpdir(workspaceDir) + const dir = await this.dir() await fs.mkdir(path.join(dir, "User")) await fs.writeFile( path.join(dir, "User/settings.json"), @@ -190,6 +198,10 @@ export class CodeServerPage { return this.codeServer.address() } + dir() { + return this.codeServer.dir() + } + /** * Navigate to code-server. */ @@ -280,6 +292,22 @@ export class CodeServerPage { await this.page.waitForSelector("textarea.xterm-helper-textarea") } + /** + * Open a file by using menus. + */ + async openFile(file: string) { + await this.navigateMenus(["File", "Open File"]) + await this.navigatePicker([path.basename(file)]) + await this.waitForTab(file) + } + + /** + * Wait for a tab to open for the specified file. + */ + async waitForTab(file: string): Promise { + return this.page.waitForSelector(`.tab :text("${path.basename(file)}")`) + } + /** * Navigate to the command palette via menus then execute a command by typing * it then clicking the match from the results. From 87b38244102d9bf3f76fdadd861df5ee00517c09 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 17 Feb 2022 23:02:35 +0000 Subject: [PATCH 3/8] Add test for colliding state --- test/e2e/codeServer.test.ts | 21 +++++++++++++++++++++ test/e2e/models/CodeServer.ts | 19 +++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/test/e2e/codeServer.test.ts b/test/e2e/codeServer.test.ts index 38a6fbaf29a2..6a7d1ec19b55 100644 --- a/test/e2e/codeServer.test.ts +++ b/test/e2e/codeServer.test.ts @@ -33,4 +33,25 @@ describe("CodeServer", true, [], () => { await fs.writeFile(file, "bar") await codeServerPage.openFile(file) }) + + test("should not share state with other paths", async ({ codeServerPage }) => { + const dir = await codeServerPage.dir() + const file = path.join(dir, "foo") + await fs.writeFile(file, "bar") + + await codeServerPage.openFile(file) + + // If we reload now VS Code will be unable to save the state changes so wait + // until those have been written to the database. It flushes every five + // seconds so we need to wait at least that long. + await codeServerPage.page.waitForTimeout(5500) + + // The tab should re-open on refresh. + await codeServerPage.page.reload() + await codeServerPage.waitForTab(file) + + // The tab should not re-open on a different path. + await codeServerPage.setup(true, "/vscode") + expect(await codeServerPage.tabIsVisible(file)).toBe(false) + }) }) diff --git a/test/e2e/models/CodeServer.ts b/test/e2e/models/CodeServer.ts index 25d36c859624..f81d9ed33862 100644 --- a/test/e2e/models/CodeServer.ts +++ b/test/e2e/models/CodeServer.ts @@ -203,11 +203,11 @@ export class CodeServerPage { } /** - * Navigate to code-server. + * Navigate to a code-server endpoint. By default go to the root. */ - async navigate() { - const address = await this.codeServer.address() - await this.page.goto(address, { waitUntil: "networkidle" }) + async navigate(path: string = "/") { + const to = new URL(path, await this.codeServer.address()) + await this.page.goto(to.toString(), { waitUntil: "networkidle" }) } /** @@ -308,6 +308,13 @@ export class CodeServerPage { return this.page.waitForSelector(`.tab :text("${path.basename(file)}")`) } + /** + * See if the specified tab is open. + */ + async tabIsVisible(file: string): Promise { + return this.page.isVisible(`.tab :text("${path.basename(file)}")`) + } + /** * Navigate to the command palette via menus then execute a command by typing * it then clicking the match from the results. @@ -426,8 +433,8 @@ export class CodeServerPage { * * It is recommended to run setup before using this model in any tests. */ - async setup(authenticated: boolean) { - await this.navigate() + async setup(authenticated: boolean, endpoint = "/") { + await this.navigate(endpoint) // If we aren't authenticated we'll see a login page so we can't wait until // the editor is ready. if (authenticated) { From e28914a3382fb6875f7499ccaeeaac48e82d15ad Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 17 Feb 2022 23:03:32 +0000 Subject: [PATCH 4/8] Update VS Code This contains the colliding state fix. --- vendor/package.json | 2 +- vendor/yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/vendor/package.json b/vendor/package.json index c1414d98ffbc..cb8e1098bae4 100644 --- a/vendor/package.json +++ b/vendor/package.json @@ -7,6 +7,6 @@ "postinstall": "./postinstall.sh" }, "devDependencies": { - "code-oss-dev": "coder/vscode#96e241330d9c44b64897c1e5031e00aa894103db" + "code-oss-dev": "coder/vscode#6337ee490d16b7dfd8854d22c998f58d6cd21ef5" } } diff --git a/vendor/yarn.lock b/vendor/yarn.lock index 98921a1b6c47..7ad95d8805cd 100644 --- a/vendor/yarn.lock +++ b/vendor/yarn.lock @@ -274,9 +274,9 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" -code-oss-dev@coder/vscode#96e241330d9c44b64897c1e5031e00aa894103db: +code-oss-dev@coder/vscode#6337ee490d16b7dfd8854d22c998f58d6cd21ef5: version "1.63.0" - resolved "https://codeload.github.com/coder/vscode/tar.gz/96e241330d9c44b64897c1e5031e00aa894103db" + resolved "https://codeload.github.com/coder/vscode/tar.gz/6337ee490d16b7dfd8854d22c998f58d6cd21ef5" dependencies: "@microsoft/applicationinsights-web" "^2.6.4" "@parcel/watcher" "2.0.3" From 2dad878fcc000a743304d12f8033f722912ecec8 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 18 Feb 2022 17:03:19 +0000 Subject: [PATCH 5/8] fmt --- test/e2e/codeServer.test.ts | 2 +- test/e2e/models/CodeServer.ts | 32 +++++++++++++++++++++++--------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/test/e2e/codeServer.test.ts b/test/e2e/codeServer.test.ts index 6a7d1ec19b55..349e106f5998 100644 --- a/test/e2e/codeServer.test.ts +++ b/test/e2e/codeServer.test.ts @@ -1,5 +1,5 @@ -import * as path from "path" import { promises as fs } from "fs" +import * as path from "path" import { describe, test, expect } from "./baseFixture" describe("CodeServer", true, [], () => { diff --git a/test/e2e/models/CodeServer.ts b/test/e2e/models/CodeServer.ts index f81d9ed33862..4f37cbfe7e1a 100644 --- a/test/e2e/models/CodeServer.ts +++ b/test/e2e/models/CodeServer.ts @@ -205,7 +205,7 @@ export class CodeServerPage { /** * Navigate to a code-server endpoint. By default go to the root. */ - async navigate(path: string = "/") { + async navigate(path = "/") { const to = new URL(path, await this.codeServer.address()) await this.page.goto(to.toString(), { waitUntil: "networkidle" }) } @@ -360,10 +360,12 @@ export class CodeServerPage { * try again. */ const navigate = async (ctx: Context) => { - const steps: Array<{fn: () => Promise, name: string}> = [{ - fn: () => this.page.waitForSelector(`${selector}:focus-within`), - name: "focus", - }] + const steps: Array<{ fn: () => Promise; name: string }> = [ + { + fn: () => this.page.waitForSelector(`${selector}:focus-within`), + name: "focus", + }, + ] for (const item of items) { // Normally these will wait for the item to be visible and then execute @@ -373,10 +375,22 @@ export class CodeServerPage { // if the old promise clicks logout before the new one can). By // splitting them into two steps each we can cancel before running the // action. - steps.push({fn: () => this.page.hover(`${selector} :text("${item}")`, { trial: true }), name: `${item}:hover:trial`}) - steps.push({fn: () => this.page.hover(`${selector} :text("${item}")`, { force: true }), name: `${item}:hover:force`}) - steps.push({fn: () => this.page.click(`${selector} :text("${item}")`, { trial: true }), name: `${item}:click:trial`}) - steps.push({fn: () => this.page.click(`${selector} :text("${item}")`, { force: true }), name: `${item}:click:force`}) + steps.push({ + fn: () => this.page.hover(`${selector} :text("${item}")`, { trial: true }), + name: `${item}:hover:trial`, + }) + steps.push({ + fn: () => this.page.hover(`${selector} :text("${item}")`, { force: true }), + name: `${item}:hover:force`, + }) + steps.push({ + fn: () => this.page.click(`${selector} :text("${item}")`, { trial: true }), + name: `${item}:click:trial`, + }) + steps.push({ + fn: () => this.page.click(`${selector} :text("${item}")`, { force: true }), + name: `${item}:click:force`, + }) } for (const step of steps) { From ee850b5bca44ca0d707fea1bdf155f7f8f848080 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 18 Feb 2022 21:03:25 +0000 Subject: [PATCH 6/8] Standardize on endpoint vs path --- test/e2e/models/CodeServer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/models/CodeServer.ts b/test/e2e/models/CodeServer.ts index 4f37cbfe7e1a..e06fa4c6ea44 100644 --- a/test/e2e/models/CodeServer.ts +++ b/test/e2e/models/CodeServer.ts @@ -205,8 +205,8 @@ export class CodeServerPage { /** * Navigate to a code-server endpoint. By default go to the root. */ - async navigate(path = "/") { - const to = new URL(path, await this.codeServer.address()) + async navigate(endpoint = "/") { + const to = new URL(endpoint, await this.codeServer.address()) await this.page.goto(to.toString(), { waitUntil: "networkidle" }) } From 2e001f24f80d3f4849c9ff0a3d8760bea7e576a8 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 18 Feb 2022 21:04:50 +0000 Subject: [PATCH 7/8] Rename picker to quick input to match VS Code terminology --- test/e2e/models/CodeServer.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/e2e/models/CodeServer.ts b/test/e2e/models/CodeServer.ts index e06fa4c6ea44..34a9d34bef66 100644 --- a/test/e2e/models/CodeServer.ts +++ b/test/e2e/models/CodeServer.ts @@ -297,7 +297,7 @@ export class CodeServerPage { */ async openFile(file: string) { await this.navigateMenus(["File", "Open File"]) - await this.navigatePicker([path.basename(file)]) + await this.navigateQuickInput([path.basename(file)]) await this.waitForTab(file) } @@ -427,9 +427,10 @@ export class CodeServerPage { } /** - * Navigate through a currently opened picker, retrying on failure. + * Navigate through a currently opened "quick input" widget, retrying on + * failure. */ - async navigatePicker(items: string[]): Promise { + async navigateQuickInput(items: string[]): Promise { await this.navigateItems(items, ".quick-input-widget") } From 092bfaaff4f22c1621245da704161c67182b7ed2 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 18 Feb 2022 21:15:16 +0000 Subject: [PATCH 8/8] Rename dir to workspaceDir --- test/e2e/codeServer.test.ts | 4 ++-- test/e2e/models/CodeServer.ts | 22 ++++++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/test/e2e/codeServer.test.ts b/test/e2e/codeServer.test.ts index 349e106f5998..1f712f913a0e 100644 --- a/test/e2e/codeServer.test.ts +++ b/test/e2e/codeServer.test.ts @@ -28,14 +28,14 @@ describe("CodeServer", true, [], () => { }) test("should open a file", async ({ codeServerPage }) => { - const dir = await codeServerPage.dir() + const dir = await codeServerPage.workspaceDir const file = path.join(dir, "foo") await fs.writeFile(file, "bar") await codeServerPage.openFile(file) }) test("should not share state with other paths", async ({ codeServerPage }) => { - const dir = await codeServerPage.dir() + const dir = await codeServerPage.workspaceDir const file = path.join(dir, "foo") await fs.writeFile(file, "bar") diff --git a/test/e2e/models/CodeServer.ts b/test/e2e/models/CodeServer.ts index 34a9d34bef66..e8fff0716300 100644 --- a/test/e2e/models/CodeServer.ts +++ b/test/e2e/models/CodeServer.ts @@ -37,7 +37,7 @@ export class CodeServer { private process: Promise | undefined public readonly logger: Logger private closed = false - private workspaceDir: Promise | undefined + private _workspaceDir: Promise | undefined constructor(name: string, private readonly codeServerArgs: string[]) { this.logger = logger.named(name) @@ -55,18 +55,21 @@ export class CodeServer { return address } - async dir(): Promise { - if (!this.workspaceDir) { - this.workspaceDir = tmpdir(workspaceDir) + /** + * The workspace directory code-server opens with. + */ + get workspaceDir(): Promise { + if (!this._workspaceDir) { + this._workspaceDir = tmpdir(workspaceDir) } - return this.workspaceDir + return this._workspaceDir } /** * Create a random workspace and seed it with settings. */ private async createWorkspace(): Promise { - const dir = await this.dir() + const dir = await this.workspaceDir await fs.mkdir(path.join(dir, "User")) await fs.writeFile( path.join(dir, "User/settings.json"), @@ -198,8 +201,11 @@ export class CodeServerPage { return this.codeServer.address() } - dir() { - return this.codeServer.dir() + /** + * The workspace directory code-server opens with. + */ + get workspaceDir() { + return this.codeServer.workspaceDir } /**