qunitx-cli 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/dependabot.yml +7 -0
- package/.github/workflows/push.yml +36 -0
- package/CHANGELOG.md +16 -0
- package/Dockerfile +24 -0
- package/LICENSE +22 -0
- package/TODO +90 -0
- package/build.js +54 -1
- package/cli.js +24 -1
- package/lib/boilerplates/default-project-config-values.js +6 -0
- package/lib/boilerplates/setup/tests.hbs +15 -0
- package/lib/boilerplates/setup/tsconfig.json +109 -0
- package/lib/boilerplates/test.js +25 -0
- package/lib/commands/generate.js +33 -0
- package/lib/commands/help.js +37 -0
- package/lib/commands/init.js +70 -0
- package/lib/commands/run/tests-in-browser.js +162 -0
- package/lib/commands/run.js +119 -0
- package/lib/servers/http.js +233 -0
- package/lib/setup/bind-server-to-port.js +14 -0
- package/lib/setup/browser.js +55 -0
- package/lib/setup/config.js +46 -0
- package/lib/setup/file-watcher.js +72 -0
- package/lib/setup/fs-tree.js +48 -0
- package/lib/setup/keyboard-events.js +34 -0
- package/lib/setup/test-file-paths.js +79 -0
- package/lib/setup/web-server.js +241 -0
- package/lib/setup/write-output-static-files.js +22 -0
- package/lib/tap/display-final-result.js +15 -0
- package/lib/tap/display-test-result.js +73 -0
- package/lib/utils/find-internal-assets-from-html.js +16 -0
- package/lib/utils/find-project-root.js +17 -0
- package/lib/utils/indent-string.js +11 -0
- package/lib/utils/listen-to-keyboard-key.js +44 -0
- package/lib/utils/parse-cli-flags.js +57 -0
- package/lib/utils/path-exists.js +11 -0
- package/lib/utils/resolve-port-number-for.js +27 -0
- package/lib/utils/run-user-module.js +18 -0
- package/lib/utils/search-in-parent-directories.js +15 -0
- package/lib/utils/time-counter.js +8 -0
- package/package.json +8 -5
- package/test/commands/help-test.js +72 -0
- package/test/commands/index.js +2 -0
- package/test/commands/init-test.js +44 -0
- package/test/flags/after-test.js +23 -0
- package/test/flags/before-test.js +23 -0
- package/test/flags/coverage-test.js +6 -0
- package/test/flags/failfast-test.js +5 -0
- package/test/flags/index.js +2 -0
- package/test/flags/output-test.js +6 -0
- package/test/flags/reporter-test.js +6 -0
- package/test/flags/timeout-test.js +6 -0
- package/test/flags/watch-test.js +6 -0
- package/test/helpers/after-script-async.js +13 -0
- package/test/helpers/after-script-basic.js +1 -0
- package/test/helpers/assert-stdout.js +112 -0
- package/test/helpers/before-script-async.js +35 -0
- package/test/helpers/before-script-basic.js +1 -0
- package/test/helpers/before-script-web-server-tests.js +28 -0
- package/test/helpers/failing-tests.js +49 -0
- package/test/helpers/failing-tests.ts +49 -0
- package/test/helpers/fs-writers.js +36 -0
- package/test/helpers/index-with-content.html +20 -0
- package/test/helpers/index-without-content.html +22 -0
- package/test/helpers/passing-tests-dist.js +4883 -0
- package/test/helpers/passing-tests.js +44 -0
- package/test/helpers/passing-tests.ts +44 -0
- package/test/helpers/shell.js +37 -0
- package/test/index.js +22 -0
- package/test/inputs/advanced-htmls-test.js +21 -0
- package/test/inputs/error-edge-cases-test.js +11 -0
- package/test/inputs/file-and-folder-test.js +11 -0
- package/test/inputs/file-test.js +169 -0
- package/test/inputs/folder-test.js +193 -0
- package/test/inputs/index.js +5 -0
- package/test/setup/index.js +1 -0
- package/test/setup/test-file-paths-test.js +33 -0
- package/test/setup.js +17 -0
- package/vendor/package.json +1 -0
- package/vendor/qunit.css +525 -0
- package/vendor/qunit.js +7037 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import kleur from 'kleur';
|
|
5
|
+
import esbuild from 'esbuild';
|
|
6
|
+
import timeCounter from '../../utils/time-counter.js';
|
|
7
|
+
import runUserModule from '../../utils/run-user-module.js';
|
|
8
|
+
import TAPDisplayFinalResult from '../../tap/display-final-result.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
class BundleError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'BundleError';
|
|
16
|
+
this.message = `esbuild Bundle Error: ${message}`.split('\n').join('\n# ');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default async function runTestsInBrowser(
|
|
21
|
+
config,
|
|
22
|
+
cachedContent = {},
|
|
23
|
+
connections,
|
|
24
|
+
targetTestFilesToFilter
|
|
25
|
+
) {
|
|
26
|
+
const { projectRoot, timeout, output } = config;
|
|
27
|
+
const allTestFilePaths = Object.keys(config.fsTree);
|
|
28
|
+
const runHasFilter = !!targetTestFilesToFilter;
|
|
29
|
+
|
|
30
|
+
config.COUNTER = { testCount: 0, failCount: 0, skipCount: 0, passCount: 0 };
|
|
31
|
+
config.lastRanTestFiles = targetTestFilesToFilter || allTestFilePaths;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await Promise.all([
|
|
35
|
+
esbuild.build({
|
|
36
|
+
stdin: {
|
|
37
|
+
contents: allTestFilePaths.reduce((result, fileAbsolutePath) => {
|
|
38
|
+
return result + `import "${fileAbsolutePath}";`
|
|
39
|
+
}, ''),
|
|
40
|
+
resolveDir: process.cwd()
|
|
41
|
+
},
|
|
42
|
+
bundle: true,
|
|
43
|
+
logLevel: 'error',
|
|
44
|
+
outfile: `${projectRoot}/${output}/tests.js`,
|
|
45
|
+
keepNames: true
|
|
46
|
+
}), // NOTE: This prevents file cache most likely
|
|
47
|
+
Promise.all(cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
|
|
48
|
+
let targetPath = `${config.projectRoot}/${config.output}${htmlPath}`;
|
|
49
|
+
|
|
50
|
+
if (htmlPath !== '/') {
|
|
51
|
+
await fs.rm(targetPath, { force: true, recursive: true });
|
|
52
|
+
await fs.mkdir(targetPath.split('/').slice(0, -1).join('/'), { recursive: true }); // NOTE: this can be done earlier
|
|
53
|
+
}
|
|
54
|
+
}))
|
|
55
|
+
]);
|
|
56
|
+
cachedContent.allTestCode = await fs.readFile(`${projectRoot}/${output}/tests.js`);
|
|
57
|
+
|
|
58
|
+
if (runHasFilter) {
|
|
59
|
+
let outputPath = `${projectRoot}/${output}/filtered-tests.js`;
|
|
60
|
+
|
|
61
|
+
await buildFilteredTests(targetTestFilesToFilter, outputPath);
|
|
62
|
+
cachedContent.filteredTestCode = (await fs.readFile(outputPath)).toString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let TIME_COUNTER = timeCounter();
|
|
66
|
+
|
|
67
|
+
if (runHasFilter) {
|
|
68
|
+
await runTestInsideHTMLFile('/qunitx.html', connections, config);
|
|
69
|
+
} else {
|
|
70
|
+
await Promise.all(cachedContent.htmlPathsToRunTests.map((htmlPath) => {
|
|
71
|
+
return runTestInsideHTMLFile(htmlPath, connections, config); // NOTE: maybe make this blocking
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let TIME_TAKEN = TIME_COUNTER.stop()
|
|
76
|
+
|
|
77
|
+
TAPDisplayFinalResult(config.COUNTER, TIME_TAKEN);
|
|
78
|
+
|
|
79
|
+
if (config.after) {
|
|
80
|
+
await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, 'after');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!config.watch) {
|
|
84
|
+
await Promise.all([
|
|
85
|
+
connections.server && connections.server.close(),
|
|
86
|
+
connections.browser && connections.browser.close()
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
return process.exit(config.COUNTER.failCount > 0 ? 1 : 0);
|
|
90
|
+
}
|
|
91
|
+
} catch(error) {
|
|
92
|
+
config.lastFailedTestFiles = config.lastRanTestFiles;
|
|
93
|
+
console.log(error);
|
|
94
|
+
let exception = new BundleError(error);
|
|
95
|
+
|
|
96
|
+
if (config.watch) {
|
|
97
|
+
console.log(`# ${exception}`);
|
|
98
|
+
} else {
|
|
99
|
+
throw exception;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return connections;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildFilteredTests(filteredTests, outputPath) {
|
|
107
|
+
return esbuild.build({
|
|
108
|
+
stdin: {
|
|
109
|
+
contents: filteredTests.reduce((result, fileAbsolutePath) => {
|
|
110
|
+
return result + `import "${fileAbsolutePath}";`
|
|
111
|
+
}, ''),
|
|
112
|
+
resolveDir: process.cwd()
|
|
113
|
+
},
|
|
114
|
+
bundle: true,
|
|
115
|
+
logLevel: 'error',
|
|
116
|
+
outfile: outputPath
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runTestInsideHTMLFile(filePath, { page, server }, config) {
|
|
121
|
+
let QUNIT_RESULT;
|
|
122
|
+
let targetError;
|
|
123
|
+
try {
|
|
124
|
+
await wait(350);
|
|
125
|
+
console.log('#', kleur.blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
|
|
126
|
+
await page.goto(`http://localhost:${config.port}${filePath}`, { timeout: 0 });
|
|
127
|
+
await page.evaluate(() => {
|
|
128
|
+
window.IS_PUPPETEER = true;
|
|
129
|
+
});
|
|
130
|
+
await page.waitForFunction(`window.testTimeout >= ${config.timeout}`, { timeout: 0 });
|
|
131
|
+
|
|
132
|
+
QUNIT_RESULT = await page.evaluate(() => window.QUNIT_RESULT);
|
|
133
|
+
} catch(error) {
|
|
134
|
+
targetError = error;
|
|
135
|
+
console.log(error);
|
|
136
|
+
console.error(error);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!QUNIT_RESULT || QUNIT_RESULT.totalTests === 0) {
|
|
140
|
+
console.log(targetError);
|
|
141
|
+
console.log('BROWSER: runtime error thrown during executing tests');
|
|
142
|
+
console.error('BROWSER: runtime error thrown during executing tests');
|
|
143
|
+
|
|
144
|
+
await failOnNonWatchMode(config.watch);
|
|
145
|
+
} else if (QUNIT_RESULT.totalTests > QUNIT_RESULT.finishedTests) {
|
|
146
|
+
console.log(targetError);
|
|
147
|
+
console.log(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
|
|
148
|
+
console.error(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
|
|
149
|
+
|
|
150
|
+
await failOnNonWatchMode(config.watch);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function failOnNonWatchMode(watchMode = false) {
|
|
155
|
+
if (!watchMode) {
|
|
156
|
+
await new Promise((resolve, reject) => setTimeout(() => resolve(process.exit(1)), 100));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function wait(duration) {
|
|
161
|
+
return new Promise((resolve) => setTimeout(() => { resolve() }, duration));
|
|
162
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { normalize, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import kleur from 'kleur';
|
|
5
|
+
import runTestsInBrowser from './run/tests-in-browser.js';
|
|
6
|
+
import setupBrowser from '../setup/browser.js';
|
|
7
|
+
import fileWatcher from '../setup/file-watcher.js';
|
|
8
|
+
import findInternalAssetsFromHTML from '../utils/find-internal-assets-from-html.js';
|
|
9
|
+
import runUserModule from '../utils/run-user-module.js';
|
|
10
|
+
import setupKeyboardEvents from '../setup/keyboard-events.js';
|
|
11
|
+
import writeOutputStaticFiles from '../setup/write-output-static-files.js';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
export default async function(config) {
|
|
16
|
+
let cachedContent = await buildCachedContent(config, config.htmlPaths);
|
|
17
|
+
let [connections, _] = await Promise.all([
|
|
18
|
+
setupBrowser(config, cachedContent),
|
|
19
|
+
writeOutputStaticFiles(config, cachedContent)
|
|
20
|
+
]);
|
|
21
|
+
config.expressApp = connections.server;
|
|
22
|
+
|
|
23
|
+
if (config.watch) {
|
|
24
|
+
setupKeyboardEvents(config, cachedContent, connections);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (config.before) {
|
|
28
|
+
await runUserModule(`${process.cwd()}/${config.before}`, config, 'before');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await runTestsInBrowser(config, cachedContent, connections);
|
|
32
|
+
|
|
33
|
+
if (config.watch) {
|
|
34
|
+
logWatcherAndKeyboardShortcutInfo(config, connections.server);
|
|
35
|
+
|
|
36
|
+
await fileWatcher(
|
|
37
|
+
config.testFileLookupPaths,
|
|
38
|
+
config,
|
|
39
|
+
async (event, file) => {
|
|
40
|
+
if (event === 'addDir') {
|
|
41
|
+
return;
|
|
42
|
+
} else if (['unlink', 'unlinkDir'].includes(event)) {
|
|
43
|
+
return await runTestsInBrowser(config, cachedContent, connections);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await runTestsInBrowser(config, cachedContent, connections, [file]);
|
|
47
|
+
},
|
|
48
|
+
(path, event) => connections.server.publish('refresh', 'refresh')
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function buildCachedContent(config, htmlPaths) {
|
|
54
|
+
let htmlBuffers = await Promise.all(config.htmlPaths.map((htmlPath) => fs.readFile(htmlPath))); // TODO: remove this and read it from the fsTree, should be cached?
|
|
55
|
+
let cachedContent = htmlPaths.reduce((result, htmlPath, index) => {
|
|
56
|
+
let filePath = config.htmlPaths[index];
|
|
57
|
+
let html = htmlBuffers[index].toString();
|
|
58
|
+
|
|
59
|
+
if (html.includes('{{content}}')) { // TODO: here I could do html analysis to see which static js certain html points to? Complex algorithm
|
|
60
|
+
result.dynamicContentHTMLs[filePath] = html;
|
|
61
|
+
|
|
62
|
+
let relativePath = filePath.replace(config.projectRoot, '');
|
|
63
|
+
|
|
64
|
+
result.htmlPathsToRunTests.push(relativePath);
|
|
65
|
+
} else {
|
|
66
|
+
console.log('#', kleur.yellow(`WARNING: Static html file with no {{content}} detected. Therefore ignoring ${filePath}`));
|
|
67
|
+
result.staticHTMLs[filePath] = html;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
findInternalAssetsFromHTML(html).forEach((key) => {
|
|
71
|
+
result.assets.add(normalizeInternalAssetPathFromHTML(config.projectRoot, key, filePath))
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
}, {
|
|
76
|
+
allTestCode: null,
|
|
77
|
+
assets: new Set(),
|
|
78
|
+
htmlPathsToRunTests: [],
|
|
79
|
+
mainHTML: { filePath: null, html: null },
|
|
80
|
+
staticHTMLs: {},
|
|
81
|
+
dynamicContentHTMLs: {}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (cachedContent.htmlPathsToRunTests.length === 0) {
|
|
85
|
+
cachedContent.htmlPathsToRunTests = ['/'];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return await addCachedContentMainHTML(config.projectRoot, cachedContent);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function addCachedContentMainHTML(projectRoot, cachedContent) {
|
|
92
|
+
let mainHTMLPath = Object.keys(cachedContent.dynamicContentHTMLs)[0];
|
|
93
|
+
if (mainHTMLPath) {
|
|
94
|
+
cachedContent.mainHTML = {
|
|
95
|
+
filePath: mainHTMLPath,
|
|
96
|
+
html: cachedContent.dynamicContentHTMLs[mainHTMLPath]
|
|
97
|
+
};
|
|
98
|
+
} else {
|
|
99
|
+
let html = (await fs.readFile(`${__dirname}/../boilerplates/setup/tests.hbs`)).toString();
|
|
100
|
+
|
|
101
|
+
cachedContent.mainHTML = { filePath: `${projectRoot}/test/tests.html`, html };
|
|
102
|
+
cachedContent.assets.add(`${projectRoot}/node_modules/qunitx/vendor/qunit.css`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return cachedContent;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function logWatcherAndKeyboardShortcutInfo(config, server) {
|
|
109
|
+
console.log('#', kleur.blue(`Watching files... You can browse the tests on http://localhost:${config.port} ...`)); // NOTE: maybe add also qx to exit
|
|
110
|
+
console.log('#', kleur.blue(`Shortcuts: Press "qq" to abort running tests, "qa" to run all the tests, "qf" to run last failing test, "ql" to repeat last test`)); // NOTE: maybe add also qx to test specific
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) { // NOTE: maybe normalize ..
|
|
114
|
+
let currentDirectory = htmlPath ? htmlPath.split('/').slice(0, -1).join('/') : projectRoot;
|
|
115
|
+
|
|
116
|
+
return assetPath.startsWith('./')
|
|
117
|
+
? normalize(`${currentDirectory}/${assetPath.slice(2)}`)
|
|
118
|
+
: normalize(`${currentDirectory}/${assetPath}`);
|
|
119
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
3
|
+
import bindServerToPort from '../setup/bind-server-to-port.js';
|
|
4
|
+
|
|
5
|
+
export const MIME_TYPES = {
|
|
6
|
+
html: "text/html; charset=UTF-8",
|
|
7
|
+
js: "application/javascript",
|
|
8
|
+
css: "text/css",
|
|
9
|
+
png: "image/png",
|
|
10
|
+
jpg: "image/jpg",
|
|
11
|
+
gif: "image/gif",
|
|
12
|
+
ico: "image/x-icon",
|
|
13
|
+
svg: "image/svg+xml",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default class HTTPServer {
|
|
17
|
+
static serve(config = { port: 1234 }, handler) {
|
|
18
|
+
let onListen = config.onListen || ((server) => {});
|
|
19
|
+
let onError = config.onError || ((error) => {});
|
|
20
|
+
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
let server = http.createServer((req, res) => {
|
|
23
|
+
return handler(req, res);
|
|
24
|
+
});
|
|
25
|
+
server = server;
|
|
26
|
+
server.on('error', (error) => {
|
|
27
|
+
onError(error);
|
|
28
|
+
reject(error);
|
|
29
|
+
}).once('listening', () => {
|
|
30
|
+
onListen(Object.assign({ hostname: '127.0.0.1', server }, config));
|
|
31
|
+
resolve(server);
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
server.wss = new WebSocketServer({ server });
|
|
35
|
+
server.wss.on('error', (error) => {
|
|
36
|
+
console.log('# [WebSocketServer] Error:');
|
|
37
|
+
console.trace(error);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
bindServerToPort(server, config)
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
constructor() {
|
|
45
|
+
this.routes = {
|
|
46
|
+
GET: {},
|
|
47
|
+
POST: {},
|
|
48
|
+
DELETE: {},
|
|
49
|
+
PUT: {}
|
|
50
|
+
};
|
|
51
|
+
this.middleware = [];
|
|
52
|
+
this._server = http.createServer((req, res) => {
|
|
53
|
+
res.send = (data) => {
|
|
54
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
55
|
+
res.end(data);
|
|
56
|
+
};
|
|
57
|
+
res.json = (data) => {
|
|
58
|
+
res.setHeader('Content-Type', 'application/json');
|
|
59
|
+
res.end(JSON.stringify(data));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return this.handleRequest(req, res);
|
|
63
|
+
});
|
|
64
|
+
this.wss = new WebSocketServer({ server: this._server });
|
|
65
|
+
this.wss.on('error', (error) => {
|
|
66
|
+
console.log('# [WebSocketServer] Error:');
|
|
67
|
+
console.log(error);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
close() {
|
|
72
|
+
return this._server.close();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get(path, handler) {
|
|
76
|
+
this.registerRouteHandler('GET', path, handler);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
listen(port = 0, callback = () => {}) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
this._server.listen(port, (error) => {
|
|
82
|
+
if (error) {
|
|
83
|
+
reject(error);
|
|
84
|
+
} else {
|
|
85
|
+
resolve(callback());
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
publish(data) {
|
|
92
|
+
this.wss.clients.forEach((client) => {
|
|
93
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
94
|
+
client.send(data);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
post(path, handler) {
|
|
100
|
+
this.registerRouteHandler('POST', path, handler);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
delete(path, handler) {
|
|
104
|
+
this.registerRouteHandler('DELETE', path, handler);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
put(path, handler) {
|
|
108
|
+
this.registerRouteHandler('PUT', path, handler);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
use(middleware) {
|
|
112
|
+
this.middleware.push(middleware);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
registerRouteHandler(method, path, handler) {
|
|
116
|
+
if (!this.routes[method]) {
|
|
117
|
+
this.routes[method] = {};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.routes[method][path] = {
|
|
121
|
+
path,
|
|
122
|
+
handler,
|
|
123
|
+
paramNames: this.extractParamNames(path),
|
|
124
|
+
isWildcard: path === '/*'
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
handleRequest(req, res) {
|
|
129
|
+
const { method, url } = req;
|
|
130
|
+
const matchingRoute = this.findRouteHandler(method, url);
|
|
131
|
+
|
|
132
|
+
if (matchingRoute) {
|
|
133
|
+
req.params = this.extractParams(matchingRoute, url);
|
|
134
|
+
this.runMiddleware(req, res, matchingRoute.handler);
|
|
135
|
+
} else {
|
|
136
|
+
res.statusCode = 404;
|
|
137
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
138
|
+
res.end('Not found');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
runMiddleware(req, res, callback) {
|
|
143
|
+
let index = 0;
|
|
144
|
+
const next = () => {
|
|
145
|
+
if (index >= this.middleware.length) {
|
|
146
|
+
callback(req, res);
|
|
147
|
+
} else {
|
|
148
|
+
const middleware = this.middleware[index];
|
|
149
|
+
index++;
|
|
150
|
+
middleware(req, res, next);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
next();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
findRouteHandler(method, url) {
|
|
157
|
+
const routes = this.routes[method];
|
|
158
|
+
if (!routes) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return routes[url] || Object.values(routes).find(route => {
|
|
163
|
+
const { path, isWildcard } = route;
|
|
164
|
+
|
|
165
|
+
if (!isWildcard && !path.includes(':')) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (isWildcard || this.matchPathSegments(path, url)) {
|
|
170
|
+
if (route.paramNames.length > 0) {
|
|
171
|
+
const regexPattern = this.buildRegexPattern(path, route.paramNames);
|
|
172
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
173
|
+
const regexMatches = regex.exec(url);
|
|
174
|
+
if (regexMatches) {
|
|
175
|
+
route.paramValues = regexMatches.slice(1);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return false;
|
|
182
|
+
}) || routes['/*'] || null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
matchPathSegments(path, url) {
|
|
186
|
+
const pathSegments = path.split('/');
|
|
187
|
+
const urlSegments = url.split('/');
|
|
188
|
+
|
|
189
|
+
if (pathSegments.length !== urlSegments.length) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (let i = 0; i < pathSegments.length; i++) {
|
|
194
|
+
const pathSegment = pathSegments[i];
|
|
195
|
+
const urlSegment = urlSegments[i];
|
|
196
|
+
|
|
197
|
+
if (pathSegment.startsWith(':')) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (pathSegment !== urlSegment) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
buildRegexPattern(path, paramNames) {
|
|
210
|
+
let regexPattern = path.replace(/:[^/]+/g, '([^/]+)');
|
|
211
|
+
regexPattern = regexPattern.replace(/\//g, '\\/');
|
|
212
|
+
|
|
213
|
+
return regexPattern;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
extractParamNames(path) {
|
|
217
|
+
const paramRegex = /:(\w+)/g;
|
|
218
|
+
const paramMatches = path.match(paramRegex);
|
|
219
|
+
|
|
220
|
+
return paramMatches ? paramMatches.map(match => match.slice(1)) : [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
extractParams(route, url) {
|
|
224
|
+
const { paramNames, paramValues } = route;
|
|
225
|
+
const params = {};
|
|
226
|
+
|
|
227
|
+
for (let i = 0; i < paramNames.length; i++) {
|
|
228
|
+
params[paramNames[i]] = paramValues[i];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return params;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import resolvePortNumberFor from '../utils/resolve-port-number-for.js';
|
|
2
|
+
|
|
3
|
+
// NOTE: there was a race condition between socket.connection and server.listen, check if nanoexpress fixes it
|
|
4
|
+
export default async function bindServerToPort(server, config) {
|
|
5
|
+
try {
|
|
6
|
+
let port = await resolvePortNumberFor(config.port);
|
|
7
|
+
|
|
8
|
+
await server.listen(port);
|
|
9
|
+
|
|
10
|
+
return server;
|
|
11
|
+
} catch(e) {
|
|
12
|
+
return await bindServerToPort(server, Object.assign(config, { port: config.port + 1 }));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Puppeteer from 'puppeteer';
|
|
2
|
+
import setupWebServer from './web-server.js';
|
|
3
|
+
import bindServerToPort from './bind-server-to-port.js';
|
|
4
|
+
|
|
5
|
+
export default async function setupBrowser(config = {
|
|
6
|
+
port: 1234, debug: false, watch: false, timeout: 10000
|
|
7
|
+
}, cachedContent) {
|
|
8
|
+
let [server, browser] = await Promise.all([
|
|
9
|
+
setupWebServer(config, cachedContent),
|
|
10
|
+
Puppeteer.launch({
|
|
11
|
+
debugger: config.debug || false,
|
|
12
|
+
args: ['--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', '--window-size=1440,900'],
|
|
13
|
+
executablePath: process.env.CHROME_BIN || null,
|
|
14
|
+
headless: 'new',
|
|
15
|
+
}),
|
|
16
|
+
]);
|
|
17
|
+
let [page, _] = await Promise.all([
|
|
18
|
+
browser.newPage(),
|
|
19
|
+
bindServerToPort(server, config)
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
page.on('console', async (msg) => {
|
|
23
|
+
if (config.debug) {
|
|
24
|
+
const args = await Promise.all(msg.args().map((arg) => turnToObjects(arg)));
|
|
25
|
+
|
|
26
|
+
console.log(...args);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
page.on('error', (msg) => {
|
|
30
|
+
try {
|
|
31
|
+
throw error;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error(e, e.stack);
|
|
34
|
+
console.log(e, e.stack);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
page.on('pageerror', async (error) => {
|
|
38
|
+
try {
|
|
39
|
+
throw error;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.log(e.toString());
|
|
42
|
+
console.error(e.toString());
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return { server, browser, page };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function turnToObjects(jsHandle) {
|
|
50
|
+
return jsHandle.jsonValue();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// function turnMStoSecond(timeInMS) {
|
|
54
|
+
// return (timeInMS / 1000).toFixed(2);
|
|
55
|
+
// }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import defaultProjectConfigValues from '../boilerplates/default-project-config-values.js';
|
|
3
|
+
import findProjectRoot from '../utils/find-project-root.js';
|
|
4
|
+
import setupFSTree from './fs-tree.js';
|
|
5
|
+
import setupTestFilePaths from './test-file-paths.js';
|
|
6
|
+
import parseCliFlags from '../utils/parse-cli-flags.js';
|
|
7
|
+
|
|
8
|
+
export default async function setupConfig() {
|
|
9
|
+
let projectRoot = await findProjectRoot();
|
|
10
|
+
let [projectPackageJSON, cliConfigFlags] = await Promise.all([
|
|
11
|
+
readConfigFromPackageJSON(projectRoot),
|
|
12
|
+
parseCliFlags(projectRoot)
|
|
13
|
+
]);
|
|
14
|
+
let inputs = cliConfigFlags.inputs.concat(readInputsFromPackageJSON(projectPackageJSON));
|
|
15
|
+
let config = {
|
|
16
|
+
projectRoot,
|
|
17
|
+
htmlPaths: [],
|
|
18
|
+
lastFailedTestFiles: null,
|
|
19
|
+
lastRanTestFiles: null,
|
|
20
|
+
...defaultProjectConfigValues,
|
|
21
|
+
...projectPackageJSON.qunitx,
|
|
22
|
+
...cliConfigFlags,
|
|
23
|
+
inputs
|
|
24
|
+
};
|
|
25
|
+
config.htmlPaths = normalizeHTMLPaths(config.projectRoot, config.htmlPaths);
|
|
26
|
+
config.testFileLookupPaths = setupTestFilePaths(config.projectRoot, config.inputs);
|
|
27
|
+
config.fsTree = await setupFSTree(config.testFileLookupPaths, config);
|
|
28
|
+
|
|
29
|
+
return config;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readConfigFromPackageJSON(projectRoot) {
|
|
33
|
+
let packageJSON = await fs.readFile(`${projectRoot}/package.json`);
|
|
34
|
+
|
|
35
|
+
return JSON.parse(packageJSON.toString());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeHTMLPaths(projectRoot, htmlPaths) {
|
|
39
|
+
return Array.from(new Set(htmlPaths.map((htmlPath) => `${projectRoot}/${htmlPath}`)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readInputsFromPackageJSON(packageJSON) {
|
|
43
|
+
let qunitx = packageJSON.qunitx;
|
|
44
|
+
|
|
45
|
+
return qunitx && qunitx.inputs ? qunitx.inputs : [];
|
|
46
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import chokidar from 'chokidar';
|
|
2
|
+
import kleur from 'kleur';
|
|
3
|
+
|
|
4
|
+
export default async function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
|
|
5
|
+
let extensions = ['js', 'ts'];
|
|
6
|
+
let fileWatchers = testFileLookupPaths.reduce((watcher, watchPath) => {
|
|
7
|
+
return Object.assign(watcher, {
|
|
8
|
+
[watchPath]: chokidar.watch(watchPath, { ignoreInitial: true }).on('all', (event, path) => {
|
|
9
|
+
if (extensions.some((extension) => path.endsWith(extension))) {
|
|
10
|
+
mutateFSTree(config.fsTree, event, path);
|
|
11
|
+
|
|
12
|
+
console.log('#', kleur.magenta().bold('=================================================================='));
|
|
13
|
+
console.log('#', getEventColor(event), path.split(config.projectRoot)[1]);
|
|
14
|
+
console.log('#', kleur.magenta().bold('=================================================================='));
|
|
15
|
+
|
|
16
|
+
if (!global.chokidarBuild) {
|
|
17
|
+
global.chokidarBuild = true;
|
|
18
|
+
|
|
19
|
+
let result = extensions.some((extension) => path.endsWith(extension)) ? onEventFunc(event, path) : null;
|
|
20
|
+
|
|
21
|
+
if (!(result instanceof Promise)) {
|
|
22
|
+
global.chokidarBuild = false;
|
|
23
|
+
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
result
|
|
28
|
+
.then(() => {
|
|
29
|
+
onFinishFunc ? onFinishFunc(event, path) : null;
|
|
30
|
+
})
|
|
31
|
+
.catch(() => {
|
|
32
|
+
// TODO: make an index.html to display the error
|
|
33
|
+
// error type has to be derived from the error!
|
|
34
|
+
})
|
|
35
|
+
.finally(() => (global.chokidarBuild = false));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
});
|
|
40
|
+
}, {});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
fileWatchers,
|
|
44
|
+
killFileWatchers() {
|
|
45
|
+
Object.keys(fileWatchers).forEach((watcherKey) => fileWatchers[watcherKey].close());
|
|
46
|
+
|
|
47
|
+
return fileWatchers;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mutateFSTree(fsTree, event, path) {
|
|
53
|
+
if (event === 'add') {
|
|
54
|
+
fsTree[path] = null;
|
|
55
|
+
} else if (event === 'unlink') {
|
|
56
|
+
delete fsTree[path];
|
|
57
|
+
} else if (event === 'unlinkDir') {
|
|
58
|
+
let targetPaths = Object.keys(config.fsTree).filter((treePath) => treePath.startsWith(path));
|
|
59
|
+
|
|
60
|
+
targetPaths.forEach((path) => delete config.fsTree[path]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getEventColor(event) {
|
|
65
|
+
if (event === 'change') {
|
|
66
|
+
return kleur.yellow('CHANGED:');
|
|
67
|
+
} else if (event === 'add' || event === 'addDir') {
|
|
68
|
+
return kleur.green('ADDED:');
|
|
69
|
+
} else if (event === 'unlink' || event === 'unlinkDir') {
|
|
70
|
+
return kleur.red('REMOVED:');
|
|
71
|
+
}
|
|
72
|
+
}
|