Skip to content

Commit bb15e38

Browse files
author
Forest Hoffman
committed
Screenshot puppeteer actions for logging purposes
1 parent e19ce0f commit bb15e38

File tree

3 files changed

+142
-47
lines changed

3 files changed

+142
-47
lines changed

test/src/chrome.test.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import * as fs from "fs";
22
import * as os from "os";
33
import * as path from "path";
4-
import * as puppeteer from "puppeteer";
5-
import { TestServer } from "./index";
4+
import { TestServer, TestPage } from "./index";
65

76
describe("chrome e2e", () => {
87
jest.setTimeout(60000);
@@ -43,43 +42,43 @@ describe("chrome e2e", () => {
4342

4443
afterEach(() => deleteTestFile());
4544

46-
const waitForSidebar = (page: puppeteer.Page): Promise<void> => {
47-
return page.waitFor(sidebarSelector, { visible: true }).then(() => page.click(sidebarSelector));
45+
const waitForSidebar = (page: TestPage): Promise<void> => {
46+
return page.rootPage.waitFor(sidebarSelector, { visible: true }).then(() => page.click(sidebarSelector));
4847
};
4948

50-
const waitForCommandInput = (page: puppeteer.Page): Promise<void> => {
51-
return page.waitFor(commandInputSelector, { visible: true }).then(() => page.click(commandInputSelector));
49+
const waitForCommandInput = (page: TestPage): Promise<void> => {
50+
return page.rootPage.waitFor(commandInputSelector, { visible: true }).then(() => page.click(commandInputSelector));
5251
};
5352

54-
const workbenchQuickOpen = (page: puppeteer.Page): Promise<void> => {
53+
const workbenchQuickOpen = (page: TestPage): Promise<void> => {
5554
return waitForSidebar(page)
5655
.then(() => server.pressKeyboardCombo(page, superKey, "P"))
5756
.then(() => waitForCommandInput(page));
5857
};
5958

60-
const workbenchShowCommands = (page: puppeteer.Page): Promise<void> => {
59+
const workbenchShowCommands = (page: TestPage): Promise<void> => {
6160
return waitForSidebar(page)
6261
.then(() => server.pressKeyboardCombo(page, superKey, "Shift", "P"))
6362
.then(() => waitForCommandInput(page));
6463
};
6564

6665
// Select all text in the search field, to avoid
6766
// invalid queries.
68-
const selectAll = (page: puppeteer.Page): Promise<void> => {
67+
const selectAll = (page: TestPage): Promise<void> => {
6968
return server.pressKeyboardCombo(page, superKey, "A");
7069
};
7170

7271
it("should open IDE", async () => {
7372
const page = await server.newPage()
74-
.then(server.loadPage.bind(server));
73+
.then((p) => server.loadPage(p, "openIDE"));
7574

7675
// Editor should be visible.
7776
await page.waitFor("div.part.editor", { visible: true });
7877
});
7978

8079
it("should create file via command palette", async () => {
8180
const page = await server.newPage()
82-
.then(server.loadPage.bind(server));
81+
.then((p) => server.loadPage(p, "createFileWithPalette"));
8382
await workbenchShowCommands(page);
8483
await page.keyboard.type("New File", { delay: 100 });
8584
await page.keyboard.press("Enter");
@@ -98,7 +97,7 @@ describe("chrome e2e", () => {
9897

9998
it("should create file via file tree", async () => {
10099
const page = await server.newPage()
101-
.then(server.loadPage.bind(server));
100+
.then((p) => server.loadPage(p, "createFileWithFileTree"));
102101
await waitForSidebar(page);
103102
const newFileBntSelector = "a.action-label.explorer-action.new-file";
104103
await page.waitFor(newFileBntSelector, { visible: true });
@@ -121,7 +120,7 @@ describe("chrome e2e", () => {
121120

122121
it("should open file", async () => {
123122
const page = await server.newPage()
124-
.then(server.loadPage.bind(server));
123+
.then((p) => server.loadPage(p, "openFile"));
125124

126125
// Setup.
127126
createTestFile();
@@ -136,7 +135,7 @@ describe("chrome e2e", () => {
136135

137136
it("should install extension", async () => {
138137
const page = await server.newPage()
139-
.then(server.loadPage.bind(server));
138+
.then((p) => server.loadPage(p, "installExtension"));
140139
await workbenchShowCommands(page);
141140
await page.keyboard.type("install extensions", { delay: 100 });
142141
const commandSelector = "div.quick-open-tree div.monaco-tree-row[aria-label*='Install Extensions, commands, picker']";
@@ -161,7 +160,7 @@ describe("chrome e2e", () => {
161160

162161
it("should debug file", async () => {
163162
const page = await server.newPage()
164-
.then(server.loadPage.bind(server));
163+
.then((p) => server.loadPage(p, "debugFile"));
165164

166165
// Setup.
167166
createTestFile();
@@ -220,7 +219,7 @@ describe("chrome e2e", () => {
220219

221220
it("should delete file", async () => {
222221
const page = await server.newPage()
223-
.then(server.loadPage.bind(server));
222+
.then((p) => server.loadPage(p, "deleteFile"));
224223

225224
// Setup.
226225
createTestFile();
@@ -256,7 +255,7 @@ describe("chrome e2e", () => {
256255

257256
it("should uninstall extension", async () => {
258257
const page = await server.newPage()
259-
.then(server.loadPage.bind(server));
258+
.then((p) => server.loadPage(p, "uninstallExtension"));
260259
await workbenchShowCommands(page);
261260
await page.keyboard.type("show installed extensions", { delay: 100 });
262261
const commandSelector = "div.quick-open-tree div.monaco-tree-row[aria-label*='Show Installed Extensions, commands, picker']";

test/src/index.ts

Lines changed: 118 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as path from "path";
55
import * as puppeteer from "puppeteer";
66
import { ChildProcess, spawn } from "child_process";
77
import { logger, field } from "@coder/logger";
8+
import { EventEmitter } from 'events';
89

910
interface IServerOptions {
1011
host: string;
@@ -88,6 +89,111 @@ class DeserializedNode {
8889
}
8990
}
9091

92+
/**
93+
* Wrapper for puppeteer.Keyboard.
94+
*/
95+
class TestKeyboard {
96+
public constructor(
97+
private readonly page: puppeteer.Page,
98+
public readonly onKeyboardEvent: (fnName: string, state: "before" | "after") => Promise<void>,
99+
) { }
100+
101+
/**
102+
* Wrapper for sending individual keyboard up/down events
103+
* to the page.
104+
*/
105+
public async press(key: string, options?: { text?: string, delay?: number }): Promise<void> {
106+
await this.onKeyboardEvent("press", "before");
107+
const prom = await this.page.keyboard.press(key, options);
108+
await this.onKeyboardEvent("press", "after");
109+
110+
return prom;
111+
}
112+
113+
/**
114+
* Wrapper for sending keyboard events to the page.
115+
*/
116+
public async type(text: string, options?: { delay?: number }): Promise<void> {
117+
await this.onKeyboardEvent("type", "before");
118+
const prom = await this.page.keyboard.type(text, options);
119+
await this.onKeyboardEvent("type", "after");
120+
121+
return prom;
122+
}
123+
}
124+
125+
/**
126+
* Wrapper for puppeteer.Page.
127+
*/
128+
export class TestPage {
129+
public readonly keyboard: TestKeyboard;
130+
private screenshotCount = 0;
131+
132+
public constructor(public readonly rootPage: puppeteer.Page, private readonly tag?: string) {
133+
this.keyboard = new TestKeyboard(rootPage, async (fnName, state): Promise<void> => {
134+
await this.screenshot(`keyboard-${fnName}_${state}`);
135+
});
136+
}
137+
138+
/**
139+
* Saves a screenshot of the headless page in the server's
140+
* working directory. Useful for debugging.
141+
*
142+
* Screenshot path will be in the following format:
143+
* `<screenshotDir>/[<page-tag>_]<screenshot-number>_<screenshot-name>.jpg`.
144+
*/
145+
public screenshot(name: string, options?: puppeteer.ScreenshotOptions): Promise<string | Buffer> {
146+
options = Object.assign({ path: path.resolve(TestServer.puppeteerDir, `./${this.tag ? `${this.tag}_` : ""}${this.screenshotCount}_${name}.jpg`), fullPage: true }, options);
147+
const img = this.rootPage.screenshot(options);
148+
this.screenshotCount++;
149+
150+
return img;
151+
}
152+
153+
/**
154+
* 1. Take a screenshot.
155+
* 2. Run the function.
156+
* 3. Take a screenshot.
157+
*/
158+
// tslint:disable-next-line:no-any
159+
public async recordFunc(fn: Function, ...args: any[]): Promise<any> {
160+
await this.screenshot(`${fn.name}_before`);
161+
const result = await fn.apply(this.rootPage, args);
162+
await this.screenshot(`${fn.name}_after`);
163+
164+
return result;
165+
}
166+
167+
/**
168+
* Wrapper for puppeteer.Page.click().
169+
*/
170+
public click(selector: string, options?: puppeteer.ClickOptions): Promise<void> {
171+
return this.recordFunc(this.rootPage.click, selector, options);
172+
}
173+
174+
public waitFor(duration: number): Promise<void>;
175+
public waitFor(selector: string, options?: puppeteer.WaitForSelectorOptions): Promise<puppeteer.ElementHandle>;
176+
public waitFor(selector: string, options: puppeteer.WaitForSelectorOptionsHidden): Promise<puppeteer.ElementHandle | null>;
177+
/**
178+
* Wrapper for puppeteer.Page.waitFor()/puppeteer.Page.waitForSelector().
179+
*/
180+
public waitFor(durationOrSelector: number | string, options?: puppeteer.WaitForSelectorOptionsHidden | puppeteer.WaitForSelectorOptions): Promise<puppeteer.ElementHandle | null | void> {
181+
if (typeof durationOrSelector === "number") {
182+
return this.recordFunc(this.rootPage.waitFor, durationOrSelector);
183+
}
184+
185+
return this.recordFunc(this.rootPage.waitFor, durationOrSelector, options);
186+
}
187+
188+
/**
189+
* Wrapper for puppeteer.Page.$eval().
190+
*/
191+
// tslint:disable-next-line:no-any
192+
public $eval<R>(selector: string, pageFunction: (element: Element, ...args: any[]) => R | Promise<R>, ...args: puppeteer.SerializableOrJSHandle[]): Promise<puppeteer.WrapElementHandle<R>> {
193+
return this.recordFunc(this.rootPage.$eval, selector, pageFunction, args);
194+
}
195+
}
196+
91197
/**
92198
* Wraps common code for end-to-end testing, like starting up
93199
* the code-server binary.
@@ -101,6 +207,7 @@ export class TestServer {
101207
private child: ChildProcess;
102208
// The directory to load the IDE with.
103209
public static readonly workingDir = path.resolve(__dirname, "../tmp/workspace/");
210+
public static readonly puppeteerDir = path.resolve(TestServer.workingDir, "../puppeteer/", Date.now().toString());
104211

105212
public constructor(opts?: {
106213
host?: string,
@@ -156,6 +263,9 @@ export class TestServer {
156263
if (!fs.existsSync(TestServer.workingDir)) {
157264
fs.mkdirSync(TestServer.workingDir, { recursive: true });
158265
}
266+
if (!fs.existsSync(TestServer.puppeteerDir)) {
267+
fs.mkdirSync(TestServer.puppeteerDir, { recursive: true });
268+
}
159269
this.child = spawn(this.options.binaryPath, args, { cwd: TestServer.workingDir });
160270
if (!this.child.stdout) {
161271
await this.dispose();
@@ -198,7 +308,7 @@ export class TestServer {
198308
* to emit the "ide-ready" event. After which, the page
199309
* should be interactive.
200310
*/
201-
public async loadPage(page: puppeteer.Page): Promise<puppeteer.Page> {
311+
public async loadPage(page: puppeteer.Page, tag?: string): Promise<TestPage> {
202312
if (!page) {
203313
throw new Error(`cannot load page, ${JSON.stringify(page)}`);
204314
}
@@ -209,7 +319,7 @@ export class TestServer {
209319
});
210320
});
211321

212-
return page;
322+
return new TestPage(page, tag);
213323
}
214324

215325
/**
@@ -249,14 +359,14 @@ export class TestServer {
249359
* runner context.
250360
*/
251361
// tslint:disable-next-line:no-any
252-
public async querySelectorAll(page: puppeteer.Page, selector: string): Promise<Array<DeserializedNode>> {
362+
public async querySelectorAll(page: TestPage, selector: string): Promise<Array<DeserializedNode>> {
253363
if (!selector) {
254364
throw new Error("selector undefined");
255365
}
256366

257367
// tslint:disable-next-line:no-any
258368
const elements: Array<DeserializedNode> = [];
259-
const serializedElements = await page.evaluate((selector) => {
369+
const serializedElements = await page.rootPage.evaluate((selector) => {
260370
// tslint:disable-next-line:no-any
261371
return new Promise<Array<string>>((res, rej): void => {
262372
const elements = Array.from(document.querySelectorAll(selector));
@@ -286,7 +396,7 @@ export class TestServer {
286396
* Get an element on the page. See `TestServer.querySelectorAll`.
287397
*/
288398
// tslint:disable-next-line:no-any
289-
public async querySelector(page: puppeteer.Page, selector: string): Promise<DeserializedNode> {
399+
public async querySelector(page: TestPage, selector: string): Promise<DeserializedNode> {
290400
if (!selector) {
291401
throw new Error("selector undefined");
292402
}
@@ -302,30 +412,17 @@ export class TestServer {
302412
* See puppeteer docs for key-code definitions:
303413
* https://github.com/GoogleChrome/puppeteer/blob/master/lib/USKeyboardLayout.js
304414
*/
305-
public async pressKeyboardCombo(page: puppeteer.Page, ...keys: string[]): Promise<void> {
415+
public async pressKeyboardCombo(page: TestPage, ...keys: string[]): Promise<void> {
306416
if (!keys || keys.length === 0) {
307417
throw new Error("no keys provided");
308418
}
309419
// Press the keys.
310420
for (let i = 0; i < keys.length; i++) {
311-
await page.keyboard.down(keys[i]);
421+
await page.rootPage.keyboard.down(keys[i]);
312422
}
313423
// Release the keys.
314424
for (let x = 0; x < keys.length; x++) {
315-
await page.keyboard.up(keys[x]);
316-
}
317-
}
318-
319-
/**
320-
* Saves a screenshot of the headless page in the server's
321-
* working directory. Useful for debugging.
322-
*/
323-
public async screenshot(page: puppeteer.Page, id: string): Promise<void> {
324-
const puppeteerDir = path.resolve(TestServer.workingDir, "../puppeteer/");
325-
if (!fs.existsSync(puppeteerDir)) {
326-
fs.mkdirSync(puppeteerDir, { recursive: true });
425+
await page.rootPage.keyboard.up(keys[x]);
327426
}
328-
const screenshotPath = path.resolve(puppeteerDir, `./screenshot-${id}.jpg`);
329-
await page.screenshot({ path: screenshotPath, fullPage: true });
330427
}
331428
}

test/src/startup.test.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import * as puppeteer from "puppeteer";
2-
import { TestServer } from "./index";
1+
import { TestServer, TestPage } from "./index";
32

43
describe("startup e2e", () => {
54
jest.setTimeout(20000);
@@ -25,7 +24,7 @@ describe("startup e2e", () => {
2524
}
2625
});
2726

28-
const expectEditor = async (server: TestServer, page: puppeteer.Page): Promise<void> => {
27+
const expectEditor = async (server: TestServer, page: TestPage): Promise<void> => {
2928
// Editor should be visible.
3029
await page.waitFor("div.part.editor");
3130
const editor = await server.querySelector(page, "div.part.editor");
@@ -51,15 +50,15 @@ describe("startup e2e", () => {
5150
const page = await server.newPage();
5251
await page.goto(server.url);
5352
await page.waitFor(1000);
53+
const testPage = new TestPage(page, "requirePassword");
5454

5555
// Enter password.
5656
expect(server.options.password).toBeTruthy();
57-
await page.click("input#password");
58-
await page.waitFor(1000);
59-
await page.keyboard.type(server.options.password, { delay: 100 });
60-
await page.waitFor(1000);
61-
await page.click("button#submit");
57+
await testPage.waitFor("input#password");
58+
await testPage.click("input#password");
59+
await testPage.keyboard.type(server.options.password, { delay: 100 });
60+
await testPage.click("button#submit");
6261

63-
await expectEditor(server, page);
62+
await expectEditor(server, testPage);
6463
});
6564
});

0 commit comments

Comments
 (0)