Skip to content

Commit

Permalink
Merge branch 'puppeteer-electron'
Browse files Browse the repository at this point in the history
* Branch commit log:
  x11test/replay.sh: use epuppeteer.mjs
  x11test/epuppeteer.mjs: use node:net to find an available port
  x11test/epuppeteer.mjs: rename
  x11test/ereplay.mjs: extend log messages
  x11test/ereplay.mjs: use constants instead of hardcoded window size numbers
  x11test/ereplay.mjs: detect reloads on window.webContents
  x11test/ereplay.mjs: remove anklang workaround
  x11test/ereplay.mjs: add replay handling for *.json files
	* Support replay of multiple *.json files in succession.
	* Add extra delay in case of page reload.
	* Adjust delays around clicks.
	* Raise maximum timeout, only triggered if something went wrong.
  x11test/ereplay.mjs: slight fixups
  x11test/ereplay.mjs: initialize "puppeteer-core" and "electron" from scratch
  x11test/ereplay.cjs: remove
  ui/Makefile.mk: add x11test/*.*js to eslint rule
  package.json: upgrade electron, puppeteer-core, remove puppeteer-in-electron
  x11test/x11rec.sh: reduce Xephyr sleep
  x11test/replay.sh: fix wrong exit code from trap+pkill
  misc/config-checks.mk: require pcre-10.34, downgrade for ubuntu 20.04 builds

Signed-off-by: Tim Janik <[email protected]>
  • Loading branch information
tim-janik committed Jun 19, 2024
2 parents b80ea85 + 4e5fad6 commit 2987fb9
Show file tree
Hide file tree
Showing 7 changed files with 287 additions and 154 deletions.
2 changes: 1 addition & 1 deletion misc/config-checks.mk
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ config-checks.require.pkgconfig ::= $(strip \
gthread-2.0 >= 2.32.3 \
gobject-2.0 >= 2.32.3 \
dbus-1 >= 1.12.16 \
libpcre2-8 >= 10.39 \
libpcre2-8 >= 10.34 \
)
# boost libraries have no .pc files
# Unused: fluidsynth >= 2.0.5
Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"colorjs.io": "^0.5.0",
"css-color-converter": "^2.0.0",
"csstree-validator": "^3.0.0",
"electron": "^30.0.2",
"electron": "^31.0.1",
"eslint": "^9.2.0",
"eslint-formatter-unix": "^8.40.0",
"eslint-plugin-html": "^8.1.1",
Expand Down Expand Up @@ -63,8 +63,7 @@
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.14",
"printf-ts": "^2.0.1",
"puppeteer-core": "^22.8.0",
"puppeteer-in-electron": "^3.0.5",
"puppeteer-core": "^22.11.0",
"rollup": "^4.17.2",
"sass": "^1.77.0",
"signal-polyfill": "^0.1.0",
Expand Down
3 changes: 2 additions & 1 deletion ui/Makefile.mk
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,12 @@ $>/ui/anklang.png: $>/ui/favicon.ico
$>/.ui-build-stamp: $>/ui/favicon.ico $>/ui/anklang.png

# == eslint ==
x11test.js ::= $(wildcard x11test/*.*js)
ui/eslint.files ::= $(wildcard ui/*.html ui/*.js ui/b/*.js)
$>/.eslint.done: ui/eslintrc.js $(ui/eslint.files) ui/Makefile.mk node_modules/.npm.done | $>/ui/
$(QECHO) RUN eslint
$Q node_modules/.bin/eslint -c ui/eslintrc.js -f unix --cache --cache-location $>/.eslintcache \
$(abspath $(ui/eslint.files) jsonipc/jsonipc.js) \
$(abspath $(ui/eslint.files) jsonipc/jsonipc.js $(x11test.js)) \
|& ./misc/colorize.sh
$Q touch $@
$>/.ui-reload-stamp: $>/.eslint.done
Expand Down
277 changes: 277 additions & 0 deletions x11test/epuppeteer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
// This Source Code Form is licensed MPL-2.0: http://mozilla.org/MPL/2.0
// @ts-check

// constants
const verbose = process.argv.indexOf ('--verbose') >= 0;
loginf ("startup", "verbose=" + verbose);
const quiet = process.argv.indexOf ('--quiet') >= 0;
const devtools = process.argv.indexOf ('--devtools') >= 0;
const localhost = "127.0.0.1";
const WIDTH = 1920, HEIGHT = 1080;

// == imports ==
loginf ("import puppeteer-core");
import puppeteer from "puppeteer-core";
loginf ("import @puppeteer/replay");
import { PuppeteerRunnerExtension, createRunner, parse } from "@puppeteer/replay";
loginf ("import electron");
import { app, BrowserWindow } from "electron";
loginf ("import fs");
import fs from "fs";
loginf ("import node:net");
import net from "node:net";

// == loginf with line numbers ==
const stack_line_rex = /(\d+):(\d+)\)?$/;
function loginf (...args)
{
if (!verbose)
return;
let err;
try { throw new Error(); }
catch (error) { err = error; }
const lines = err.stack.split ("\n");
const file_line = /([^/]+:\d+):\d+/.exec (lines[2])[1];
return console.log (`${file_line}:`, ...args);
}

// == get_port ==
async function get_port (listenhost)
{
const promise = new Promise ((resolve, reject) => {
const server = net.createServer();
server.on ("error", reject);
server.listen ({ host: listenhost, port: 0 }, () => {
const { port } = server.address();
server.close (() => resolve (port));
});
server.unref();
});
return promise;
}

// == configure electron ==
/** Setup electron for remote debugging from puppeteer
* @returns {number} Remote debugging port
*/
async function electron_configure (listenhost)
{
loginf ("electron_configure");
process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = true; // otherwise warns "Insecure Content-Security-Policy"
loginf ("Electronjs app checks");
if (!app || app.commandLine.getSwitchValue ("remote-debugging-address") ||
app.commandLine.getSwitchValue ("remote-debugging-port") || app.isReady())
throw new Error ("invalid Electronjs App handle");
loginf ("Electronjs version:", app.getVersion());
const port = await get_port (listenhost);
loginf ("getPort", listenhost, port);
// options for remote debugging
app.commandLine.appendSwitch ("remote-debugging-address", listenhost);
app.commandLine.appendSwitch ("remote-debugging-port", `${port}`);
app.commandLine.appendSwitch ("disable-dev-shm-usage"); // otherwise will not work inside docker
app.commandLine.appendSwitch ("disable-software-rasterizer"); // otherwise hangs for 9 seconds after new BrowserWindow
app.disableHardwareAcceleration(); // same as --disable-gpu; otherwise hangs for 9 seconds after new BrowserWindow
return port;
}

// == puppeteer connect ==
/** Connect puppeteer with control over the electron app
* @returns puppeteer broser
*/
async function puppeteer_connect (listenhost, port)
{
loginf ("await app.whenReady");
await app.whenReady();
console.assert (BrowserWindow.getAllWindows().length === 0);

// get webSocketDebuggerUrl from /json/version; https://pptr.dev/api/puppeteer.browser
loginf ("fetch /json/version");
const json_response = await fetch (`http://${listenhost}:${port}/json/version`);
//loginf ("json response:", json_response);
const response_object = await json_response.json();
//loginf ("response object:", response_object);
loginf ("response webSocketDebuggerUrl:", response_object.webSocketDebuggerUrl);
loginf ("await puppeteer.connect");
const puppeteer_browser = await puppeteer.connect ({ browserWSEndpoint: response_object.webSocketDebuggerUrl,
defaultViewport: { width: WIDTH, height: HEIGHT } });
loginf ("browser:", puppeteer_browser);
// monkey patch puppeteer_browser.newPage which otherwise cannot work
puppeteer_browser.newPage = puppeteer_newpage.bind (puppeteer_browser);
return puppeteer_browser;
}

// == puppeteer getpage ==
/** Find the puppeteer page for an electron browser window.
* @returns puppeteer page
*/
async function puppeteer_getpage (puppeteer_browser, window)
{
loginf ("await puppeteer_browser.pages");
const pages = await puppeteer_browser.pages();
loginf ("pages:", pages);
const cookie = "PUPPETEER:" + String (new Date()) + ":" + Math.random(); // must be unique
loginf ("await executeJavaScript");
const js1 = await window.webContents.executeJavaScript (`window[':puppeteer_getpage.cookie'] = "${cookie}";`);
loginf ("executeJavaScript:", js1);
loginf ("await test_page.evaluate");
const cookies = await Promise.all (pages.map (test_page => test_page.evaluate (`window[':puppeteer_getpage.cookie']`)));
loginf ("await executeJavaScript");
const js2 = await window.webContents.executeJavaScript (`delete window[':puppeteer_getpage.cookie'];`);
loginf ("executeJavaScript:", js2);
const index = cookies.findIndex (test_cookie => cookie === test_cookie);
if (index < 0)
throw new Error (`failed to find electron window in puppeteer_browser.pages()`);
const page = pages[index];
loginf ("page:", page);
return page;
}

// == puppeteer newPage ==
/** Create a new electron window and return the puppeteer Page for it.
* @returns puppeteer page
*/
async function puppeteer_newpage (settings = {})
{
const webPreferences = Object.assign ({
nodeIntegration: false,
sandbox: true,
contextIsolation: true,
defaultEncoding: "UTF-8",
}, settings.webPreferences);
settings = Object.assign ({ show: true }, settings, { webPreferences });

loginf ("new BrowserWindow");
const window = new BrowserWindow (settings);
const url = "about:blank";
loginf ("loadURL:", url);
await window.loadURL (url);
if (devtools)
window.toggleDevTools(); // start with DevTools enabled
const handle_navigate = () => did_navigate += 1;
for (let k of [ 'did-start-navigation', 'did-navigate', 'did-frame-navigate', 'did-navigate-in-page' ])
window.webContents.on (k, handle_navigate);

return puppeteer_getpage (this, window);
}
let did_navigate = 0;

// == abort on any errors ==
function test_error (arg) {
console.error (arg, "\nAborting...");
app.exit (255);
process.abort();
}
process.on ('uncaughtException', test_error);
process.on ('unhandledRejection', test_error);

// == delay ==
const delay = ms => new Promise (resolve => setTimeout (resolve, ms));

// == TestRunnerExtension ==
const MAX_TIMEOUT = 10 * 1000;
const BEFORE_CLICK = 50; // give time to let clickable elements emerge
const CLICK_DURATION = 99; // allow clicks to have a UI effect
const AFTER_CLICK = 50; // give time to let clicks take effect
const RELOAD_DELAY = 1500;
const DELAY_EXIT = 1000;
class UnhurriedTestRunnerExtension extends PuppeteerRunnerExtension {
constructor (browser, page, opts, scriptname)
{
super (browser, page, opts);
this.page = page;
this.scriptname = scriptname;
this.log = (...args) => quiet || console.log (this.scriptname + ":", ...args);
}
async beforeAllSteps (flow)
{
await super.beforeAllSteps (flow);
if (!quiet) console.log ("\nSTART REPLAY:", this.scriptname);
}
async beforeEachStep (step, flow)
{
if (click_types.indexOf (step.type) >= 0 && !step.duration)
step.duration = CLICK_DURATION;
const s = step.selectors ? step.selectors.flat() : [];
let msg = step.type;
if (step.url)
msg += ': ' + step.url;
else if (s.length)
msg += ': ' + s[0];
if (step.key)
msg += ` <${step.key}>`;
if ("string" === typeof step.value && step.value.length <= 80)
msg += ': ' + JSON.stringify (step.value);
this.log (msg);
if (click_types.indexOf (step.type) >= 0)
await delay (BEFORE_CLICK);
await super.beforeEachStep (step, flow);
}
async afterEachStep (step, flow)
{
await super.afterEachStep (step, flow);
if (click_types.indexOf (step.type) >= 0)
await delay (AFTER_CLICK);
const s = step.selectors ? step.selectors.flat() : [];
// const will_navigate = "navigate" === step.type;
if (did_navigate) { // page reloads often take a while
did_navigate = 0;
this.log (" reload delay...");
await delay (RELOAD_DELAY);
}
}
async afterAllSteps (flow)
{
await super.afterAllSteps(flow);
if (!quiet) console.log ("SUCCESS!");
await delay (DELAY_EXIT); // time for observer
}
}
const click_types = [ 'click', 'doubleClick' ];

// == main ==
async function main (argv)
{
loginf ("main:", argv.join (" "));

// setup
loginf ("await electron_configure");
const port = await electron_configure (localhost);
const window_all_closed = new Promise (r => app.on ("window-all-closed", r));
loginf ("await puppeteer_connect");
const puppeteer_browser = await puppeteer_connect (localhost, port);

// run *.json scripts
const show = true;
const viewport = { deviceScaleFactor: 1, isMobile: false, width: WIDTH, height: HEIGHT };
for (const arg of process.argv) {
if (!arg.endsWith ('.json')) continue;
const json_events = JSON.parse (fs.readFileSync (arg, "utf8"));
const page = await puppeteer_browser.newPage ({
show,
x: 0,
y: 0,
width: WIDTH, // calling win.maximize() causes flicker
height: HEIGHT, // using a big initial size avoids flickering
backgroundColor: '#000',
autoHideMenuBar: true,
});
await page.setViewport (viewport);

page.setDefaultTimeout (MAX_TIMEOUT);
const runner_extension = new UnhurriedTestRunnerExtension (puppeteer_browser, page, { timeout: MAX_TIMEOUT }, arg);
const runner = await createRunner (parse (json_events), runner_extension);
await runner.run();

if (!devtools)
await page.close();
}

// shutdown
loginf ("await window closed");
await window_all_closed;
loginf ("app.quit");
app.quit();
}

// Start main()
main (process.argv);
Loading

0 comments on commit 2987fb9

Please sign in to comment.