kryptheon 0.1.0 → 0.1.1
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/bin/kryptheon.js +192 -14
- package/package.json +1 -1
package/bin/kryptheon.js
CHANGED
|
@@ -58,28 +58,36 @@ function findPlaywrightCli() {
|
|
|
58
58
|
|
|
59
59
|
// Always runs with the user's folder as the working directory, so tests,
|
|
60
60
|
// baselines and .env are found where they actually live.
|
|
61
|
-
|
|
61
|
+
// Returns { status, stderr }. With quietErrors the child's stderr is captured
|
|
62
|
+
// rather than inherited, so a Node or Playwright stack trace never reaches the
|
|
63
|
+
// person running the command.
|
|
64
|
+
function runPlaywright(args, options) {
|
|
65
|
+
const quiet = !!(options && options.quietErrors);
|
|
62
66
|
const cli = findPlaywrightCli();
|
|
63
67
|
if (!cli) {
|
|
64
68
|
console.error('');
|
|
65
69
|
console.error(' The testing engine is missing from this install.');
|
|
66
70
|
console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
|
|
67
71
|
console.error('');
|
|
68
|
-
return 1;
|
|
72
|
+
return { status: 1, stderr: '' };
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
const result = spawnSync(process.execPath, [cli].concat(args), {
|
|
72
76
|
cwd: USER_DIR,
|
|
73
|
-
stdio: 'inherit',
|
|
77
|
+
stdio: quiet ? ['inherit', 'inherit', 'pipe'] : 'inherit',
|
|
78
|
+
encoding: 'utf8',
|
|
74
79
|
});
|
|
75
80
|
|
|
76
81
|
if (result.error) {
|
|
77
82
|
console.error('');
|
|
78
83
|
console.error(' Could not start the testing engine: ' + result.error.message);
|
|
79
84
|
console.error('');
|
|
80
|
-
return 1;
|
|
85
|
+
return { status: 1, stderr: String(result.stderr || '') };
|
|
81
86
|
}
|
|
82
|
-
return
|
|
87
|
+
return {
|
|
88
|
+
status: result.status === null ? 1 : result.status,
|
|
89
|
+
stderr: String(result.stderr || ''),
|
|
90
|
+
};
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
// Chromium is a separate download from the npm package, so the first run on a
|
|
@@ -97,7 +105,7 @@ function ensureBrowser() {
|
|
|
97
105
|
console.log(' Downloading a browser to run your app in.');
|
|
98
106
|
console.log(' This is about 200MB and only happens once.');
|
|
99
107
|
console.log('');
|
|
100
|
-
const status = runPlaywright(['install', 'chromium']);
|
|
108
|
+
const status = runPlaywright(['install', 'chromium']).status;
|
|
101
109
|
if (status !== 0) {
|
|
102
110
|
console.error('');
|
|
103
111
|
console.error(' The browser download did not finish.');
|
|
@@ -125,6 +133,124 @@ function listSpecFiles() {
|
|
|
125
133
|
.filter((name) => /\.(spec|test)\.(c|m)?[jt]sx?$/.test(name));
|
|
126
134
|
}
|
|
127
135
|
|
|
136
|
+
// --- is the address actually there? ----------------------------------------
|
|
137
|
+
|
|
138
|
+
// "localhost:3000" is what people type; give it a scheme before using it.
|
|
139
|
+
function normaliseRecordUrl(url) {
|
|
140
|
+
const s = String(url == null ? '' : url).trim();
|
|
141
|
+
if (!s) return s;
|
|
142
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s)) return s;
|
|
143
|
+
return 'http://' + s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isLocalAddress(url) {
|
|
147
|
+
let host;
|
|
148
|
+
try {
|
|
149
|
+
host = new URL(url).hostname.toLowerCase();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return (
|
|
154
|
+
host === 'localhost' ||
|
|
155
|
+
host === '127.0.0.1' ||
|
|
156
|
+
host === '0.0.0.0' ||
|
|
157
|
+
host === '::1' ||
|
|
158
|
+
host === '[::1]' ||
|
|
159
|
+
host.endsWith('.local') ||
|
|
160
|
+
host.endsWith('.localhost')
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Turns a fetch failure into one of a few plain kinds. Split out from the
|
|
165
|
+
// network call so it can be checked without a network.
|
|
166
|
+
function classifyFetchError(err) {
|
|
167
|
+
const code = (err && err.cause && err.cause.code) || (err && err.code) || null;
|
|
168
|
+
if (code === 'ECONNREFUSED') return 'refused';
|
|
169
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'unknown-address';
|
|
170
|
+
if (err && err.name === 'AbortError') return 'timeout';
|
|
171
|
+
if (code === 'ETIMEDOUT' || code === 'ECONNRESET') return 'timeout';
|
|
172
|
+
// A certificate complaint means something IS answering there. Let the
|
|
173
|
+
// browser deal with it rather than blocking the recording.
|
|
174
|
+
if (code && /CERT|SSL|SELF_SIGNED|VERIFY/i.test(String(code))) return 'reachable';
|
|
175
|
+
return 'other';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function reachability(url, timeoutMs) {
|
|
179
|
+
try {
|
|
180
|
+
const controller = new AbortController();
|
|
181
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs || 8000);
|
|
182
|
+
await fetch(url, { signal: controller.signal, redirect: 'manual' });
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
return { ok: true, kind: 'reachable' };
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const kind = classifyFetchError(err);
|
|
187
|
+
return kind === 'reachable' ? { ok: true, kind: kind } : { ok: false, kind: kind };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// One place that explains an address not answering, used both before codegen
|
|
192
|
+
// starts and if codegen itself reports the same thing.
|
|
193
|
+
function explainUnreachable(url, kind) {
|
|
194
|
+
const local = isLocalAddress(url);
|
|
195
|
+
console.error('');
|
|
196
|
+
console.error(' Nothing answered at ' + url);
|
|
197
|
+
console.error('');
|
|
198
|
+
|
|
199
|
+
if (kind === 'unknown-address') {
|
|
200
|
+
console.error(' That web address could not be found.');
|
|
201
|
+
console.error(' Check the spelling, and that you are connected to the internet.');
|
|
202
|
+
} else if (kind === 'timeout') {
|
|
203
|
+
console.error(' The address did not answer in time.');
|
|
204
|
+
console.error(' It may be slow, or blocked by a firewall or VPN.');
|
|
205
|
+
} else if (local) {
|
|
206
|
+
console.error(' Your app does not look like it is running.');
|
|
207
|
+
console.error('');
|
|
208
|
+
console.error(' For an app on your own machine, start it first - usually:');
|
|
209
|
+
console.error(' npm run dev');
|
|
210
|
+
console.error('');
|
|
211
|
+
console.error(' Then check the address and port match what it printed.');
|
|
212
|
+
} else {
|
|
213
|
+
console.error(' Nothing is listening at that address.');
|
|
214
|
+
console.error(' Check the address, including the port number, and that the site is up.');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
console.error('');
|
|
218
|
+
console.error(' Nothing was recorded.');
|
|
219
|
+
console.error('');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Codegen writes Node/Playwright traces to stderr. We never show those; we
|
|
223
|
+
// pick out the part that means something and say it in plain words.
|
|
224
|
+
function explainCodegenFailure(url, stderr) {
|
|
225
|
+
const text = String(stderr || '');
|
|
226
|
+
if (/ERR_CONNECTION_REFUSED/.test(text)) return explainUnreachable(url, 'refused');
|
|
227
|
+
if (/ERR_NAME_NOT_RESOLVED/.test(text)) return explainUnreachable(url, 'unknown-address');
|
|
228
|
+
if (/ERR_CONNECTION_TIMED_OUT|ERR_TIMED_OUT/.test(text)) return explainUnreachable(url, 'timeout');
|
|
229
|
+
if (/ERR_INTERNET_DISCONNECTED/.test(text)) {
|
|
230
|
+
console.error('');
|
|
231
|
+
console.error(' There is no internet connection.');
|
|
232
|
+
console.error('');
|
|
233
|
+
console.error(' Nothing was recorded.');
|
|
234
|
+
console.error('');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
console.error('');
|
|
238
|
+
console.error(' The browser closed before anything could be recorded.');
|
|
239
|
+
console.error('');
|
|
240
|
+
console.error(' Check that ' + url + ' opens normally in your own browser,');
|
|
241
|
+
console.error(' then try again.');
|
|
242
|
+
console.error('');
|
|
243
|
+
console.error(' Nothing was recorded.');
|
|
244
|
+
console.error('');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// A recording is only worth keeping if the browser actually did something.
|
|
248
|
+
// An abandoned or failed run leaves a test body with no page calls at all.
|
|
249
|
+
function countRecordedActions(source) {
|
|
250
|
+
const found = String(source || '').match(/\bpage\s*\.\s*[A-Za-z_$][\w$]*\s*\(/g);
|
|
251
|
+
return found ? found.length : 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
128
254
|
function timestampName() {
|
|
129
255
|
const d = new Date();
|
|
130
256
|
const pad = (n) => String(n).padStart(2, '0');
|
|
@@ -305,8 +431,18 @@ async function record(url) {
|
|
|
305
431
|
console.error('');
|
|
306
432
|
return 1;
|
|
307
433
|
}
|
|
434
|
+
const target = normaliseRecordUrl(url);
|
|
435
|
+
|
|
436
|
+
// Fail early and kindly rather than letting codegen throw a stack trace.
|
|
437
|
+
const reach = await reachability(target);
|
|
438
|
+
if (!reach.ok) {
|
|
439
|
+
explainUnreachable(target, reach.kind);
|
|
440
|
+
return 1;
|
|
441
|
+
}
|
|
442
|
+
|
|
308
443
|
if (!ensureBrowser()) return 1;
|
|
309
444
|
|
|
445
|
+
const hadTestsDir = fs.existsSync(TESTS_DIR);
|
|
310
446
|
try {
|
|
311
447
|
fs.mkdirSync(TESTS_DIR, { recursive: true });
|
|
312
448
|
} catch (err) {
|
|
@@ -316,7 +452,7 @@ async function record(url) {
|
|
|
316
452
|
let outFile = path.join('tests', timestampName());
|
|
317
453
|
|
|
318
454
|
console.log('');
|
|
319
|
-
console.log(' Opening ' +
|
|
455
|
+
console.log(' Opening ' + target + ' in a browser.');
|
|
320
456
|
console.log('');
|
|
321
457
|
console.log(' Use your app normally - click, type, sign in, whatever you want');
|
|
322
458
|
console.log(' covered. Every step is recorded as you go.');
|
|
@@ -324,20 +460,58 @@ async function record(url) {
|
|
|
324
460
|
console.log(' When you are done, close the browser window to save the test.');
|
|
325
461
|
console.log('');
|
|
326
462
|
|
|
327
|
-
const
|
|
463
|
+
const run = runPlaywright(['codegen', '--target', 'playwright-test', '-o', outFile, target], {
|
|
464
|
+
quietErrors: true,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const specPath = path.join(USER_DIR, outFile);
|
|
468
|
+
let source = null;
|
|
469
|
+
try {
|
|
470
|
+
source = fs.readFileSync(specPath, 'utf8');
|
|
471
|
+
} catch (err) {
|
|
472
|
+
source = null; // codegen wrote nothing at all
|
|
473
|
+
}
|
|
328
474
|
|
|
329
|
-
// Keyed off the file rather than the exit code: codegen still
|
|
330
|
-
// recording when the window is force-closed, and
|
|
331
|
-
//
|
|
332
|
-
if (
|
|
475
|
+
// Keyed off what is in the file rather than the exit code: codegen still
|
|
476
|
+
// writes a recording when the window is force-closed, and still leaves an
|
|
477
|
+
// empty shell behind when the page never loaded.
|
|
478
|
+
if (source !== null && countRecordedActions(source) > 0) {
|
|
333
479
|
pointAtFixture(outFile);
|
|
334
480
|
outFile = await nameRecording(outFile);
|
|
335
481
|
console.log('');
|
|
336
482
|
console.log(' Saved to ' + outFile);
|
|
337
483
|
console.log(' Run it any time with: kryptheon check');
|
|
338
484
|
console.log('');
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Nothing usable: leave no empty file behind.
|
|
489
|
+
if (source !== null) {
|
|
490
|
+
try {
|
|
491
|
+
fs.unlinkSync(specPath);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
/* nothing more we can do */
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (!hadTestsDir) {
|
|
497
|
+
try {
|
|
498
|
+
if (fs.readdirSync(TESTS_DIR).length === 0) fs.rmdirSync(TESTS_DIR);
|
|
499
|
+
} catch (err) {
|
|
500
|
+
/* only tidying up */
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (run.status !== 0 || source === null) {
|
|
505
|
+
explainCodegenFailure(target, run.stderr);
|
|
506
|
+
} else {
|
|
507
|
+
console.log('');
|
|
508
|
+
console.log(' Nothing was recorded.');
|
|
509
|
+
console.log('');
|
|
510
|
+
console.log(' The browser closed before anything was clicked or typed.');
|
|
511
|
+
console.log(' Run the same command again and use your app before closing it.');
|
|
512
|
+
console.log('');
|
|
339
513
|
}
|
|
340
|
-
return
|
|
514
|
+
return 1;
|
|
341
515
|
}
|
|
342
516
|
|
|
343
517
|
// Recordings import the fixture by package name, which only resolves if
|
|
@@ -397,7 +571,7 @@ function check() {
|
|
|
397
571
|
return 1;
|
|
398
572
|
}
|
|
399
573
|
if (!ensureBrowser()) return 1;
|
|
400
|
-
return runPlaywright(['test', '--config', CONFIG]);
|
|
574
|
+
return runPlaywright(['test', '--config', CONFIG]).status;
|
|
401
575
|
}
|
|
402
576
|
|
|
403
577
|
// --- accept -----------------------------------------------------------------
|
|
@@ -548,6 +722,10 @@ module.exports = {
|
|
|
548
722
|
nameRecording: nameRecording,
|
|
549
723
|
pointAtFixture: pointAtFixture,
|
|
550
724
|
listSpecFiles: listSpecFiles,
|
|
725
|
+
countRecordedActions: countRecordedActions,
|
|
726
|
+
classifyFetchError: classifyFetchError,
|
|
727
|
+
normaliseRecordUrl: normaliseRecordUrl,
|
|
728
|
+
isLocalAddress: isLocalAddress,
|
|
551
729
|
nodeIsTooOld: nodeIsTooOld,
|
|
552
730
|
};
|
|
553
731
|
|