kryptheon 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/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # kryptheon
2
+
3
+ Record what you do in your app, and get told in plain English when it breaks.
4
+
5
+ No test code to write, no config to edit.
6
+
7
+ ## Use it
8
+
9
+ Install it into your project first:
10
+
11
+ ```
12
+ npm i -D kryptheon
13
+ ```
14
+
15
+ This step is not optional. Every recording kryptheon writes starts with
16
+ `import { test, expect } from 'kryptheon/kryptheon-fixture'`, and your tests can
17
+ only find that import if kryptheon lives in your project's `node_modules`.
18
+ Running `npx kryptheon` without installing will record fine but fail to check.
19
+
20
+ Then record:
21
+
22
+ ```
23
+ npx kryptheon record https://your-app.example.com
24
+ ```
25
+
26
+ A browser opens. Use your app the way a customer would — click, type, sign in.
27
+ Close the browser when you are done, and the recording is saved as a test.
28
+
29
+ ```
30
+ npx kryptheon check
31
+ ```
32
+
33
+ Runs everything you have recorded and reports what happened:
34
+
35
+ ```
36
+ OK Sign in (4.1s)
37
+
38
+ X Checkout
39
+ Could not find the button "Place order" on the page.
40
+ This was working on 12 Mar at 9:14 AM.
41
+ What to check: your last change may have renamed, hidden, or removed it.
42
+ Where to look:
43
+ - Browser was on: https://your-app.example.com/cart
44
+ - POST /api/orders returned 500
45
+ the server crashed or is unavailable - the error is in backend code, not the page
46
+ ```
47
+
48
+ Every failure also ends with a short summary you can paste straight into an AI
49
+ coding tool.
50
+
51
+ ## Automatic checks
52
+
53
+ The first time a test passes, kryptheon remembers where the browser ended up
54
+ and what the page was called. If either changes later, the test fails and tells
55
+ you what changed — so a recording catches regressions without you writing a
56
+ single assertion.
57
+
58
+ When a change is intentional, accept it for that one test:
59
+
60
+ ```
61
+ npx kryptheon accept "Checkout"
62
+ ```
63
+
64
+ `npx kryptheon accept` on its own lists what has been saved.
65
+
66
+ ## Files it creates in your folder
67
+
68
+ | File | What it is |
69
+ | --- | --- |
70
+ | `tests/` | your recordings |
71
+ | `kryptheon-baselines.json` | the remembered result for each test |
72
+ | `kryptheon-history.jsonl` | one line per run, used for "this was working on …" |
73
+
74
+ `.env` in the same folder is loaded automatically, so a recording can sign in
75
+ without the password living in the test file.
76
+
77
+ Worth adding to `.gitignore`: `.env`, `kryptheon-baselines.json`,
78
+ `kryptheon-history.jsonl`, `test-results/`.
79
+
80
+ ## Requirements
81
+
82
+ Node 20.6 or later. The first run downloads a browser (about 200MB, once).
83
+
84
+ ## Licence
85
+
86
+ MIT
@@ -0,0 +1,554 @@
1
+ #!/usr/bin/env node
2
+ // The kryptheon CLI. A thin wrapper so nobody has to remember Playwright's
3
+ // command line or edit a config file. Everything here shells out to the
4
+ // Playwright binary that ships with this package.
5
+ //
6
+ // Two roots matter, and they are not the same once this is installed:
7
+ // PACKAGE_DIR - the tool's own files (fixture, reporter, config, this file)
8
+ // USER_DIR - the folder the command was run in, which owns tests/,
9
+ // kryptheon-baselines.json, kryptheon-history.jsonl and .env
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { spawnSync } = require('child_process');
14
+
15
+ const PACKAGE_DIR = path.join(__dirname, '..');
16
+ const USER_DIR = process.cwd();
17
+ const CONFIG = path.join(PACKAGE_DIR, 'playwright.config.js');
18
+ const TESTS_DIR = path.join(USER_DIR, 'tests');
19
+
20
+ const MIN_NODE = [20, 6, 0];
21
+
22
+ function nodeIsTooOld(version) {
23
+ const parts = String(version || process.versions.node).split('.').map(Number);
24
+ for (let i = 0; i < MIN_NODE.length; i++) {
25
+ if ((parts[i] || 0) > MIN_NODE[i]) return false;
26
+ if ((parts[i] || 0) < MIN_NODE[i]) return true;
27
+ }
28
+ return false;
29
+ }
30
+
31
+ function usage() {
32
+ console.log('');
33
+ console.log(' kryptheon - record and check your app');
34
+ console.log('');
35
+ console.log(' kryptheon record <url> open your app and record what you do as a test');
36
+ console.log(' kryptheon check run every recorded test and report in plain language');
37
+ console.log(' kryptheon accept <name> agree that one test\'s new result is the correct one');
38
+ console.log('');
39
+ }
40
+
41
+ // Resolve Playwright's own CLI and run it on this Node binary, rather than
42
+ // relying on a `playwright` executable being on PATH.
43
+ // The package's "exports" map exposes "./cli" (no .js), so that specifier is
44
+ // the one that resolves; the package.json route is the fallback.
45
+ function findPlaywrightCli() {
46
+ try {
47
+ return require.resolve('@playwright/test/cli', { paths: [PACKAGE_DIR] });
48
+ } catch (err) {
49
+ /* fall through */
50
+ }
51
+ try {
52
+ const pkg = require.resolve('@playwright/test/package.json', { paths: [PACKAGE_DIR] });
53
+ return path.join(path.dirname(pkg), 'cli.js');
54
+ } catch (err) {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ // Always runs with the user's folder as the working directory, so tests,
60
+ // baselines and .env are found where they actually live.
61
+ function runPlaywright(args) {
62
+ const cli = findPlaywrightCli();
63
+ if (!cli) {
64
+ console.error('');
65
+ console.error(' The testing engine is missing from this install.');
66
+ console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
67
+ console.error('');
68
+ return 1;
69
+ }
70
+
71
+ const result = spawnSync(process.execPath, [cli].concat(args), {
72
+ cwd: USER_DIR,
73
+ stdio: 'inherit',
74
+ });
75
+
76
+ if (result.error) {
77
+ console.error('');
78
+ console.error(' Could not start the testing engine: ' + result.error.message);
79
+ console.error('');
80
+ return 1;
81
+ }
82
+ return result.status === null ? 1 : result.status;
83
+ }
84
+
85
+ // Chromium is a separate download from the npm package, so the first run on a
86
+ // new machine has to fetch it.
87
+ function ensureBrowser() {
88
+ let executable = null;
89
+ try {
90
+ executable = require('@playwright/test').chromium.executablePath();
91
+ } catch (err) {
92
+ return true; // cannot tell - let Playwright speak for itself
93
+ }
94
+ if (executable && fs.existsSync(executable)) return true;
95
+
96
+ console.log('');
97
+ console.log(' Downloading a browser to run your app in.');
98
+ console.log(' This is about 200MB and only happens once.');
99
+ console.log('');
100
+ const status = runPlaywright(['install', 'chromium']);
101
+ if (status !== 0) {
102
+ console.error('');
103
+ console.error(' The browser download did not finish.');
104
+ console.error(' Check your internet connection and try again.');
105
+ console.error('');
106
+ return false;
107
+ }
108
+ return true;
109
+ }
110
+
111
+ function listSpecFiles() {
112
+ if (!fs.existsSync(TESTS_DIR)) return [];
113
+ let entries = [];
114
+ try {
115
+ entries = fs.readdirSync(TESTS_DIR, { recursive: true });
116
+ } catch (err) {
117
+ try {
118
+ entries = fs.readdirSync(TESTS_DIR);
119
+ } catch (err2) {
120
+ return [];
121
+ }
122
+ }
123
+ return entries
124
+ .map(String)
125
+ .filter((name) => /\.(spec|test)\.(c|m)?[jt]sx?$/.test(name));
126
+ }
127
+
128
+ function timestampName() {
129
+ const d = new Date();
130
+ const pad = (n) => String(n).padStart(2, '0');
131
+ return (
132
+ 'recorded-' +
133
+ d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) +
134
+ '-' + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) +
135
+ '.spec.js'
136
+ );
137
+ }
138
+
139
+ // Codegen imports from '@playwright/test'. Point the new file at the shared
140
+ // fixture instead, so a recorded test gets the same failure diagnostics
141
+ // (failed requests, console errors) as every other spec. Imported by package
142
+ // name, because the user's tests folder is not next to the installed package.
143
+ function pointAtFixture(relativeFile) {
144
+ const full = path.join(USER_DIR, relativeFile);
145
+ let src;
146
+ try {
147
+ src = fs.readFileSync(full, 'utf8');
148
+ } catch (err) {
149
+ return false;
150
+ }
151
+ const updated = src
152
+ .replace(/from (['"])@playwright\/test\1/, "from 'kryptheon/kryptheon-fixture'")
153
+ .replace(/require\((['"])@playwright\/test\1\)/, "require('kryptheon/kryptheon-fixture')");
154
+ if (updated === src) return false;
155
+ try {
156
+ fs.writeFileSync(full, updated, 'utf8');
157
+ return true;
158
+ } catch (err) {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ // "about-us.html" -> "About Us"; "/" -> null
164
+ function humanisePath(pathname) {
165
+ const last = String(pathname || '')
166
+ .split('?')[0]
167
+ .split('#')[0]
168
+ .split('/')
169
+ .filter(Boolean)
170
+ .pop();
171
+ if (!last) return null;
172
+ const words = last
173
+ .replace(/\.[a-z0-9]+$/i, '')
174
+ .replace(/[-_+]+/g, ' ')
175
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
176
+ .trim();
177
+ if (!words) return null;
178
+ return words
179
+ .split(/\s+/)
180
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
181
+ .join(' ');
182
+ }
183
+
184
+ // Titles are usually "Site | Page". The page half is the useful one, and a
185
+ // very long tail is prose rather than a name, so it is rejected.
186
+ function nameFromTitle(title) {
187
+ const parts = String(title || '')
188
+ .split(/[|–—•:]|\s-\s/)
189
+ .map((p) => p.replace(/\s+/g, ' ').trim())
190
+ .filter(Boolean);
191
+ if (!parts.length) return null;
192
+ const tail = parts[parts.length - 1];
193
+ if (tail && tail.split(' ').length <= 4) return tail;
194
+ return null;
195
+ }
196
+
197
+ // Reads the title of a page over plain HTTP. Best effort: the name falls back
198
+ // to the recorded path if this cannot be reached.
199
+ async function fetchTitle(url) {
200
+ try {
201
+ const controller = new AbortController();
202
+ const timer = setTimeout(() => controller.abort(), 5000);
203
+ const res = await fetch(url, { signal: controller.signal });
204
+ clearTimeout(timer);
205
+ if (!res.ok) return null;
206
+ const html = await res.text();
207
+ const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
208
+ return m ? m[1].replace(/\s+/g, ' ').trim() : null;
209
+ } catch (err) {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ function lastGoto(source) {
215
+ const gotos = [...String(source).matchAll(/page\.goto\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
216
+ return gotos.length ? gotos[gotos.length - 1] : null;
217
+ }
218
+
219
+ // Codegen calls every recording "test". Give it a name taken from what was
220
+ // actually recorded: the last page navigated to, else the last thing clicked.
221
+ function deriveTestName(source) {
222
+ const gotos = [...String(source).matchAll(/page\.goto\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
223
+ for (let i = gotos.length - 1; i >= 0; i--) {
224
+ let pathname = gotos[i];
225
+ try {
226
+ pathname = new URL(gotos[i]).pathname;
227
+ } catch (e) {
228
+ /* already a path */
229
+ }
230
+ const name = humanisePath(pathname);
231
+ if (name) return name;
232
+ }
233
+
234
+ const names = [...String(source).matchAll(/name:\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
235
+ if (names.length) return names[names.length - 1].trim();
236
+
237
+ const hosts = [...String(source).matchAll(/page\.goto\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
238
+ if (hosts.length) {
239
+ try {
240
+ const host = new URL(hosts[hosts.length - 1]).hostname.replace(/^www\./, '');
241
+ const label = humanisePath('/' + host.split('.')[0]);
242
+ if (label) return label;
243
+ } catch (e) {
244
+ /* fall through */
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+
250
+ function slugify(name) {
251
+ return String(name)
252
+ .toLowerCase()
253
+ .replace(/[^a-z0-9]+/g, '-')
254
+ .replace(/^-+|-+$/g, '') || 'recording';
255
+ }
256
+
257
+ // Renames the test inside the file, and the file to match. Only ever touches
258
+ // the file codegen just wrote.
259
+ async function nameRecording(relativeFile) {
260
+ const full = path.join(USER_DIR, relativeFile);
261
+ let src;
262
+ try {
263
+ src = fs.readFileSync(full, 'utf8');
264
+ } catch (err) {
265
+ return relativeFile;
266
+ }
267
+ // Only rename codegen's placeholder, never a name someone chose.
268
+ if (!/\btest\(\s*(['"])test\1/.test(src)) return relativeFile;
269
+
270
+ // The page title is the friendliest source; the recorded path is the
271
+ // fallback when the site cannot be reached.
272
+ const visited = lastGoto(src);
273
+ const name = (visited ? nameFromTitle(await fetchTitle(visited)) : null) || deriveTestName(src);
274
+ if (!name) return relativeFile;
275
+
276
+ const safeName = name.replace(/'/g, "\\'");
277
+ const updated = src.replace(/\btest\(\s*(['"])test\1/, "test('" + safeName + "'");
278
+
279
+ let target = path.join('tests', slugify(name) + '.spec.js');
280
+ let attempt = 1;
281
+ while (fs.existsSync(path.join(USER_DIR, target)) && path.join(USER_DIR, target) !== full) {
282
+ attempt += 1;
283
+ target = path.join('tests', slugify(name) + '-' + attempt + '.spec.js');
284
+ }
285
+
286
+ try {
287
+ fs.writeFileSync(full, updated, 'utf8');
288
+ if (path.join(USER_DIR, target) !== full) {
289
+ fs.renameSync(full, path.join(USER_DIR, target));
290
+ return target;
291
+ }
292
+ } catch (err) {
293
+ return relativeFile;
294
+ }
295
+ return target;
296
+ }
297
+
298
+ async function record(url) {
299
+ if (!url) {
300
+ console.error('');
301
+ console.error(' Which address should I open?');
302
+ console.error('');
303
+ console.error(' Add the web address of your app, for example:');
304
+ console.error(' kryptheon record https://www.example.com');
305
+ console.error('');
306
+ return 1;
307
+ }
308
+ if (!ensureBrowser()) return 1;
309
+
310
+ try {
311
+ fs.mkdirSync(TESTS_DIR, { recursive: true });
312
+ } catch (err) {
313
+ /* codegen will report if it cannot write */
314
+ }
315
+
316
+ let outFile = path.join('tests', timestampName());
317
+
318
+ console.log('');
319
+ console.log(' Opening ' + url + ' in a browser.');
320
+ console.log('');
321
+ console.log(' Use your app normally - click, type, sign in, whatever you want');
322
+ console.log(' covered. Every step is recorded as you go.');
323
+ console.log('');
324
+ console.log(' When you are done, close the browser window to save the test.');
325
+ console.log('');
326
+
327
+ const status = runPlaywright(['codegen', '--target', 'playwright-test', '-o', outFile, url]);
328
+
329
+ // Keyed off the file rather than the exit code: codegen still writes the
330
+ // recording when the window is force-closed, and that file should be wired
331
+ // up the same way.
332
+ if (fs.existsSync(path.join(USER_DIR, outFile))) {
333
+ pointAtFixture(outFile);
334
+ outFile = await nameRecording(outFile);
335
+ console.log('');
336
+ console.log(' Saved to ' + outFile);
337
+ console.log(' Run it any time with: kryptheon check');
338
+ console.log('');
339
+ }
340
+ return status;
341
+ }
342
+
343
+ // Recordings import the fixture by package name, which only resolves if
344
+ // kryptheon is in the user's own node_modules. Running through a bare `npx`
345
+ // puts the package somewhere the tests cannot see.
346
+ // Deliberately a filesystem walk, not require.resolve: this file lives inside
347
+ // the kryptheon package, and a package with a name and an "exports" map can
348
+ // always resolve itself by name, so require.resolve would answer "yes" even
349
+ // when the user's tests have no way to find it.
350
+ function fixtureResolvesForUser() {
351
+ let dir = USER_DIR;
352
+ for (;;) {
353
+ if (fs.existsSync(path.join(dir, 'node_modules', 'kryptheon', 'kryptheon-fixture.js'))) {
354
+ return true;
355
+ }
356
+ const parent = path.dirname(dir);
357
+ if (parent === dir) return false;
358
+ dir = parent;
359
+ }
360
+ }
361
+
362
+ // Only a problem if a recording actually asks for it: specs written against a
363
+ // relative path (as in this repo) resolve on their own.
364
+ function specsNeedThePackage() {
365
+ return listSpecFiles().some(function (name) {
366
+ try {
367
+ return fs.readFileSync(path.join(TESTS_DIR, name), 'utf8').indexOf('kryptheon/kryptheon-fixture') !== -1;
368
+ } catch (err) {
369
+ return false;
370
+ }
371
+ });
372
+ }
373
+
374
+ function check() {
375
+ if (!listSpecFiles().length) {
376
+ console.log('');
377
+ console.log(' There is nothing to check yet.');
378
+ console.log('');
379
+ console.log(' Record something first, for example:');
380
+ console.log(' kryptheon record https://www.example.com');
381
+ console.log('');
382
+ console.log(' Use your app in the browser that opens, then close it.');
383
+ console.log('');
384
+ return 1;
385
+ }
386
+ if (!fixtureResolvesForUser() && specsNeedThePackage()) {
387
+ console.log('');
388
+ console.log(' Your recordings cannot find kryptheon.');
389
+ console.log('');
390
+ console.log(' They are saved in this folder, but kryptheon itself is installed');
391
+ console.log(' somewhere else, so they have nothing to load. Add it here:');
392
+ console.log('');
393
+ console.log(' npm i -D kryptheon');
394
+ console.log('');
395
+ console.log(' Then run "kryptheon check" again.');
396
+ console.log('');
397
+ return 1;
398
+ }
399
+ if (!ensureBrowser()) return 1;
400
+ return runPlaywright(['test', '--config', CONFIG]);
401
+ }
402
+
403
+ // --- accept -----------------------------------------------------------------
404
+
405
+ function baselineApi() {
406
+ try {
407
+ return require(path.join(PACKAGE_DIR, 'kryptheon-fixture.js'));
408
+ } catch (err) {
409
+ console.error('');
410
+ console.error(' Could not read the saved results: ' + err.message);
411
+ console.error('');
412
+ return null;
413
+ }
414
+ }
415
+
416
+ // A key looks like "tests/login.spec.js :: Login".
417
+ function titleOf(key) {
418
+ const at = key.indexOf(' :: ');
419
+ return at === -1 ? key : key.slice(at + 4);
420
+ }
421
+
422
+ function listBaselines(api) {
423
+ const all = api.readBaselines(api.BASELINE_FILE);
424
+ const keys = Object.keys(all);
425
+
426
+ console.log('');
427
+ if (!keys.length) {
428
+ console.log(' No tests have a saved result yet.');
429
+ console.log(' Run "kryptheon check" once and they will be saved automatically.');
430
+ console.log('');
431
+ return 0;
432
+ }
433
+
434
+ console.log(' Tests with a saved result:');
435
+ console.log('');
436
+ for (const key of keys) {
437
+ const waiting = all[key] && all[key].pending ? ' (has a new result waiting)' : '';
438
+ console.log(' ' + titleOf(key) + waiting);
439
+ console.log(' from ' + key.split(' :: ')[0]);
440
+ }
441
+ console.log('');
442
+ console.log(' To agree that a new result is correct:');
443
+ console.log(' kryptheon accept "<name>"');
444
+ console.log('');
445
+ return 0;
446
+ }
447
+
448
+ function accept(name) {
449
+ const api = baselineApi();
450
+ if (!api) return 1;
451
+ if (!name) return listBaselines(api);
452
+
453
+ const all = api.readBaselines(api.BASELINE_FILE);
454
+ const wanted = String(name).trim().toLowerCase();
455
+ const matches = Object.keys(all).filter(function (key) {
456
+ return titleOf(key).toLowerCase() === wanted || key.toLowerCase() === wanted;
457
+ });
458
+
459
+ if (!matches.length) {
460
+ console.error('');
461
+ console.error(' No saved result for a test called "' + name + '".');
462
+ console.error(' Run "kryptheon accept" on its own to see the names.');
463
+ console.error('');
464
+ return 1;
465
+ }
466
+ if (matches.length > 1) {
467
+ console.error('');
468
+ console.error(' More than one test is called "' + name + '":');
469
+ matches.forEach((k) => console.error(' ' + k));
470
+ console.error('');
471
+ console.error(' Pass the full line above instead.');
472
+ console.error('');
473
+ return 1;
474
+ }
475
+
476
+ const key = matches[0];
477
+ const result = api.acceptBaseline(api.BASELINE_FILE, key);
478
+
479
+ if (result.ok) {
480
+ console.log('');
481
+ console.log(' Updated the saved result for "' + titleOf(key) + '".');
482
+ console.log(' Address: ' + result.entry.url);
483
+ console.log(' Title: ' + result.entry.title);
484
+ console.log('');
485
+ console.log(' Every other test was left alone.');
486
+ console.log('');
487
+ return 0;
488
+ }
489
+
490
+ console.error('');
491
+ if (result.reason === 'nothing-pending') {
492
+ console.error(' "' + titleOf(key) + '" has no new result waiting.');
493
+ console.error(' Nothing to accept - it last matched its saved result.');
494
+ } else if (result.reason === 'no-entry') {
495
+ console.error(' "' + titleOf(key) + '" has no saved result yet.');
496
+ } else {
497
+ console.error(' Could not update the saved result for "' + titleOf(key) + '".');
498
+ }
499
+ console.error('');
500
+ return 1;
501
+ }
502
+
503
+ const [command, ...rest] = process.argv.slice(2);
504
+
505
+ async function main() {
506
+ if (nodeIsTooOld()) {
507
+ console.error('');
508
+ console.error(' This tool needs a newer version of Node.');
509
+ console.error(' You have: ' + process.versions.node);
510
+ console.error(' You need: 20.6.0 or later');
511
+ console.error('');
512
+ console.error(' Download the latest from https://nodejs.org and try again.');
513
+ console.error('');
514
+ process.exit(1);
515
+ }
516
+
517
+ switch (command) {
518
+ case 'record':
519
+ process.exit(await record(rest[0]));
520
+ break;
521
+ case 'check':
522
+ process.exit(check());
523
+ break;
524
+ case 'accept':
525
+ process.exit(accept(rest.join(' ').trim()));
526
+ break;
527
+ case undefined:
528
+ case '-h':
529
+ case '--help':
530
+ case 'help':
531
+ usage();
532
+ process.exit(0);
533
+ break;
534
+ default:
535
+ console.error('');
536
+ console.error(' Unknown command: ' + command);
537
+ usage();
538
+ process.exit(1);
539
+ }
540
+ }
541
+
542
+ // Exported so the naming logic can be checked without opening a browser.
543
+ module.exports = {
544
+ humanisePath: humanisePath,
545
+ nameFromTitle: nameFromTitle,
546
+ deriveTestName: deriveTestName,
547
+ slugify: slugify,
548
+ nameRecording: nameRecording,
549
+ pointAtFixture: pointAtFixture,
550
+ listSpecFiles: listSpecFiles,
551
+ nodeIsTooOld: nodeIsTooOld,
552
+ };
553
+
554
+ if (require.main === module) main();