You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
667 lines
20 KiB
667 lines
20 KiB
/**
|
|
* Copyright 2018 Google Inc. All rights reserved.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the 'License');
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an 'AS IS' BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const path = require('path');
|
|
const puppeteer = require('puppeteer-core');
|
|
const findChrome = require('./find_chrome');
|
|
const {rpc} = require('../rpc');
|
|
const debugApp = require('debug')('carlo:app');
|
|
const debugServer = require('debug')('carlo:server');
|
|
const {Color} = require('./color');
|
|
const {HttpRequest} = require('./http_request');
|
|
|
|
const fs = require('fs');
|
|
const util = require('util');
|
|
const {URL} = require('url');
|
|
const EventEmitter = require('events');
|
|
const fsReadFile = util.promisify(fs.readFile);
|
|
|
|
let testMode = false;
|
|
|
|
class App extends EventEmitter {
|
|
/**
|
|
* @param {!Puppeteer.Browser} browser Puppeteer browser
|
|
* @param {!Object} options
|
|
*/
|
|
constructor(browser, options) {
|
|
super();
|
|
this.browser_ = browser;
|
|
this.options_ = options;
|
|
this.windows_ = new Map();
|
|
this.exposedFunctions_ = [];
|
|
this.pendingWindows_ = new Map();
|
|
this.windowSeq_ = 0;
|
|
this.www_ = [];
|
|
}
|
|
|
|
async init_() {
|
|
debugApp('Configuring browser');
|
|
let page;
|
|
await Promise.all([
|
|
this.browser_.target().createCDPSession().then(session => {
|
|
this.session_ = session;
|
|
if (this.options_.icon)
|
|
this.setIcon(this.options_.icon);
|
|
}),
|
|
this.browser_.defaultBrowserContext().
|
|
overridePermissions('https://domain', [
|
|
'geolocation',
|
|
'midi',
|
|
'notifications',
|
|
'camera',
|
|
'microphone',
|
|
'clipboard-read',
|
|
'clipboard-write']),
|
|
this.browser_.pages().then(pages => page = pages[0])
|
|
]);
|
|
|
|
this.browser_.on('targetcreated', this.targetCreated_.bind(this));
|
|
|
|
// Simulate the pageCreated sequence.
|
|
let callback;
|
|
const result = new Promise(f => callback = f);
|
|
this.pendingWindows_.set('', { options: this.options_, callback });
|
|
this.pageCreated_(page);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Close the app windows.
|
|
*/
|
|
async exit() {
|
|
debugApp('app.exit...');
|
|
if (this.exited_)
|
|
return;
|
|
this.exited_ = true;
|
|
await this.browser_.close();
|
|
this.emit(App.Events.Exit);
|
|
}
|
|
|
|
/**
|
|
* @return {!<Window>} main window.
|
|
*/
|
|
mainWindow() {
|
|
for (const window of this.windows_.values())
|
|
return window;
|
|
}
|
|
|
|
/**
|
|
* @param {!Object=} options
|
|
* @return {!Promise<Window>}
|
|
*/
|
|
async createWindow(options = {}) {
|
|
options = Object.assign({}, this.options_, options);
|
|
const seq = String(++this.windowSeq_);
|
|
if (!this.windows_.size)
|
|
throw new Error('Needs at least one window to create more.');
|
|
|
|
const params = [];
|
|
for (const prop of ['top', 'left', 'width', 'height']) {
|
|
if (typeof options[prop] === 'number')
|
|
params.push(`${prop}=${options[prop]}`);
|
|
}
|
|
|
|
for (const page of this.windows_.keys()) {
|
|
page.evaluate(`window.open('about:blank?seq=${seq}', '', '${params.join(',')}')`);
|
|
break;
|
|
}
|
|
|
|
return new Promise(callback => {
|
|
this.pendingWindows_.set(seq, { options, callback });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return {!Array<!Window>}
|
|
*/
|
|
windows() {
|
|
return Array.from(this.windows_.values());
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @param {function} func
|
|
* @return {!Promise}
|
|
*/
|
|
exposeFunction(name, func) {
|
|
this.exposedFunctions_.push({name, func});
|
|
return Promise.all(this.windows().map(window => window.exposeFunction(name, func)));
|
|
}
|
|
|
|
/**
|
|
* @param {function()|string} pageFunction
|
|
* @param {!Array<*>} args
|
|
* @return {!Promise<*>}
|
|
*/
|
|
evaluate(pageFunction, ...args) {
|
|
return this.mainWindow().evaluate(pageFunction, ...args);
|
|
}
|
|
|
|
/**
|
|
* @param {string=} folder Folder with the web content.
|
|
* @param {string=} prefix Only serve folder for requests with given prefix.
|
|
*/
|
|
serveFolder(folder = '', prefix = '') {
|
|
this.www_.push({folder, prefix: wrapPrefix(prefix)});
|
|
}
|
|
|
|
/**
|
|
* Serves pages from given origin, eg `http://localhost:8080`.
|
|
* This can be used for the fast development mode available in web frameworks.
|
|
*
|
|
* @param {string} base
|
|
* @param {string=} prefix Only serve folder for requests with given prefix.
|
|
*/
|
|
serveOrigin(base, prefix = '') {
|
|
this.www_.push({baseURL: new URL(base + '/'), prefix: wrapPrefix(prefix)});
|
|
}
|
|
|
|
/**
|
|
* Calls given handler for each request and allows called to handle it.
|
|
*
|
|
* @param {function(!Request)} handler to be used for each request.
|
|
*/
|
|
serveHandler(handler) {
|
|
this.httpHandler_ = handler;
|
|
}
|
|
|
|
/**
|
|
* @param {string=} uri
|
|
* @param {...*} params
|
|
* @return {!Promise<*>}
|
|
*/
|
|
async load(uri = '', ...params) {
|
|
return this.mainWindow().load(uri, ...params);
|
|
}
|
|
|
|
/**
|
|
* Set the application icon shown in the OS dock / task swicher.
|
|
* @param {string|!Buffer} dockIcon
|
|
*/
|
|
async setIcon(icon) {
|
|
const buffer = typeof icon === 'string' ? await fsReadFile(icon) : icon;
|
|
this.session_.send('Browser.setDockTile',
|
|
{ image: buffer.toString('base64') }).catch(e => {});
|
|
}
|
|
|
|
/**
|
|
* Puppeteer browser object for test.
|
|
* @return {!Puppeteer.Browser}
|
|
*/
|
|
browserForTest() {
|
|
return this.browser_;
|
|
}
|
|
|
|
async targetCreated_(target) {
|
|
const page = await target.page();
|
|
if (!page)
|
|
return;
|
|
this.pageCreated_(page);
|
|
}
|
|
|
|
/**
|
|
* @param {!Puppeteer.Page} page
|
|
*/
|
|
async pageCreated_(page) {
|
|
const url = page.url();
|
|
debugApp('Page created at', url);
|
|
const seq = url.startsWith('about:blank?seq=') ? url.substr('about:blank?seq='.length) : '';
|
|
const params = this.pendingWindows_.get(seq);
|
|
const { callback, options } = params || { options: this.options_ };
|
|
this.pendingWindows_.delete(seq);
|
|
const window = new Window(this, page, options);
|
|
await window.init_();
|
|
this.windows_.set(page, window);
|
|
if (callback)
|
|
callback(window);
|
|
this.emit(App.Events.Window, window);
|
|
}
|
|
|
|
/**
|
|
* @param {!Window}
|
|
*/
|
|
windowClosed_(window) {
|
|
debugApp('window closed', window.loadURI_);
|
|
this.windows_.delete(window.page_);
|
|
if (!this.windows_.size)
|
|
this.exit();
|
|
}
|
|
}
|
|
|
|
App.Events = {
|
|
Exit: 'exit',
|
|
Window: 'window'
|
|
};
|
|
|
|
class Window extends EventEmitter {
|
|
/**
|
|
* @param {!App} app
|
|
* @param {!Puppeteer.Page} page Puppeteer page
|
|
* @param {!Object} options
|
|
*/
|
|
constructor(app, page, options) {
|
|
super();
|
|
this.app_ = app;
|
|
this.options_ = Object.assign({}, app.options_, options);
|
|
this.www_ = [];
|
|
this.page_ = page;
|
|
this.page_.on('close', this.closed_.bind(this));
|
|
this.page_.on('domcontentloaded', this.domContentLoaded_.bind(this));
|
|
this.hostHandle_ = rpc.handle(new HostWindow(this));
|
|
}
|
|
|
|
async init_() {
|
|
debugApp('Configuring window');
|
|
const targetId = this.page_.target()._targetInfo.targetId;
|
|
const bgcolor = Color.parse(this.options_.bgcolor);
|
|
const bgcolorRGBA = bgcolor.canonicalRGBA();
|
|
this.session_ = await this.page_.target().createCDPSession();
|
|
|
|
await Promise.all([
|
|
this.session_.send('Runtime.evaluate', { expression: 'self.paramsForReuse', returnByValue: true }).
|
|
then(response => { this.paramsForReuse_ = response.result.value; }),
|
|
this.session_.send('Emulation.setDefaultBackgroundColorOverride',
|
|
{color: {r: bgcolorRGBA[0], g: bgcolorRGBA[1],
|
|
b: bgcolorRGBA[2], a: bgcolorRGBA[3] * 255}}),
|
|
this.app_.session_.send('Browser.getWindowForTarget', { targetId })
|
|
.then(this.initBounds_.bind(this)),
|
|
this.configureRpcOnce_(),
|
|
...this.app_.exposedFunctions_.map(({name, func}) => this.exposeFunction(name, func))
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @param {function} func
|
|
* @return {!Promise}
|
|
*/
|
|
exposeFunction(name, func) {
|
|
debugApp('Exposing function', name);
|
|
return this.page_.exposeFunction(name, func);
|
|
}
|
|
|
|
/**
|
|
* @param {function()|string} pageFunction
|
|
* @param {!Array<*>} args
|
|
* @return {!Promise<*>}
|
|
*/
|
|
evaluate(pageFunction, ...args) {
|
|
return this.page_.evaluate(pageFunction, ...args);
|
|
}
|
|
|
|
/**
|
|
* @param {string=} www Folder with the web content.
|
|
* @param {string=} prefix Only serve folder for requests with given prefix.
|
|
*/
|
|
serveFolder(folder = '', prefix = '') {
|
|
this.www_.push({folder, prefix: wrapPrefix(prefix)});
|
|
}
|
|
|
|
/**
|
|
* Serves pages from given origin, eg `http://localhost:8080`.
|
|
* This can be used for the fast development mode available in web frameworks.
|
|
*
|
|
* @param {string} base
|
|
* @param {string=} prefix Only serve folder for requests with given prefix.
|
|
*/
|
|
serveOrigin(base, prefix = '') {
|
|
this.www_.push({baseURL: new URL(base + '/'), prefix: wrapPrefix(prefix)});
|
|
}
|
|
|
|
/**
|
|
* Calls given handler for each request and allows called to handle it.
|
|
*
|
|
* @param {function(!Request)} handler to be used for each request.
|
|
*/
|
|
serveHandler(handler) {
|
|
this.httpHandler_ = handler;
|
|
}
|
|
|
|
/**
|
|
* @param {string=} uri
|
|
* @param {...*} params
|
|
* @return {!Promise}
|
|
*/
|
|
async load(uri = '', ...params) {
|
|
debugApp('Load page', uri);
|
|
this.loadURI_ = uri;
|
|
this.loadParams_ = params;
|
|
await this.initializeInterception_();
|
|
debugApp('Navigating the page to', this.loadURI_);
|
|
|
|
const result = new Promise(f => this.domContentLoadedCallback_ = f);
|
|
// Await here to process exceptions.
|
|
await this.page_.goto(new URL(this.loadURI_, 'https://domain/').toString(), {timeout: 0, waitFor: 'domcontentloaded'});
|
|
// Available in Chrome M73+.
|
|
this.session_.send('Page.resetNavigationHistory').catch(e => {});
|
|
// Make sure domContentLoaded callback is processed before we return.
|
|
// That indirection is here to handle debug-related reloads we did not call for.
|
|
return result;
|
|
}
|
|
|
|
initBounds_(result) {
|
|
this.windowId_ = result.windowId;
|
|
return this.setBounds({ top: this.options_.top,
|
|
left: this.options_.left,
|
|
width: this.options_.width,
|
|
height: this.options_.height });
|
|
}
|
|
|
|
/**
|
|
* Puppeteer page object for test.
|
|
* @return {!Puppeteer.Page}
|
|
*/
|
|
pageForTest() {
|
|
return this.page_;
|
|
}
|
|
|
|
/**
|
|
* Returns value specified in the carlo.launch(options.paramsForReuse). This is handy
|
|
* when Carlo is reused across app runs. First Carlo app successfully starts the browser.
|
|
* Second carlo attempts to start the browser, but browser profile is already in use.
|
|
* Yet, new window is being opened in the first Carlo app. This new window returns
|
|
* options.paramsForReuse passed into the second Carlo. This was single app knows what to
|
|
* do with the additional windows.
|
|
*
|
|
* @return {*}
|
|
*/
|
|
paramsForReuse() {
|
|
return this.paramsForReuse_;
|
|
}
|
|
|
|
async configureRpcOnce_() {
|
|
await this.page_.exposeFunction('receivedFromChild', data => this.receivedFromChild_(data));
|
|
|
|
const rpcFile = (await fsReadFile(__dirname + '/../rpc/rpc.js')).toString();
|
|
const features = [ require('./features/shortcuts.js'),
|
|
require('./features/file_info.js') ];
|
|
|
|
await this.page_.evaluateOnNewDocument((rpcFile, features) => {
|
|
const module = { exports: {} };
|
|
eval(rpcFile);
|
|
self.rpc = module.exports;
|
|
self.carlo = {};
|
|
let argvCallback;
|
|
const argvPromise = new Promise(f => argvCallback = f);
|
|
self.carlo.loadParams = () => argvPromise;
|
|
|
|
function transport(receivedFromParent) {
|
|
self.receivedFromParent = receivedFromParent;
|
|
return receivedFromChild;
|
|
}
|
|
|
|
self.rpc.initWorld(transport, async(loadParams, win) => {
|
|
argvCallback(loadParams);
|
|
|
|
if (document.readyState === 'loading')
|
|
await new Promise(f => document.addEventListener('DOMContentLoaded', f));
|
|
|
|
for (const feature of features)
|
|
eval(`(${feature})`)(win);
|
|
});
|
|
}, rpcFile, features.map(f => f.toString()));
|
|
}
|
|
|
|
async domContentLoaded_() {
|
|
debugApp('Creating rpc world for page...');
|
|
const transport = receivedFromChild => {
|
|
this.receivedFromChild_ = receivedFromChild;
|
|
return data => {
|
|
const json = JSON.stringify(data);
|
|
if (this.session_._connection)
|
|
this.session_.send('Runtime.evaluate', {expression: `self.receivedFromParent(${json})`});
|
|
};
|
|
};
|
|
if (this._lastWebWorldId)
|
|
rpc.disposeWorld(this._lastWebWorldId);
|
|
const { worldId } = await rpc.createWorld(transport, this.loadParams_, this.hostHandle_);
|
|
debugApp('World created', worldId);
|
|
this._lastWebWorldId = worldId;
|
|
|
|
this.domContentLoadedCallback_();
|
|
}
|
|
|
|
async initializeInterception_() {
|
|
debugApp('Initializing network interception...');
|
|
if (this.interceptionInitialized_)
|
|
return;
|
|
if (this.www_.length + this.app_.www_.length === 0 && !this.httpHandler_ && !this.app_.httpHandler_)
|
|
return;
|
|
this.interceptionInitialized_ = true;
|
|
this.session_.on('Network.requestIntercepted', this.requestIntercepted_.bind(this));
|
|
return this.session_.send('Network.setRequestInterception', {patterns: [{urlPattern: '*'}]});
|
|
}
|
|
|
|
/**
|
|
* @param {!Object} request Intercepted request.
|
|
*/
|
|
async requestIntercepted_(payload) {
|
|
debugServer('intercepted:', payload.request.url);
|
|
const handlers = [];
|
|
if (this.httpHandler_)
|
|
handlers.push(this.httpHandler_);
|
|
if (this.app_.httpHandler_)
|
|
handlers.push(this.app_.httpHandler_);
|
|
handlers.push(this.handleRequest_.bind(this));
|
|
new HttpRequest(this.session_, payload, handlers);
|
|
}
|
|
|
|
/**
|
|
* @param {!HttpRequest} request Intercepted request.
|
|
*/
|
|
async handleRequest_(request) {
|
|
const url = new URL(request.url());
|
|
debugServer('request url:', url.toString());
|
|
|
|
if (url.hostname !== 'domain') {
|
|
request.deferToBrowser();
|
|
return;
|
|
}
|
|
|
|
const urlpathname = url.pathname;
|
|
for (const {prefix, folder, baseURL} of this.app_.www_.concat(this.www_)) {
|
|
debugServer('prefix:', prefix);
|
|
if (!urlpathname.startsWith(prefix))
|
|
continue;
|
|
|
|
const pathname = urlpathname.substr(prefix.length);
|
|
debugServer('pathname:', pathname);
|
|
if (baseURL) {
|
|
request.deferToBrowser({ url: String(new URL(pathname, baseURL)) });
|
|
return;
|
|
}
|
|
const fileName = path.join(folder, pathname);
|
|
if (!fs.existsSync(fileName))
|
|
continue;
|
|
|
|
const headers = { 'content-type': contentType(request, fileName) };
|
|
const body = await fsReadFile(fileName);
|
|
request.fulfill({ headers, body});
|
|
return;
|
|
}
|
|
request.deferToBrowser();
|
|
}
|
|
|
|
/**
|
|
* @return {{left: number, top: number, width: number, height: number}}
|
|
*/
|
|
async bounds() {
|
|
const { bounds } = await this.app_.session_.send('Browser.getWindowBounds', { windowId: this.windowId_ });
|
|
return { left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height };
|
|
}
|
|
|
|
/**
|
|
* @param {{left: (number|undefined), top: (number|undefined), width: (number|undefined), height: (number|undefined)}} bounds
|
|
*/
|
|
async setBounds(bounds) {
|
|
await this.app_.session_.send('Browser.setWindowBounds', { windowId: this.windowId_, bounds });
|
|
}
|
|
|
|
async fullscreen() {
|
|
const bounds = { windowState: 'fullscreen' };
|
|
await this.app_.session_.send('Browser.setWindowBounds', { windowId: this.windowId_, bounds });
|
|
}
|
|
|
|
async minimize() {
|
|
const bounds = { windowState: 'minimized' };
|
|
await this.app_.session_.send('Browser.setWindowBounds', { windowId: this.windowId_, bounds });
|
|
}
|
|
|
|
async maximize() {
|
|
const bounds = { windowState: 'maximized' };
|
|
await this.app_.session_.send('Browser.setWindowBounds', { windowId: this.windowId_, bounds });
|
|
}
|
|
|
|
bringToFront() {
|
|
return this.page_.bringToFront();
|
|
}
|
|
|
|
close() {
|
|
return this.page_.close();
|
|
}
|
|
|
|
closed_() {
|
|
rpc.dispose(this.hostHandle_);
|
|
this.app_.windowClosed_(this);
|
|
this.emit(Window.Events.Close);
|
|
}
|
|
|
|
/**
|
|
* @return {boolean}
|
|
*/
|
|
isClosed() {
|
|
return this.page_.isClosed();
|
|
}
|
|
}
|
|
|
|
Window.Events = {
|
|
Close: 'close',
|
|
};
|
|
|
|
const imageContentTypes = new Map([
|
|
['jpeg', 'image/jpeg'], ['jpg', 'image/jpeg'], ['svg', 'image/svg+xml'], ['gif', 'image/gif'], ['webp', 'image/webp'],
|
|
['png', 'image/png'], ['ico', 'image/ico'], ['tiff', 'image/tiff'], ['tif', 'image/tiff'], ['bmp', 'image/bmp']
|
|
]);
|
|
|
|
const fontContentTypes = new Map([
|
|
['ttf', 'font/opentype'], ['otf', 'font/opentype'], ['ttc', 'font/opentype'], ['woff', 'application/font-woff']
|
|
]);
|
|
|
|
/**
|
|
* @param {!HttpRequest} request
|
|
* @param {!string} fileName
|
|
*/
|
|
function contentType(request, fileName) {
|
|
const dotIndex = fileName.lastIndexOf('.');
|
|
const extension = fileName.substr(dotIndex + 1);
|
|
switch (request.resourceType()) {
|
|
case 'Document': return 'text/html';
|
|
case 'Script': return 'text/javascript';
|
|
case 'Stylesheet': return 'text/css';
|
|
case 'Image':
|
|
return imageContentTypes.get(extension) || 'image/png';
|
|
case 'Font':
|
|
return fontContentTypes.get(extension) || 'application/font-woff';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {!Object=} options
|
|
* @return {!App}
|
|
*/
|
|
async function launch(options = {}) {
|
|
debugApp('Launching Carlo', options);
|
|
options = Object.assign(options);
|
|
if (!options.bgcolor)
|
|
options.bgcolor = '#ffffff';
|
|
options.localDataDir = options.localDataDir || path.join(__dirname, '.local-data');
|
|
|
|
const { executablePath, type } = await findChrome(options);
|
|
if (!executablePath) {
|
|
console.error('Could not find Chrome installation, please make sure Chrome browser is installed from https://www.google.com/chrome/.');
|
|
process.exit(0);
|
|
return;
|
|
}
|
|
|
|
const targetPage = `
|
|
<title>${encodeURIComponent(options.title || '')}</title>
|
|
<style>html{background:${encodeURIComponent(options.bgcolor)};}</style>
|
|
<script>self.paramsForReuse = ${JSON.stringify(options.paramsForReuse || undefined)};</script>`;
|
|
|
|
const args = [
|
|
`--app=data:text/html,${targetPage}`,
|
|
`--enable-features=NetworkService,NetworkServiceInProcess`,
|
|
];
|
|
|
|
if (options.args)
|
|
args.push(...options.args);
|
|
if (typeof options.width === 'number' && typeof options.height === 'number')
|
|
args.push(`--window-size=${options.width},${options.height}`);
|
|
if (typeof options.left === 'number' && typeof options.top === 'number')
|
|
args.push(`--window-position=${options.left},${options.top}`);
|
|
|
|
try {
|
|
const browser = await puppeteer.launch({
|
|
executablePath,
|
|
pipe: true,
|
|
defaultViewport: null,
|
|
headless: testMode,
|
|
userDataDir: options.userDataDir || path.join(options.localDataDir, `profile-${type}`),
|
|
args });
|
|
const app = new App(browser, options);
|
|
await app.init_();
|
|
return app;
|
|
} catch (e) {
|
|
if (e.toString().includes('Target closed'))
|
|
throw new Error('Could not start the browser or the browser was already running with the given profile.');
|
|
else
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
class HostWindow {
|
|
/**
|
|
* @param {!Window} win
|
|
*/
|
|
constructor(win) {
|
|
this.window_ = win;
|
|
}
|
|
|
|
closeBrowser() {
|
|
// Allow rpc response to land.
|
|
setTimeout(() => this.window_.app_.exit(), 0);
|
|
}
|
|
|
|
async fileInfo(expression) {
|
|
const { result } = await this.window_.session_.send('Runtime.evaluate', { expression });
|
|
return this.window_.session_.send('DOM.getFileInfo', { objectId: result.objectId });
|
|
}
|
|
}
|
|
|
|
function enterTestMode() {
|
|
testMode = true;
|
|
}
|
|
|
|
function wrapPrefix(prefix) {
|
|
if (!prefix.startsWith('/')) prefix = '/' + prefix;
|
|
if (!prefix.endsWith('/')) prefix += '/';
|
|
return prefix;
|
|
}
|
|
|
|
module.exports = { launch, enterTestMode };
|