browser-ava 1.0.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/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2021-2022 by arlac77
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ * Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ [![npm](https://img.shields.io/npm/v/browser-ava.svg)](https://www.npmjs.com/package/browser-ava)
2
+ [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause)
3
+ [![Open Bundle](https://bundlejs.com/badge-light.svg)](https://bundlejs.com/?q=browser-ava)
4
+ [![downloads](http://img.shields.io/npm/dm/browser-ava.svg?style=flat-square)](https://npmjs.org/package/browser-ava)
5
+ [![GitHub Issues](https://img.shields.io/github/issues/arlac77/browser-ava.svg?style=flat-square)](https://github.com/arlac77/browser-ava/issues)
6
+ [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Farlac77%2Fbrowser-ava%2Fbadge\&style=flat)](https://actions-badge.atrox.dev/arlac77/browser-ava/goto)
7
+ [![Coverage Status](https://coveralls.io/repos/arlac77/browser-ava/badge.svg)](https://coveralls.io/github/arlac77/browser-ava)
8
+ # browser-ava
9
+ Run ava tests in the browser
10
+
11
+
12
+ ## What it does
13
+
14
+ If your code does not depend on any node api (process, fs, ...) then this runner allows to run your ava test inside the browser.
15
+
16
+ ### Running your tests
17
+
18
+ ```console
19
+ browser-ava --webkit --chromium --firefox tests/*.mjs
20
+ ```
21
+
22
+
23
+ ## limitations
24
+
25
+ - only supports ESM
26
+
27
+
28
+ ## install
29
+
30
+ ```console
31
+ npm -g install browser-ava
32
+ ```
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "browser-ava",
3
+ "version": "1.0.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Run ava tests in the browser",
8
+ "keywords": [
9
+ "ava",
10
+ "runner",
11
+ "test",
12
+ "testing"
13
+ ],
14
+ "contributors": [
15
+ {
16
+ "name": "Markus Felten",
17
+ "email": "markus.felten@gmx.de"
18
+ }
19
+ ],
20
+ "license": "BSD-2-Clause",
21
+ "bin": {
22
+ "browser-ava": "src/browser-ava-cli.mjs"
23
+ },
24
+ "scripts": {
25
+ "test": "npm run test:ava",
26
+ "test:ava": "ava --timeout 2m tests/*.mjs",
27
+ "cover": "c8 -x 'tests/**/*' --temp-directory build/tmp ava --timeout 2m tests/*.mjs && c8 report -r lcov -o build/coverage --temp-directory build/tmp"
28
+ },
29
+ "dependencies": {
30
+ "commander": "^9.4.1",
31
+ "es-module-lexer": "^1.0.5",
32
+ "koa": "^2.13.4",
33
+ "koa-static": "^5.0.0",
34
+ "playwright": "^1.27.1",
35
+ "ws": "^8.9.0"
36
+ },
37
+ "devDependencies": {
38
+ "ava": "^5.0.1",
39
+ "c8": "^7.12.0",
40
+ "execa": "^6.1.0",
41
+ "semantic-release": "^19.0.5"
42
+ },
43
+ "engines": {
44
+ "node": ">=16.18.0"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/arlac77/browser-ava.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/arlac77/browser-ava/issues"
52
+ },
53
+ "homepage": "https://github.com/arlac77/browser-ava#readme",
54
+ "template": {
55
+ "inheritFrom": [
56
+ "arlac77/template-arlac77-github",
57
+ "arlac77/template-node-app"
58
+ ]
59
+ }
60
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Holds all tests
3
+ */
4
+ export const testModules = [];
5
+
6
+ /**
7
+ * Collect all tests into testModules
8
+ */
9
+ export default function test(body, ...args) {
10
+ let title;
11
+ if (typeof body === "string") {
12
+ title = body;
13
+ body = args.shift();
14
+ }
15
+
16
+ if (body.title) {
17
+ title = body.title(title, ...args);
18
+ }
19
+
20
+ const def = { title, body, args };
21
+ testModules.at(-1).tests.push(def);
22
+ return def;
23
+ }
24
+
25
+ test.failing = (...args) => {
26
+ test(...args).failing = true;
27
+ };
28
+
29
+ test.skip = (...args) => {
30
+ test(...args).skip = true;
31
+ };
32
+
33
+ test.only = (...args) => {
34
+ test(...args).only = true;
35
+ };
36
+
37
+ test.serial = (...args) => {
38
+ test(...args).serial = true;
39
+ };
40
+
41
+ test.todo = title => {
42
+ const def = { title, todo: true };
43
+ testModules.at(-1).tests.push(def);
44
+ };
45
+
46
+ test.serial.todo = test.todo;
47
+
48
+ test.before = (...args) => {
49
+ const def = { args };
50
+ testModules.at(-1).before.push(def);
51
+ return def;
52
+ };
53
+
54
+ test.serial.before = (...args) => {
55
+ test.before(...args).serial = true;
56
+ };
57
+
58
+ test.after = (...args) => {
59
+ const def = { args };
60
+ testModules.at(-1).after.push(def);
61
+ return def;
62
+ };
63
+ test.after.always = (...args) => {
64
+ test.after(...args).always = true;
65
+ };
66
+
67
+ test.serial.after = (...args) => {
68
+ test.after(...args).serial = true;
69
+ };
70
+
71
+ test.beforeEach = (...args) => {
72
+ const def = { args };
73
+ testModules.at(-1).beforeEach.push(def);
74
+ return def;
75
+ };
76
+ test.beforeEach.always = (...args) => {
77
+ test.beforeEach(...args).always = true;
78
+ };
79
+
80
+ test.afterEach = (...args) => {
81
+ const def = { args };
82
+ testModules.at(-1).afterEach.push(def);
83
+ return def;
84
+ };
85
+ test.afterEach.always = () => {
86
+ test.afterEach(...args).always = true;
87
+ };
@@ -0,0 +1,73 @@
1
+ export function isEqual(a, b) {
2
+ if (a !== undefined && b === undefined) {
3
+ return false;
4
+ }
5
+
6
+ if (isScalar(a)) {
7
+ return Object.is(a, b);
8
+ }
9
+
10
+ if (Array.isArray(a)) {
11
+ if (a.length === b.length) {
12
+ for (let i = 0; i < a.length; i++) {
13
+ if (!isEqual(a[i], b[i])) {
14
+ return false;
15
+ }
16
+ }
17
+ return true;
18
+ }
19
+
20
+ return false;
21
+ }
22
+
23
+ if (typeof a === "object") {
24
+ if (a instanceof Set) {
25
+ return (
26
+ b instanceof Set &&
27
+ a.size === b.size &&
28
+ [...a].every((value) => b.has(value))
29
+ );
30
+ }
31
+ if (a instanceof Map) {
32
+ if (!(b instanceof Map) || a.size !== b.size) {
33
+ return false;
34
+ }
35
+ for (const [k, v] of a.entries()) {
36
+ if (!isEqual(v, b.get(k))) {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ return true;
42
+ }
43
+
44
+ for (const key of new Set(Object.keys(a).concat(Object.keys(b)))) {
45
+ if (!isEqual(a[key], b[key])) {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ return true;
51
+ }
52
+
53
+ return true;
54
+ }
55
+
56
+ const scalarTypes = new Set([
57
+ "symbol",
58
+ "undefined",
59
+ "string",
60
+ "number",
61
+ "bigint",
62
+ "boolean",
63
+ ]);
64
+
65
+ export function isScalar(a) {
66
+ return (
67
+ scalarTypes.has(typeof a) ||
68
+ a instanceof String ||
69
+ a instanceof Number ||
70
+ a instanceof Function ||
71
+ a === null
72
+ );
73
+ }
Binary file
@@ -0,0 +1,40 @@
1
+
2
+ .running {
3
+ font-weight: bold;
4
+ }
5
+
6
+ .passed {
7
+ color: #006100;
8
+ background-color: #c6efce;
9
+ }
10
+
11
+ .failed {
12
+ color: #9c0006;
13
+ background-color: #ffc7ce;
14
+ }
15
+
16
+ .todo, .skip {
17
+ color: #9c6500;
18
+ background-color: #ffeb9c;
19
+ }
20
+ #summary {
21
+ font-weight: bold;
22
+ padding: 20px 50px;
23
+ }
24
+ .hidePassed ~ ul>li.passed,
25
+ .hidePassed>ul>li.passed {display: none;}
26
+ .module{
27
+ cursor: pointer;
28
+ }
29
+ .module::marker {
30
+ content: "▼ ";//⮟
31
+ }
32
+ .module.hidePassed::marker {
33
+ content: "▶ ";//➤
34
+ }
35
+ li:has(>.module)::marker {
36
+ content: "▼ ";//⮟
37
+ }
38
+ li:has(>.module.hidePassed)::marker {
39
+ content: "▶ ";//➤
40
+ }
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf8" />
5
+ <meta name="application-name" content="AVA test runner"/>
6
+ <title>AVA test runner</title>
7
+ <link rel="stylesheet" href="index.css" />
8
+ <script type="module" src="runtime.mjs"></script>
9
+ </head>
10
+ <body>
11
+ <button id="run">run</button>
12
+ <div id="tests"></div>
13
+ <div id="summary"></div>
14
+ </body>
15
+ </html>
@@ -0,0 +1,427 @@
1
+ import { testModules } from "./ava.mjs";
2
+ import { calculateSummary, summaryMessages, pluralize, stringify } from "./util.mjs";
3
+ import { isEqual } from "./eql.mjs";
4
+
5
+ let ws = new WebSocket(`ws://${location.host}`);
6
+ ws.onerror = console.error;
7
+
8
+ /*
9
+ forward console info,log,error to the server
10
+ */
11
+ for (const slot of ["log", "info", "error"]) {
12
+ const former = console[slot];
13
+
14
+ console[slot] = (...args) => {
15
+ if (ws) {
16
+ ws.send(stringify({ action: slot, data: args }));
17
+ }
18
+ former(...args);
19
+ };
20
+ }
21
+
22
+ ws.onmessage = async message => {
23
+ const data = JSON.parse(message.data);
24
+ switch (data.action) {
25
+ case "load":
26
+ {
27
+ testModules.length = 0;
28
+ let errors = 0;
29
+ for (const tm of data.data) {
30
+ tm.logs = [];
31
+ tm.tests = [];
32
+ tm.before = [];
33
+ tm.after = [];
34
+ tm.beforeEach = [];
35
+ tm.afterEach = [];
36
+ testModules.push(tm);
37
+ try {
38
+ await import(new URL(tm.url, import.meta.url));
39
+ } catch (e) {
40
+ errors++;
41
+ console.error(e.toString());
42
+ tm.logs.push(`error importing ${tm.url} ${e}`);
43
+ }
44
+ }
45
+
46
+ displayTests();
47
+ if (errors === 0) {
48
+ ws.send(stringify({ action: "ready" }));
49
+ }
50
+ }
51
+ break;
52
+ case "run": {
53
+ await runTestModules();
54
+ }
55
+ }
56
+ };
57
+
58
+ async function displayTests() {
59
+ function renderTest(t) {
60
+ return `<li class="test ${
61
+ t.passed === true
62
+ ? "passed"
63
+ : t.passed === false
64
+ ? "failed"
65
+ : t.skip
66
+ ? "skip"
67
+ : t.todo
68
+ ? "todo"
69
+ : ""
70
+ }">${t.title} <span>${
71
+ t.assertions
72
+ ? t.assertions
73
+ .filter(a => !a.passed)
74
+ .map(a => (a.title || "") + " " + (a.message || ""))
75
+ .join(" ")
76
+ : ""
77
+ }</span>${t.message ? t.message : ""}</li>`;
78
+ }
79
+
80
+ function renderModule(tm) {
81
+ const passedTestsCount = tm.tests.filter(t => t.passed).length;
82
+ const allTestsCount = tm.tests.length;
83
+ return `<li id="${tm.file}" class="module${
84
+ passedTestsCount === allTestsCount ? " passed" : ""
85
+ }">
86
+ <span class="moduleName">${tm.file}</span>
87
+ <span class="moduleSummary"> ( ${passedTestsCount} / ${allTestsCount} ${pluralize(
88
+ "test",
89
+ allTestsCount
90
+ )} passed )</span>
91
+ <div class="logs">${tm.logs.join("<br/>")}</div>
92
+ <ul>${tm.tests.map(renderTest).join("\n")}</ul>
93
+ </li>`;
94
+ }
95
+
96
+ const tests = document.getElementById("tests");
97
+ tests.innerHTML = `<ul class="wrapper"><li>
98
+ <span class="all module">ALL TESTS</span>
99
+ <ul class="allTests">${testModules.map(renderModule).join("\n")}</ul>
100
+ </li></ul>`;
101
+
102
+ tests.querySelectorAll(".module").forEach(elem => {
103
+ elem.onclick = switchPassed;
104
+ manualSwitchPassed(elem);
105
+ });
106
+ function switchPassed() {
107
+ manualSwitchPassed(this);
108
+ console.log(this);
109
+ }
110
+ function manualSwitchPassed(elem) {
111
+ elem.classList.toggle("hidePassed");
112
+ }
113
+
114
+ document.getElementById("summary").innerHTML = summaryMessages(
115
+ calculateSummary(testModules)
116
+ )
117
+ .map(m => m.html)
118
+ .join("");
119
+ }
120
+
121
+ async function execHooks(hooks, t) {
122
+ if (hooks.length > 0) {
123
+ await Promise.all(
124
+ hooks.map(async h => {
125
+ h.args[typeof h.args[0] === "string" ? 1 : 0](t);
126
+ })
127
+ );
128
+ }
129
+ }
130
+
131
+ async function runTest(parent, tm, test) {
132
+ if (!test.skip && !test.todo) {
133
+ const t = testContext(test, parent);
134
+
135
+ try {
136
+ await execHooks(tm.beforeEach, t);
137
+
138
+ if (t.ms) {
139
+ t.timer = setTimeout(() => {
140
+ t.passed = false;
141
+ t.log("Test timeout exceeded");
142
+ }, t.ms);
143
+ }
144
+
145
+ await test.body(t, ...test.args);
146
+
147
+ if (t.timer) {
148
+ clearTimeout(t.timer);
149
+ delete t.timer;
150
+ }
151
+
152
+ for (const td of t.teardowns.reverse()) {
153
+ await td();
154
+ }
155
+
156
+ await execHooks(tm.afterEach, t);
157
+
158
+ if (test.assertions.length === 0) {
159
+ test.passed = false;
160
+ test.message = "Test finished without running any assertions";
161
+ } else {
162
+ test.passed = !test.assertions.find(
163
+ a => a.passed !== true && !a.skipped
164
+ );
165
+
166
+ if (t.planned !== undefined && t.planned !== test.assertions.length) {
167
+ test.passed = false;
168
+ test.message = `Planned for ${t.planned} assertions, but got ${test.assertions.length}`;
169
+ }
170
+ }
171
+ } catch (e) {
172
+ test.passed = false;
173
+ test.message = e;
174
+ }
175
+ }
176
+ }
177
+
178
+ const runButton = document.getElementById("run");
179
+
180
+ /**
181
+ * run serial tests before all others
182
+ */
183
+ async function runTestModule(tm) {
184
+ runButton.classList.add("running");
185
+
186
+ try {
187
+ tm.logs = [];
188
+
189
+ const t = {
190
+ context: {},
191
+ log(...args) {
192
+ tm.logs.push(args);
193
+ }
194
+ };
195
+
196
+ await execHooks(tm.before, t);
197
+
198
+ for (const test of tm.tests.filter(test => test.serial)) {
199
+ await runTest(t, tm, test);
200
+ }
201
+
202
+ await Promise.all(
203
+ tm.tests.filter(test => !test.serial).map(test => runTest(t, tm, test))
204
+ );
205
+
206
+ await execHooks(tm.after, t);
207
+ } finally {
208
+ runButton.classList.remove("running");
209
+ }
210
+ }
211
+
212
+ async function runTestModules() {
213
+ await Promise.all(testModules.map(tm => runTestModule(tm)));
214
+
215
+ ws.send(stringify({ action: "result", data: testModules }));
216
+
217
+ displayTests();
218
+ }
219
+
220
+ runButton.onclick = runTestModules;
221
+
222
+ function testContext(def, parentContext) {
223
+ def.assertions = [];
224
+ def.logs = [];
225
+
226
+ function throwsExpectationHandler(e, expectation, title) {
227
+ if (expectation !== undefined) {
228
+ for (const slot of ["name", "code", "is"]) {
229
+ if (expectation[slot] !== undefined) {
230
+ if (expectation[slot] !== e[slot]) {
231
+ def.assertions.push({
232
+ passed: false,
233
+ message: `expected ${slot}=${expectation[slot]} but got ${e[slot]}`,
234
+ title
235
+ });
236
+ return;
237
+ }
238
+ }
239
+ }
240
+ if (expectation.message !== undefined) {
241
+ const slot = "message";
242
+ if (expectation.message instanceof RegExp) {
243
+ if (!expectation.message.test(e.message)) {
244
+ def.assertions.push({
245
+ passed: false,
246
+ message: `${slot} does not match ${expectation[slot]}`,
247
+ title
248
+ });
249
+ return;
250
+ }
251
+ }
252
+
253
+ if (typeof expectation.message === "string") {
254
+ if (expectation[slot] !== e[slot]) {
255
+ def.assertions.push({
256
+ passed: false,
257
+ message: `expected ${slot}=${expectation[slot]} but got ${e[slot]}`,
258
+ title
259
+ });
260
+ return;
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ def.assertions.push({ passed: true, title });
267
+ }
268
+
269
+ const assertions = {
270
+ pass(title) {
271
+ def.assertions.push({ passed: true, title });
272
+ },
273
+ fail(title) {
274
+ def.assertions.push({
275
+ passed: false,
276
+ title,
277
+ message: "Test failed via `t.fail()`"
278
+ });
279
+ },
280
+
281
+ throws(a, expectation, title) {
282
+ try {
283
+ a();
284
+ def.assertions.push({
285
+ passed: false,
286
+ title,
287
+ message: "Expected exception to be thrown"
288
+ });
289
+ } catch (e) {
290
+ throwsExpectationHandler(e, expectation, title);
291
+ }
292
+ },
293
+
294
+ async throwsAsync(a, expectation, title) {
295
+ try {
296
+ await a();
297
+ def.assertions.push({
298
+ passed: false,
299
+ title,
300
+ message: "Expected exception to be thrown"
301
+ });
302
+ } catch (e) {
303
+ throwsExpectationHandler(e, expectation, title);
304
+ }
305
+ },
306
+
307
+ notThrows(a, title) {
308
+ try {
309
+ a();
310
+ } catch (e) {
311
+ def.assertions.push({
312
+ passed: false,
313
+ title,
314
+ message: `Unexpected exception ${e}`
315
+ });
316
+ }
317
+ },
318
+
319
+ async notThrowsAsync(a, title) {
320
+ try {
321
+ await a();
322
+ } catch (e) {
323
+ def.assertions.push({
324
+ passed: false,
325
+ title,
326
+ message: `Unexpected exception ${e}`
327
+ });
328
+ }
329
+ },
330
+
331
+ deepEqual(a, b, title) {
332
+ def.assertions.push({
333
+ passed: isEqual(a, b),
334
+ message: `${a} != ${b}`,
335
+ title
336
+ });
337
+ },
338
+ notDeepEqual(a, b, title) {
339
+ def.assertions.push({
340
+ passed: !isEqual(a, b),
341
+ message: `${a} = ${b}`,
342
+ title
343
+ });
344
+ },
345
+
346
+ regex(contents, regex, message) {
347
+ def.assertions.push({
348
+ passed: contents.match(regex) ? true : false,
349
+ message: `${contents} matches ${regex}`
350
+ });
351
+ },
352
+ notRegex(contents, regex, message) {
353
+ def.assertions.push({
354
+ passed: contents.match(regex) ? false : true,
355
+ message: `${contents} matches ${regex}`
356
+ });
357
+ },
358
+
359
+ is(a, b, title) {
360
+ def.assertions.push({
361
+ passed: Object.is(a, b),
362
+ message: `${a} != ${b}`,
363
+ title
364
+ });
365
+ },
366
+ not(a, b, title) {
367
+ def.assertions.push({
368
+ passed: !Object.is(a, b),
369
+ message: `${a} = ${b}`,
370
+ title
371
+ });
372
+ },
373
+
374
+ true(value, title) {
375
+ def.assertions.push({
376
+ passed: value === true,
377
+ message: `${value} != true`,
378
+ title
379
+ });
380
+ },
381
+ truthy(value, title) {
382
+ def.assertions.push({
383
+ passed: value ? true : false,
384
+ message: `${value} is not truthy`,
385
+ title
386
+ });
387
+ },
388
+ false(value, title) {
389
+ def.assertions.push({
390
+ passed: value === false,
391
+ message: `${value} != false`,
392
+ title
393
+ });
394
+ },
395
+ falsy(value, title) {
396
+ def.assertions.push({
397
+ passed: value ? false : true,
398
+ message: `${value} is not falsy`,
399
+ title
400
+ });
401
+ }
402
+ };
403
+
404
+ Object.values(assertions).forEach(
405
+ assertion => (assertion.skip = () => def.assertions.push({ skipped: true }))
406
+ );
407
+
408
+ return {
409
+ ...assertions,
410
+ ...parentContext,
411
+ teardowns: [],
412
+ title: def.title,
413
+ log(...args) {
414
+ def.logs.push(args);
415
+ },
416
+
417
+ plan(count) {
418
+ this.planned = count;
419
+ },
420
+ teardown(fn) {
421
+ this.teardowns.push(fn);
422
+ },
423
+ timeout(ms) {
424
+ this.ms = ms;
425
+ }
426
+ };
427
+ }
@@ -0,0 +1,72 @@
1
+ export function calculateSummary(testModules) {
2
+ let failed = 0,
3
+ knownFailure = 0,
4
+ todo = 0,
5
+ skip = 0,
6
+ passed = 0;
7
+
8
+ for (const tm of testModules) {
9
+ for (const test of tm.tests) {
10
+ if (test.skip) {
11
+ skip++;
12
+ } else {
13
+ if (test.todo) {
14
+ todo++;
15
+ } else {
16
+ if (test.passed) {
17
+ passed++;
18
+ } else {
19
+ if (test.failing) {
20
+ knownFailure++;
21
+ } else {
22
+ if (test.passed === false) {
23
+ failed++;
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ return { passed, failed, knownFailure, skip, todo };
33
+ }
34
+
35
+ export function pluralize(word, number) {
36
+ return number > 1 ? word + "s" : word;
37
+ }
38
+ export function summaryMessages(summary) {
39
+ const messages = [];
40
+
41
+ function message(number, word, template, colorClass = "") {
42
+ if (number >= 1) {
43
+ const text = template
44
+ .replace(/{number}/, number)
45
+ .replace(/{word}/, pluralize(word, number));
46
+ messages.push({
47
+ colorClass,
48
+ text,
49
+ html: `<div class="${colorClass}">${text}</div>`
50
+ });
51
+ }
52
+ }
53
+
54
+ message(summary.passed, "test", "{number} {word} passed", "passed");
55
+ message(summary.failed, "test", "{number} {word} failed", "failed");
56
+ message(summary.knownFailure, "failure", "{number} known {word}", "failed");
57
+ message(summary.skip, "test", "{number} {word} skipped", "skip");
58
+ message(summary.todo, "test", "{number} {word} todo", "todo");
59
+
60
+ return messages;
61
+ }
62
+
63
+ /**
64
+ * @TODO HACK to be able to sent BigInt
65
+ */
66
+ export function stringify(...args) {
67
+ const former = BigInt.prototype.toJSON;
68
+ BigInt.prototype.toJSON = (v) => v.toString();
69
+ const string = JSON.stringify(...args);
70
+ BigInt.prototype.toJSON = former;
71
+ return string;
72
+ }
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { init, parse } from "es-module-lexer";
6
+ import { chromium, firefox, webkit } from "playwright";
7
+ import Koa from "koa";
8
+ import Static from "koa-static";
9
+ import { WebSocketServer } from "ws";
10
+ import { program, Option } from "commander";
11
+ import { calculateSummary, summaryMessages } from "./browser/util.mjs";
12
+ import { resolveImport } from "./resolver.mjs";
13
+
14
+ const utf8EncodingOptions = { encoding: "utf8" };
15
+
16
+ const { version, description } = JSON.parse(
17
+ readFileSync(
18
+ new URL("../package.json", import.meta.url).pathname,
19
+ utf8EncodingOptions
20
+ )
21
+ );
22
+
23
+ const knownBrowsers = {
24
+ chromium: chromium,
25
+ firefox: firefox,
26
+ webkit: webkit,
27
+ safari: webkit
28
+ };
29
+
30
+ const browsers = [];
31
+ let openBrowsers = [];
32
+
33
+ Object.entries(knownBrowsers).forEach(([name, browser]) => {
34
+ program.option(`--${name}`, `run tests against ${name} browser`, () =>
35
+ browsers.push(browser)
36
+ );
37
+ });
38
+
39
+ program
40
+ .description(description)
41
+ .version(version)
42
+ .addOption(
43
+ new Option("-p, --port <number>", "server port to use")
44
+ .default(8080)
45
+ .env("PORT")
46
+ )
47
+ .addOption(
48
+ new Option("-b, --browser <name>", "browser to use").env("BROWSER")
49
+ )
50
+ .option("--headless", "hide browser window", false)
51
+ .option(
52
+ "--no-keep-open",
53
+ "keep browser-ava and the page open after execution",
54
+ true
55
+ )
56
+ .argument("<tests...>")
57
+ .action(async (tests, options) => {
58
+ if (options.browser) {
59
+ browsers.push(knownBrowsers[options.browser]);
60
+ }
61
+
62
+ if (browsers.length === 0) {
63
+ console.error(
64
+ "No browsers selected use --webkit, --chromium and/or --firefox"
65
+ );
66
+ process.exit(2);
67
+ }
68
+
69
+ await init;
70
+
71
+ tests = tests.map(file => {
72
+ return { url: resolve(process.cwd(), file), file };
73
+ });
74
+
75
+ const { server, wss } = await createServer(tests, options);
76
+
77
+ async function shutdown(failed, force) {
78
+ if (!options.keepOpen || force) {
79
+ await Promise.all(openBrowsers.map(browser => browser.close()));
80
+ server.close();
81
+ process.exit(force ? 2 : failed ? 1 : 0);
82
+ }
83
+ }
84
+
85
+ let errors = 0;
86
+
87
+ wss.on("connection", ws => {
88
+ ws.on("message", async data => {
89
+ data = JSON.parse(data);
90
+ switch (data.action) {
91
+ case "info":
92
+ console.info(...data.data);
93
+ break;
94
+ case "log":
95
+ console.log(...data.data);
96
+ break;
97
+ case "error":
98
+ errors++;
99
+ console.error(...data.data);
100
+ await shutdown(undefined, true);
101
+ break;
102
+
103
+ case "ready":
104
+ ws.send(JSON.stringify({ action: "run" }));
105
+ break;
106
+ case "result":
107
+ const summary = calculateSummary(data.data);
108
+
109
+ for (const m of summaryMessages(summary)) {
110
+ console.log(m.text);
111
+ }
112
+
113
+ await shutdown(summary.failed);
114
+ }
115
+ });
116
+
117
+ ws.send(
118
+ JSON.stringify({
119
+ action: "load",
120
+ data: tests
121
+ })
122
+ );
123
+ });
124
+
125
+ openBrowsers = await Promise.all(
126
+ browsers.map(async b => {
127
+ const browser = await b.launch({ headless: options.headless });
128
+ const page = await browser.newPage();
129
+ await page.goto(`http://localhost:${options.port}/index.html`);
130
+ return browser;
131
+ })
132
+ );
133
+ });
134
+
135
+ program.parse(process.argv);
136
+
137
+
138
+ async function loadAndRewriteImports(file) {
139
+ let body = await readFile(file, utf8EncodingOptions);
140
+
141
+ const [imports] = parse(body);
142
+
143
+ let d = 0;
144
+
145
+ for (const i of imports) {
146
+ let m;
147
+
148
+ if (i.n === "ava") {
149
+ // m = new URL("browser/ava.mjs", import.meta.url).pathname;
150
+ m = "/ava.mjs";
151
+ }
152
+
153
+ if (!m) {
154
+ m = await resolveImport(i.n, resolve(process.cwd(), file));
155
+ }
156
+
157
+ if (m) {
158
+ body = body.substring(0, i.s + d) + m + body.substring(i.e + d);
159
+ d += m.length - i.n.length;
160
+ } else {
161
+ console.warn(`Unable to resolve "${i.n}" may lead to import errors`);
162
+ }
163
+ }
164
+
165
+ return body;
166
+ }
167
+
168
+ async function createServer(tests, options) {
169
+ const app = new Koa();
170
+
171
+ app.use(Static(new URL("./browser", import.meta.url).pathname));
172
+
173
+ app.on("error", console.error);
174
+
175
+ app.use(async (ctx, next) => {
176
+ const path = ctx.request.path;
177
+
178
+ if (path.endsWith(".mjs") || path.endsWith(".js")) {
179
+ ctx.response.type = "text/javascript";
180
+ ctx.body = await loadAndRewriteImports(path);
181
+ return;
182
+ }
183
+
184
+ await next();
185
+ });
186
+
187
+ const server = await new Promise((resolve, reject) => {
188
+ const server = app.listen(options.port, error => {
189
+ if (error) {
190
+ reject(error);
191
+ } else {
192
+ resolve(server);
193
+ }
194
+ });
195
+ });
196
+
197
+ const wss = new WebSocketServer({ server });
198
+
199
+ return {
200
+ server,
201
+ wss
202
+ };
203
+ }
@@ -0,0 +1,114 @@
1
+ import { join, dirname, resolve } from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ const utf8EncodingOptions = { encoding: "utf8" };
5
+
6
+ /**
7
+ * Order in which imports are searched
8
+ * @see {https://nodejs.org/dist/latest/docs/api/packages.html#imports}
9
+ */
10
+ const importsConditionOrder = ["browser", "default"];
11
+
12
+ /**
13
+ * Order in which exports are searched
14
+ * @see {https://nodejs.org/dist/latest/docs/api/packages.html#exports}
15
+ */
16
+ const exportsConditionOrder = ["browser", "import", ".", "default"];
17
+
18
+ /**
19
+ * find module inside a package
20
+ * @param {string} parts
21
+ * @param {Object} pkg package.json content
22
+ * @returns {string|undefined} module file name relative to package
23
+ */
24
+ export function resolveExports(parts, pkg) {
25
+ function matchingCondition(value) {
26
+ switch (typeof value) {
27
+ case "string":
28
+ return value;
29
+ case "object":
30
+ for (const condition of exportsConditionOrder) {
31
+ if (value[condition]) {
32
+ return value[condition];
33
+ }
34
+ }
35
+ }
36
+ }
37
+
38
+ if (parts[0] === pkg.name) {
39
+ switch (parts.length) {
40
+ case 1:
41
+ return matchingCondition(pkg.exports) || pkg.main || "index.js";
42
+ default:
43
+ return matchingCondition(pkg.exports["./" + parts.slice(1).join("/")]);
44
+ }
45
+ }
46
+ }
47
+
48
+ export function resolveImports(name, pkg) {
49
+ if (name.match(/^#/)) {
50
+ const importSlot = pkg.imports[name];
51
+ if (importSlot) {
52
+ for (const condition of importsConditionOrder) {
53
+ if (importSlot[condition]) {
54
+ return importSlot[condition];
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ async function loadPackage(path) {
62
+ try {
63
+ return JSON.parse(
64
+ await readFile(join(path, "package.json"), utf8EncodingOptions)
65
+ );
66
+ } catch (e) {
67
+ if (e.code !== "ENOTDIR" && e.code !== "ENOENT") {
68
+ throw e;
69
+ }
70
+ }
71
+ }
72
+
73
+ async function findPackage(path) {
74
+ while (path.length) {
75
+ const pkg = await loadPackage(path);
76
+ if (pkg) {
77
+ return {
78
+ path,
79
+ pkg
80
+ };
81
+ }
82
+ path = dirname(path);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Maps import url from node to browser view.
88
+ * @param {string} name module to resolve
89
+ * @param {string} file where to start resolving (base)
90
+ * @returns {Promise<string>} resolved import url
91
+ */
92
+ export async function resolveImport(name, file) {
93
+ if (name.match(/^[\/\.]/)) {
94
+ return resolve(dirname(file), name);
95
+ }
96
+ let { pkg, path } = await findPackage(file);
97
+
98
+ const parts = name.split(/\//);
99
+
100
+ const e = resolveExports(parts, pkg) || resolveImports(name, pkg);
101
+
102
+ if (e) {
103
+ return join(path, e);
104
+ }
105
+
106
+ while (path.length > 1) {
107
+ const p = join(path, "node_modules", parts[0]);
108
+ pkg = await loadPackage(p);
109
+ if (pkg) {
110
+ return join(p, resolveExports(parts, pkg));
111
+ }
112
+ path = dirname(dirname(path));
113
+ }
114
+ }