Skip to content

Commit f9402a6

Browse files
authored
fix: state collision (#4881)
* 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. * Add a test for opening a file * Add test for colliding state * Update VS Code This contains the colliding state fix.
1 parent 23734d3 commit f9402a6

File tree

4 files changed

+183
-44
lines changed

4 files changed

+183
-44
lines changed

test/e2e/codeServer.test.ts

+30
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { promises as fs } from "fs"
2+
import * as path from "path"
13
import { describe, test, expect } from "./baseFixture"
24

35
describe("CodeServer", true, [], () => {
@@ -24,4 +26,32 @@ describe("CodeServer", true, [], () => {
2426
await codeServerPage.focusTerminal()
2527
expect(await codeServerPage.page.isVisible("#terminal")).toBe(true)
2628
})
29+
30+
test("should open a file", async ({ codeServerPage }) => {
31+
const dir = await codeServerPage.workspaceDir
32+
const file = path.join(dir, "foo")
33+
await fs.writeFile(file, "bar")
34+
await codeServerPage.openFile(file)
35+
})
36+
37+
test("should not share state with other paths", async ({ codeServerPage }) => {
38+
const dir = await codeServerPage.workspaceDir
39+
const file = path.join(dir, "foo")
40+
await fs.writeFile(file, "bar")
41+
42+
await codeServerPage.openFile(file)
43+
44+
// If we reload now VS Code will be unable to save the state changes so wait
45+
// until those have been written to the database. It flushes every five
46+
// seconds so we need to wait at least that long.
47+
await codeServerPage.page.waitForTimeout(5500)
48+
49+
// The tab should re-open on refresh.
50+
await codeServerPage.page.reload()
51+
await codeServerPage.waitForTab(file)
52+
53+
// The tab should not re-open on a different path.
54+
await codeServerPage.setup(true, "/vscode")
55+
expect(await codeServerPage.tabIsVisible(file)).toBe(false)
56+
})
2757
})

test/e2e/models/CodeServer.ts

+150-41
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as cp from "child_process"
33
import { promises as fs } from "fs"
44
import * as path from "path"
55
import { Page } from "playwright"
6-
import { logError } from "../../../src/common/util"
6+
import { logError, plural } from "../../../src/common/util"
77
import { onLine } from "../../../src/node/util"
88
import { PASSWORD, workspaceDir } from "../../utils/constants"
99
import { idleTimer, tmpdir } from "../../utils/helpers"
@@ -13,14 +13,21 @@ interface CodeServerProcess {
1313
address: string
1414
}
1515

16-
class CancelToken {
16+
class Context {
1717
private _canceled = false
18+
private _done = false
1819
public canceled(): boolean {
1920
return this._canceled
2021
}
22+
public done(): void {
23+
this._done = true
24+
}
2125
public cancel(): void {
2226
this._canceled = true
2327
}
28+
public finish(): boolean {
29+
return this._done
30+
}
2431
}
2532

2633
/**
@@ -30,6 +37,7 @@ export class CodeServer {
3037
private process: Promise<CodeServerProcess> | undefined
3138
public readonly logger: Logger
3239
private closed = false
40+
private _workspaceDir: Promise<string> | undefined
3341

3442
constructor(name: string, private readonly codeServerArgs: string[]) {
3543
this.logger = logger.named(name)
@@ -47,11 +55,21 @@ export class CodeServer {
4755
return address
4856
}
4957

58+
/**
59+
* The workspace directory code-server opens with.
60+
*/
61+
get workspaceDir(): Promise<string> {
62+
if (!this._workspaceDir) {
63+
this._workspaceDir = tmpdir(workspaceDir)
64+
}
65+
return this._workspaceDir
66+
}
67+
5068
/**
5169
* Create a random workspace and seed it with settings.
5270
*/
5371
private async createWorkspace(): Promise<string> {
54-
const dir = await tmpdir(workspaceDir)
72+
const dir = await this.workspaceDir
5573
await fs.mkdir(path.join(dir, "User"))
5674
await fs.writeFile(
5775
path.join(dir, "User/settings.json"),
@@ -184,11 +202,18 @@ export class CodeServerPage {
184202
}
185203

186204
/**
187-
* Navigate to code-server.
205+
* The workspace directory code-server opens with.
206+
*/
207+
get workspaceDir() {
208+
return this.codeServer.workspaceDir
209+
}
210+
211+
/**
212+
* Navigate to a code-server endpoint. By default go to the root.
188213
*/
189-
async navigate() {
190-
const address = await this.codeServer.address()
191-
await this.page.goto(address, { waitUntil: "networkidle" })
214+
async navigate(endpoint = "/") {
215+
const to = new URL(endpoint, await this.codeServer.address())
216+
await this.page.goto(to.toString(), { waitUntil: "networkidle" })
192217
}
193218

194219
/**
@@ -273,6 +298,29 @@ export class CodeServerPage {
273298
await this.page.waitForSelector("textarea.xterm-helper-textarea")
274299
}
275300

301+
/**
302+
* Open a file by using menus.
303+
*/
304+
async openFile(file: string) {
305+
await this.navigateMenus(["File", "Open File"])
306+
await this.navigateQuickInput([path.basename(file)])
307+
await this.waitForTab(file)
308+
}
309+
310+
/**
311+
* Wait for a tab to open for the specified file.
312+
*/
313+
async waitForTab(file: string): Promise<void> {
314+
return this.page.waitForSelector(`.tab :text("${path.basename(file)}")`)
315+
}
316+
317+
/**
318+
* See if the specified tab is open.
319+
*/
320+
async tabIsVisible(file: string): Promise<void> {
321+
return this.page.isVisible(`.tab :text("${path.basename(file)}")`)
322+
}
323+
276324
/**
277325
* Navigate to the command palette via menus then execute a command by typing
278326
* it then clicking the match from the results.
@@ -287,66 +335,127 @@ export class CodeServerPage {
287335
}
288336

289337
/**
290-
* Navigate through the specified set of menus. If it fails it will keep
291-
* trying.
338+
* Navigate through the items in the selector. `open` is a function that will
339+
* open the menu/popup containing the items through which to navigation.
292340
*/
293-
async navigateMenus(menus: string[]) {
294-
const navigate = async (cancelToken: CancelToken) => {
295-
const steps: Array<() => Promise<unknown>> = [() => this.page.waitForSelector(`${menuSelector}:focus-within`)]
296-
for (const menu of menus) {
341+
async navigateItems(items: string[], selector: string, open?: (selector: string) => void): Promise<void> {
342+
const logger = this.codeServer.logger.named(selector)
343+
344+
/**
345+
* If the selector loses focus or gets removed this will resolve with false,
346+
* signaling we need to try again.
347+
*/
348+
const openThenWaitClose = async (ctx: Context) => {
349+
if (open) {
350+
await open(selector)
351+
}
352+
this.codeServer.logger.debug(`watching ${selector}`)
353+
try {
354+
await this.page.waitForSelector(`${selector}:not(:focus-within)`)
355+
} catch (error) {
356+
if (!ctx.done()) {
357+
this.codeServer.logger.debug(`${selector} navigation: ${error.message || error}`)
358+
}
359+
}
360+
return false
361+
}
362+
363+
/**
364+
* This will step through each item, aborting and returning false if
365+
* canceled or if any navigation step has an error which signals we need to
366+
* try again.
367+
*/
368+
const navigate = async (ctx: Context) => {
369+
const steps: Array<{ fn: () => Promise<unknown>; name: string }> = [
370+
{
371+
fn: () => this.page.waitForSelector(`${selector}:focus-within`),
372+
name: "focus",
373+
},
374+
]
375+
376+
for (const item of items) {
297377
// Normally these will wait for the item to be visible and then execute
298378
// the action. The problem is that if the menu closes these will still
299379
// be waiting and continue to execute once the menu is visible again,
300380
// potentially conflicting with the new set of navigations (for example
301381
// if the old promise clicks logout before the new one can). By
302382
// splitting them into two steps each we can cancel before running the
303383
// action.
304-
steps.push(() => this.page.hover(`text=${menu}`, { trial: true }))
305-
steps.push(() => this.page.hover(`text=${menu}`, { force: true }))
306-
steps.push(() => this.page.click(`text=${menu}`, { trial: true }))
307-
steps.push(() => this.page.click(`text=${menu}`, { force: true }))
384+
steps.push({
385+
fn: () => this.page.hover(`${selector} :text("${item}")`, { trial: true }),
386+
name: `${item}:hover:trial`,
387+
})
388+
steps.push({
389+
fn: () => this.page.hover(`${selector} :text("${item}")`, { force: true }),
390+
name: `${item}:hover:force`,
391+
})
392+
steps.push({
393+
fn: () => this.page.click(`${selector} :text("${item}")`, { trial: true }),
394+
name: `${item}:click:trial`,
395+
})
396+
steps.push({
397+
fn: () => this.page.click(`${selector} :text("${item}")`, { force: true }),
398+
name: `${item}:click:force`,
399+
})
308400
}
401+
309402
for (const step of steps) {
310-
await step()
311-
if (cancelToken.canceled()) {
312-
this.codeServer.logger.debug("menu navigation canceled")
403+
try {
404+
logger.debug(`navigation step: ${step.name}`)
405+
await step.fn()
406+
if (ctx.canceled()) {
407+
logger.debug("navigation canceled")
408+
return false
409+
}
410+
} catch (error) {
411+
logger.debug(`navigation: ${error.message || error}`)
313412
return false
314413
}
315414
}
316415
return true
317416
}
318417

319-
const menuSelector = '[aria-label="Application Menu"]'
320-
const open = async () => {
321-
await this.page.click(menuSelector)
322-
await this.page.waitForSelector(`${menuSelector}:not(:focus-within)`)
323-
return false
418+
// We are seeing the menu closing after opening if we open it too soon and
419+
// the picker getting recreated in the middle of trying to select an item.
420+
// To counter this we will keep trying to navigate through the items every
421+
// time we lose focus or there is an error.
422+
let attempts = 1
423+
let context = new Context()
424+
while (!(await Promise.race([openThenWaitClose(), navigate(context)]))) {
425+
++attempts
426+
logger.debug("closed, retrying (${attempt}/∞)")
427+
context.cancel()
428+
context = new Context()
324429
}
325430

326-
// TODO: Starting in 1.57 something closes the menu after opening it if we
327-
// open it too soon. To counter that we'll watch for when the menu loses
328-
// focus and when/if it does we'll try again.
329-
// I tried using the classic menu but it doesn't show up at all for some
330-
// reason. I also tried toggle but the menu disappears after toggling.
331-
let retryCount = 0
332-
let cancelToken = new CancelToken()
333-
while (!(await Promise.race([open(), navigate(cancelToken)]))) {
334-
this.codeServer.logger.debug("menu was closed, retrying")
335-
++retryCount
336-
cancelToken.cancel()
337-
cancelToken = new CancelToken()
338-
}
431+
context.finish()
432+
logger.debug(`navigation took ${attempts} ${plural(attempts, "attempt")}`)
433+
}
434+
435+
/**
436+
* Navigate through a currently opened "quick input" widget, retrying on
437+
* failure.
438+
*/
439+
async navigateQuickInput(items: string[]): Promise<void> {
440+
await this.navigateItems(items, ".quick-input-widget")
441+
}
339442

340-
this.codeServer.logger.debug(`menu navigation retries: ${retryCount}`)
443+
/**
444+
* Navigate through the menu, retrying on failure.
445+
*/
446+
async navigateMenus(menus: string[]): Promise<void> {
447+
await this.navigateItems(menus, '[aria-label="Application Menu"]', async (selector) => {
448+
await this.page.click(selector)
449+
})
341450
}
342451

343452
/**
344453
* Navigates to code-server then reloads until the editor is ready.
345454
*
346455
* It is recommended to run setup before using this model in any tests.
347456
*/
348-
async setup(authenticated: boolean) {
349-
await this.navigate()
457+
async setup(authenticated: boolean, endpoint = "/") {
458+
await this.navigate(endpoint)
350459
// If we aren't authenticated we'll see a login page so we can't wait until
351460
// the editor is ready.
352461
if (authenticated) {

vendor/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
"postinstall": "./postinstall.sh"
88
},
99
"devDependencies": {
10-
"code-oss-dev": "coder/vscode#96e241330d9c44b64897c1e5031e00aa894103db"
10+
"code-oss-dev": "coder/vscode#6337ee490d16b7dfd8854d22c998f58d6cd21ef5"
1111
}
1212
}

vendor/yarn.lock

+2-2
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,9 @@ clone-response@^1.0.2:
274274
dependencies:
275275
mimic-response "^1.0.0"
276276

277-
code-oss-dev@coder/vscode#96e241330d9c44b64897c1e5031e00aa894103db:
277+
code-oss-dev@coder/vscode#6337ee490d16b7dfd8854d22c998f58d6cd21ef5:
278278
version "1.63.0"
279-
resolved "https://codeload.github.com/coder/vscode/tar.gz/%3Cspan%20class="x x-first x-last">96e241330d9c44b64897c1e5031e00aa894103db"
279+
resolved "https://codeload.github.com/coder/vscode/tar.gz/%3Cspan%20class="x x-first x-last">6337ee490d16b7dfd8854d22c998f58d6cd21ef5"
280280
dependencies:
281281
"@microsoft/applicationinsights-web" "^2.6.4"
282282
"@parcel/watcher" "2.0.3"

0 commit comments

Comments
 (0)