Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
Krinkle committed Jan 23, 2025
1 parent da1e629 commit a0cdc03
Show file tree
Hide file tree
Showing 13 changed files with 625 additions and 149 deletions.
6 changes: 4 additions & 2 deletions bin/qtap.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ program
},
60
)
.option('-r, --reporter <reporter>')
.option('-w, --watch', 'Watch files for changes and re-run the test suite.')
.option('-v, --verbose', 'Enable verbose debug logging.')
.option('-V, --version', 'Display version number.')
Expand All @@ -74,13 +75,14 @@ if (opts.version) {
});

try {
const exitCode = await qtap.run(opts.browser, program.args, {
const result = await qtap.runWaitFor(opts.browser, program.args, {
config: opts.config,
timeout: opts.timeout,
connectTimeout: opts.connectTimeout,
reporter: opts.reporter,
verbose: opts.verbose
});
process.exit(exitCode);
process.exit(result.exitCode);
} catch (e) {
console.error(e);
process.exit(1);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"eslint": "~8.57.1",
"eslint-config-semistandard": "~17.0.0",
"eslint-plugin-qunit": "^8.1.2",
"qunit": "2.23.1",
"qunit": "2.24.0",
"semistandard": "~17.0.0",
"typescript": "5.7.3"
},
Expand Down
81 changes: 81 additions & 0 deletions spinners.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
{
"dots": {
"interval": 80,
"frames": [
"",
"",
"",
"",
"",
"",
"",
"",
"",
""
]
},
"dotsCircle": {
"interval": 80,
"frames": [
"",
"⠎⠁",
"⠊⠑",
"⠈⠱",
"",
"⢀⡰",
"⢄⡠",
"⢆⡀"
]
},
"toggle10": {
"interval": 100,
"frames": [
"",
"",
""
]
},
"pong": {
"interval": 80,
"frames": [
"▐⠂ ▌",
"▐⠈ ▌",
"▐ ⠂ ▌",
"▐ ⠠ ▌",
"▐ ⡀ ▌",
"▐ ⠠ ▌",
"▐ ⠂ ▌",
"▐ ⠈ ▌",
"▐ ⠂ ▌",
"▐ ⠠ ▌",
"▐ ⡀ ▌",
"▐ ⠠ ▌",
"▐ ⠂ ▌",
"▐ ⠈ ▌",
"▐ ⠂▌",
"▐ ⠠▌",
"▐ ⡀▌",
"▐ ⠠ ▌",
"▐ ⠂ ▌",
"▐ ⠈ ▌",
"▐ ⠂ ▌",
"▐ ⠠ ▌",
"▐ ⡀ ▌",
"▐ ⠠ ▌",
"▐ ⠂ ▌",
"▐ ⠈ ▌",
"▐ ⠂ ▌",
"▐ ⠠ ▌",
"▐ ⡀ ▌",
"▐⠠ ▌"
]
},
"layer": {
"interval": 150,
"frames": [
"-",
"=",
""
]
}
}
150 changes: 105 additions & 45 deletions src/qtap.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
'use strict';

import { EventEmitter } from 'node:events';
import util from 'node:util';
import path from 'node:path';

import kleur from 'kleur';
import browsers from './browsers.js';
import reporters from './reporters.js';
import { ControlServer } from './server.js';

/**
Expand Down Expand Up @@ -83,6 +85,7 @@ function makeLogger (defaultChannel, printDebug, verbose = false) {
* @property {number} [timeout=30] How long a browser may be quiet between results.
* @property {number} [connectTimeout=60] How many seconds a browser may take to start up.
* @property {boolean} [verbose=false]
* @property {string} [reporter]
* @property {string} [cwd=process.cwd()] Base directory to interpret test file paths
* relative to. Ignored if testing from URLs.
* @property {Function} [printDebug=console.error]
Expand All @@ -93,9 +96,9 @@ function makeLogger (defaultChannel, printDebug, verbose = false) {
* to a built-in browser from QTap, or to a key in the optional `config.browsers` object.
* @param {string|string[]} files Files and/or URLs.
* @param {qtap.RunOptions} [options]
* @return {Promise<number>} Exit code. 0 is success, 1 is failed.
* @return {EventEmitter}
*/
async function run (browserNames, files, options = {}) {
function run (browserNames, files, options = {}) {
if (typeof browserNames === 'string') browserNames = [browserNames];
if (typeof files === 'string') files = [files];

Expand All @@ -104,67 +107,124 @@ async function run (browserNames, files, options = {}) {
options.printDebug || console.error,
options.verbose
);
const eventbus = new EventEmitter();

if (options.reporter) {
if (options.reporter in reporters) {
logger.debug('reporter_init', options.reporter);
reporters[options.reporter](eventbus);
} else {
logger.warning('reporter_unknown', options.reporter);
}
}

const servers = [];
for (const file of files) {
servers.push(new ControlServer(options.cwd, file, logger, {
servers.push(new ControlServer(options.cwd, file, eventbus, logger, {
idleTimeout: options.timeout,
connectTimeout: options.connectTimeout
}));
}

// TODO: Add test for config file not found
// TODO: Add test for config file with runtime errors
// TODO: Add test for relative config file without leading `./`, handled by process.resolve()
let config;
if (typeof options.config === 'string') {
logger.debug('load_config', options.config);
config = (await import(path.resolve(process.cwd(), options.config))).default;
}
const globalController = new AbortController();
const globalSignal = globalController.signal;

const browerPromises = [];
for (const browserName of browserNames) {
logger.debug('get_browser', browserName);
const browserFn = browsers[browserName] || config?.browsers?.[browserName];
if (typeof browserFn !== 'function') {
throw new Error('Unknown browser ' + browserName);
const runPromise = (async () => {
// TODO: Add test for config file not found
// TODO: Add test for config file with runtime errors
// TODO: Add test for relative config file without leading `./`, handled by process.resolve()
let config;
if (typeof options.config === 'string') {
logger.debug('load_config', options.config);
config = (await import(path.resolve(process.cwd(), options.config))).default;
}
for (const server of servers) {
// Each launchBrowser() returns a Promise that settles when the browser exits.
// Launch concurrently, and await afterwards.
browerPromises.push(server.launchBrowser(browserFn, browserName, globalSignal));
const globalController = new AbortController();
const globalSignal = globalController.signal;

const browerPromises = [];
for (const browserName of browserNames) {
logger.debug('get_browser', browserName);
const browserFn = browsers[browserName] || config?.browsers?.[browserName];
if (typeof browserFn !== 'function') {
throw new Error('Unknown browser ' + browserName);
}
for (const server of servers) {
// Each launchBrowser() returns a Promise that settles when the browser exits.
// Launch concurrently, and await afterwards.
browerPromises.push(server.launchBrowser(browserFn, browserName, globalSignal));
}
}
}

try {
// Wait for all tests and browsers to finish/stop, regardless of errors thrown,
// to avoid dangling browser processes.
await Promise.allSettled(browerPromises);

// Re-wait, this time letting the first of any errors bubble up.
for (const browerPromise of browerPromises) {
await browerPromise;
const result = {
ok: true,
exitCode: 0
};
eventbus.on('clientbail', () => {
result.ok = false;
result.exitCode = 1;
});
eventbus.on('clientresult', (event) => {
if (!event.result.ok) {
result.ok = false;
result.exitCode = 1;
}
});

try {
// Wait for all tests and browsers to finish/stop, regardless of errors thrown,
// to avoid dangling browser processes.
await Promise.allSettled(browerPromises);

// Re-wait, this time letting the first of any errors bubble up.
for (const browerPromise of browerPromises) {
await browerPromise;
}

logger.debug('shared_cleanup', 'Invoke global signal to clean up shared resources');
globalController.abort();
} finally {
// Make sure we close our server even if the above throws, so that Node.js
// may naturally exit (no open ports remaining)
for (const server of servers) {
server.close();
}
}

logger.debug('shared_cleanup', 'Invoke global signal to clean up shared resources');
globalController.abort();
} finally {
// Make sure we close our server even if the above throws, so that Node.js
// may naturally exit (no open ports remaining)
for (const server of servers) {
server.close();
}
}
eventbus.emit('finish', result);
})();
runPromise.catch((error) => {
// Node.js automatically ensures users cannot forget to listen for the 'error' event.
// For this reason, runWaitFor() is a separate method, because that converts the
// 'error' event into a rejected Promise. If we created that Promise as part of run()
// like `return {eventbus, promise}`), then we loose this useful detection, because
// we'd have already listened for it. Plus, it causes an unhandledRejection error
// for those that only want the events and not the Promise.
eventbus.emit('error', error);
});

return eventbus;
}

// TODO: Set exit status to 1 on failures, to ease programmatic use and testing.
// TODO: Return an event emitter for custom reporting via programmatic use.
return 0;
/**
* Same as run() but can awaited.
*
* Use this if all you want is a boolean result and/or if you use the 'reporter'
* option for any output/display. For detailed events, call run() instead.
*
* @return {Promise<{ok: boolean, exitCode: number}>}
* - ok: true for success, false for failure.
* - exitCode: 0 for success, 1 for failure.
*/
async function runWaitFor (browserNames, files, options = {}) {
const eventbus = run(browserNames, files, options);

const result = await new Promise((resolve, reject) => {
eventbus.on('finish', resolve);
eventbus.on('error', reject);
});
return result;
}

export default {
run,
runWaitFor,

browsers,
LocalBrowser: browsers.LocalBrowser,
Expand Down
Loading

0 comments on commit a0cdc03

Please sign in to comment.