kryptheon 0.1.0 → 0.1.2
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 +518 -36
- package/package.json +1 -1
package/bin/kryptheon.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
|
-
const { spawnSync } = require('child_process');
|
|
13
|
+
const { spawn, spawnSync } = require('child_process');
|
|
14
14
|
|
|
15
15
|
const PACKAGE_DIR = path.join(__dirname, '..');
|
|
16
16
|
const USER_DIR = process.cwd();
|
|
@@ -19,6 +19,10 @@ const TESTS_DIR = path.join(USER_DIR, 'tests');
|
|
|
19
19
|
|
|
20
20
|
const MIN_NODE = [20, 6, 0];
|
|
21
21
|
|
|
22
|
+
// How long to wait for a browser window to appear before giving up.
|
|
23
|
+
const WINDOW_POLL_MS = 3000;
|
|
24
|
+
const WINDOW_TIMEOUT_MS = 30000;
|
|
25
|
+
|
|
22
26
|
function nodeIsTooOld(version) {
|
|
23
27
|
const parts = String(version || process.versions.node).split('.').map(Number);
|
|
24
28
|
for (let i = 0; i < MIN_NODE.length; i++) {
|
|
@@ -58,28 +62,36 @@ function findPlaywrightCli() {
|
|
|
58
62
|
|
|
59
63
|
// Always runs with the user's folder as the working directory, so tests,
|
|
60
64
|
// baselines and .env are found where they actually live.
|
|
61
|
-
|
|
65
|
+
// Returns { status, stderr }. With quietErrors the child's stderr is captured
|
|
66
|
+
// rather than inherited, so a Node or Playwright stack trace never reaches the
|
|
67
|
+
// person running the command.
|
|
68
|
+
function runPlaywright(args, options) {
|
|
69
|
+
const quiet = !!(options && options.quietErrors);
|
|
62
70
|
const cli = findPlaywrightCli();
|
|
63
71
|
if (!cli) {
|
|
64
72
|
console.error('');
|
|
65
73
|
console.error(' The testing engine is missing from this install.');
|
|
66
74
|
console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
|
|
67
75
|
console.error('');
|
|
68
|
-
return 1;
|
|
76
|
+
return { status: 1, stderr: '' };
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
const result = spawnSync(process.execPath, [cli].concat(args), {
|
|
72
80
|
cwd: USER_DIR,
|
|
73
|
-
stdio: 'inherit',
|
|
81
|
+
stdio: quiet ? ['inherit', 'inherit', 'pipe'] : 'inherit',
|
|
82
|
+
encoding: 'utf8',
|
|
74
83
|
});
|
|
75
84
|
|
|
76
85
|
if (result.error) {
|
|
77
86
|
console.error('');
|
|
78
87
|
console.error(' Could not start the testing engine: ' + result.error.message);
|
|
79
88
|
console.error('');
|
|
80
|
-
return 1;
|
|
89
|
+
return { status: 1, stderr: String(result.stderr || '') };
|
|
81
90
|
}
|
|
82
|
-
return
|
|
91
|
+
return {
|
|
92
|
+
status: result.status === null ? 1 : result.status,
|
|
93
|
+
stderr: String(result.stderr || ''),
|
|
94
|
+
};
|
|
83
95
|
}
|
|
84
96
|
|
|
85
97
|
// Chromium is a separate download from the npm package, so the first run on a
|
|
@@ -97,7 +109,7 @@ function ensureBrowser() {
|
|
|
97
109
|
console.log(' Downloading a browser to run your app in.');
|
|
98
110
|
console.log(' This is about 200MB and only happens once.');
|
|
99
111
|
console.log('');
|
|
100
|
-
const status = runPlaywright(['install', 'chromium']);
|
|
112
|
+
const status = runPlaywright(['install', 'chromium']).status;
|
|
101
113
|
if (status !== 0) {
|
|
102
114
|
console.error('');
|
|
103
115
|
console.error(' The browser download did not finish.');
|
|
@@ -125,6 +137,171 @@ function listSpecFiles() {
|
|
|
125
137
|
.filter((name) => /\.(spec|test)\.(c|m)?[jt]sx?$/.test(name));
|
|
126
138
|
}
|
|
127
139
|
|
|
140
|
+
// A plain GET on Node's own http/https, deliberately NOT fetch. fetch keeps
|
|
141
|
+
// its sockets alive in a pool, and those were still open at exit - handles
|
|
142
|
+
// being torn down while the process is ending is how libuv assertions happen
|
|
143
|
+
// on Windows. agent:false closes the socket as soon as we are done with it.
|
|
144
|
+
function httpGet(url, timeoutMs, redirectsLeft) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = new URL(url);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return reject(Object.assign(new Error('bad address'), { code: 'ERR_INVALID_URL' }));
|
|
151
|
+
}
|
|
152
|
+
const lib = parsed.protocol === 'https:' ? require('https') : require('http');
|
|
153
|
+
const req = lib.get(url, { agent: false, timeout: timeoutMs || 8000 }, (res) => {
|
|
154
|
+
const location = res.headers && res.headers.location;
|
|
155
|
+
if (location && res.statusCode >= 300 && res.statusCode < 400 && (redirectsLeft || 0) > 0) {
|
|
156
|
+
res.resume();
|
|
157
|
+
req.destroy();
|
|
158
|
+
return resolve(httpGet(new URL(location, url).toString(), timeoutMs, redirectsLeft - 1));
|
|
159
|
+
}
|
|
160
|
+
let body = '';
|
|
161
|
+
res.setEncoding('utf8');
|
|
162
|
+
res.on('data', (chunk) => {
|
|
163
|
+
if (body.length < 300000) body += chunk;
|
|
164
|
+
});
|
|
165
|
+
res.on('end', () => {
|
|
166
|
+
req.destroy();
|
|
167
|
+
resolve({ status: res.statusCode, body: body });
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
req.on('timeout', () => {
|
|
171
|
+
req.destroy(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }));
|
|
172
|
+
});
|
|
173
|
+
req.on('error', reject);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- is the address actually there? ----------------------------------------
|
|
178
|
+
|
|
179
|
+
// "localhost:3000" is what people type; give it a scheme before using it.
|
|
180
|
+
function normaliseRecordUrl(url) {
|
|
181
|
+
const s = String(url == null ? '' : url).trim();
|
|
182
|
+
if (!s) return s;
|
|
183
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s)) return s;
|
|
184
|
+
return 'http://' + s;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isLocalAddress(url) {
|
|
188
|
+
let host;
|
|
189
|
+
try {
|
|
190
|
+
host = new URL(url).hostname.toLowerCase();
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
return (
|
|
195
|
+
host === 'localhost' ||
|
|
196
|
+
host === '127.0.0.1' ||
|
|
197
|
+
host === '0.0.0.0' ||
|
|
198
|
+
host === '::1' ||
|
|
199
|
+
host === '[::1]' ||
|
|
200
|
+
host.endsWith('.local') ||
|
|
201
|
+
host.endsWith('.localhost')
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Turns a fetch failure into one of a few plain kinds. Split out from the
|
|
206
|
+
// network call so it can be checked without a network.
|
|
207
|
+
function classifyFetchError(err) {
|
|
208
|
+
const code = (err && err.cause && err.cause.code) || (err && err.code) || null;
|
|
209
|
+
if (code === 'ECONNREFUSED') return 'refused';
|
|
210
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'unknown-address';
|
|
211
|
+
if (err && err.name === 'AbortError') return 'timeout';
|
|
212
|
+
if (code === 'ETIMEDOUT' || code === 'ECONNRESET') return 'timeout';
|
|
213
|
+
// A certificate complaint means something IS answering there. Let the
|
|
214
|
+
// browser deal with it rather than blocking the recording.
|
|
215
|
+
if (code && /CERT|SSL|SELF_SIGNED|VERIFY/i.test(String(code))) return 'reachable';
|
|
216
|
+
return 'other';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function reachability(url, timeoutMs) {
|
|
220
|
+
try {
|
|
221
|
+
await httpGet(url, timeoutMs || 8000, 0);
|
|
222
|
+
return { ok: true, kind: 'reachable' };
|
|
223
|
+
} catch (err) {
|
|
224
|
+
const kind = classifyFetchError(err);
|
|
225
|
+
return kind === 'reachable' ? { ok: true, kind: kind } : { ok: false, kind: kind };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// One place that explains an address not answering, used both before codegen
|
|
230
|
+
// starts and if codegen itself reports the same thing.
|
|
231
|
+
function explainUnreachable(url, kind) {
|
|
232
|
+
const local = isLocalAddress(url);
|
|
233
|
+
console.error('');
|
|
234
|
+
console.error(' Nothing answered at ' + url);
|
|
235
|
+
console.error('');
|
|
236
|
+
|
|
237
|
+
if (kind === 'unknown-address') {
|
|
238
|
+
console.error(' That web address could not be found.');
|
|
239
|
+
console.error(' Check the spelling, and that you are connected to the internet.');
|
|
240
|
+
} else if (kind === 'timeout') {
|
|
241
|
+
console.error(' The address did not answer in time.');
|
|
242
|
+
console.error(' It may be slow, or blocked by a firewall or VPN.');
|
|
243
|
+
} else if (local) {
|
|
244
|
+
console.error(' Your app does not look like it is running.');
|
|
245
|
+
console.error('');
|
|
246
|
+
console.error(' For an app on your own machine, start it first - usually:');
|
|
247
|
+
console.error(' npm run dev');
|
|
248
|
+
console.error('');
|
|
249
|
+
console.error(' Then check the address and port match what it printed.');
|
|
250
|
+
} else {
|
|
251
|
+
console.error(' Nothing is listening at that address.');
|
|
252
|
+
console.error(' Check the address, including the port number, and that the site is up.');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
console.error('');
|
|
256
|
+
console.error(' Nothing was recorded.');
|
|
257
|
+
console.error('');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Codegen writes Node/Playwright traces to stderr. We never show those; we
|
|
261
|
+
// pick out the part that means something and say it in plain words.
|
|
262
|
+
function explainCodegenFailure(url, stderr) {
|
|
263
|
+
const text = String(stderr || '');
|
|
264
|
+
if (/ERR_CONNECTION_REFUSED/.test(text)) return explainUnreachable(url, 'refused');
|
|
265
|
+
if (/ERR_NAME_NOT_RESOLVED/.test(text)) return explainUnreachable(url, 'unknown-address');
|
|
266
|
+
if (/ERR_CONNECTION_TIMED_OUT|ERR_TIMED_OUT/.test(text)) return explainUnreachable(url, 'timeout');
|
|
267
|
+
if (/ERR_INTERNET_DISCONNECTED/.test(text)) {
|
|
268
|
+
console.error('');
|
|
269
|
+
console.error(' There is no internet connection.');
|
|
270
|
+
console.error('');
|
|
271
|
+
console.error(' Nothing was recorded.');
|
|
272
|
+
console.error('');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
console.error('');
|
|
276
|
+
console.error(' The browser closed before anything could be recorded.');
|
|
277
|
+
console.error('');
|
|
278
|
+
console.error(' Check that ' + url + ' opens normally in your own browser,');
|
|
279
|
+
console.error(' then try again.');
|
|
280
|
+
console.error('');
|
|
281
|
+
console.error(' Nothing was recorded.');
|
|
282
|
+
console.error('');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A recording is only worth keeping if the browser actually did something.
|
|
286
|
+
// An abandoned or failed run leaves a test body with no page calls at all.
|
|
287
|
+
function countRecordedActions(source) {
|
|
288
|
+
const found = String(source || '').match(/\bpage\s*\.\s*[A-Za-z_$][\w$]*\s*\(/g);
|
|
289
|
+
return found ? found.length : 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A spec that pulls Playwright in directly gets none of our diagnostics.
|
|
293
|
+
function importsPlaywrightDirectly(source) {
|
|
294
|
+
const text = String(source || '');
|
|
295
|
+
// The literal specifier is what matters; import and require both carry it.
|
|
296
|
+
return text.indexOf("'@playwright/test'") !== -1 || text.indexOf('"@playwright/test"') !== -1;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// codegen always writes the opening page.goto. A recording is only real if
|
|
300
|
+
// something else happened as well.
|
|
301
|
+
function isRealRecording(source) {
|
|
302
|
+
return countRecordedActions(source) >= 2;
|
|
303
|
+
}
|
|
304
|
+
|
|
128
305
|
function timestampName() {
|
|
129
306
|
const d = new Date();
|
|
130
307
|
const pad = (n) => String(n).padStart(2, '0');
|
|
@@ -198,13 +375,9 @@ function nameFromTitle(title) {
|
|
|
198
375
|
// to the recorded path if this cannot be reached.
|
|
199
376
|
async function fetchTitle(url) {
|
|
200
377
|
try {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
const
|
|
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);
|
|
378
|
+
const res = await httpGet(url, 5000, 2);
|
|
379
|
+
if (!res || res.status < 200 || res.status >= 300) return null;
|
|
380
|
+
const m = String(res.body).match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
208
381
|
return m ? m[1].replace(/\s+/g, ' ').trim() : null;
|
|
209
382
|
} catch (err) {
|
|
210
383
|
return null;
|
|
@@ -294,6 +467,127 @@ async function nameRecording(relativeFile) {
|
|
|
294
467
|
}
|
|
295
468
|
return target;
|
|
296
469
|
}
|
|
470
|
+
// Recording needs a real browser window on someone's screen. Inside an AI
|
|
471
|
+
// coding assistant there is no desktop to draw it on, so codegen would sit
|
|
472
|
+
// there forever and the run would be killed before anything was tidied up.
|
|
473
|
+
function looksLikeNoDesktop() {
|
|
474
|
+
if (process.env.KRYPTHEON_FORCE_RECORD) return false;
|
|
475
|
+
return !process.stdout.isTTY;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function explainNoWindow(url) {
|
|
479
|
+
console.error('');
|
|
480
|
+
console.error(' Recording needs a real browser window on your screen.');
|
|
481
|
+
console.error('');
|
|
482
|
+
console.error(' This terminal cannot show one - that is what happens inside an');
|
|
483
|
+
console.error(' AI coding assistant, or any window-less session.');
|
|
484
|
+
console.error('');
|
|
485
|
+
console.error(' Open Command Prompt (or PowerShell) yourself and run:');
|
|
486
|
+
console.error(' npx kryptheon record ' + url);
|
|
487
|
+
console.error('');
|
|
488
|
+
console.error(' Nothing was recorded.');
|
|
489
|
+
console.error('');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Is a Playwright-controlled browser actually showing a window?
|
|
493
|
+
function browserWindowIsUp() {
|
|
494
|
+
try {
|
|
495
|
+
if (process.platform === 'win32') {
|
|
496
|
+
const probe = spawnSync(
|
|
497
|
+
'powershell',
|
|
498
|
+
[
|
|
499
|
+
'-NoProfile',
|
|
500
|
+
'-Command',
|
|
501
|
+
"@(Get-Process chrome,msedge -ErrorAction SilentlyContinue | " +
|
|
502
|
+
"Where-Object { $_.Path -like '*ms-playwright*' -and $_.MainWindowHandle -ne 0 }).Count",
|
|
503
|
+
],
|
|
504
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 8000 }
|
|
505
|
+
);
|
|
506
|
+
return Number(String(probe.stdout || '').trim()) > 0;
|
|
507
|
+
}
|
|
508
|
+
const probe = spawnSync('ps', ['-A', '-o', 'command'], { encoding: 'utf8', timeout: 8000 });
|
|
509
|
+
return /ms-playwright/.test(String(probe.stdout || ''));
|
|
510
|
+
} catch (err) {
|
|
511
|
+
return true; // cannot tell - do not block the user
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Runs codegen without blocking the event loop, so SIGINT and SIGTERM can
|
|
516
|
+
// still be handled. spawnSync would freeze the loop and no handler would run.
|
|
517
|
+
function startCodegen(args) {
|
|
518
|
+
const cli = findPlaywrightCli();
|
|
519
|
+
if (!cli) {
|
|
520
|
+
console.error('');
|
|
521
|
+
console.error(' The testing engine is missing from this install.');
|
|
522
|
+
console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
|
|
523
|
+
console.error('');
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
return spawn(process.execPath, [cli].concat(args), {
|
|
527
|
+
cwd: USER_DIR,
|
|
528
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Everything that has to happen to whatever codegen left behind, however
|
|
533
|
+
// the run ended. Module level so it can be exercised directly.
|
|
534
|
+
async function finaliseRecording(outFile, context) {
|
|
535
|
+
const ctx = context || {};
|
|
536
|
+
const specPath = path.join(USER_DIR, outFile);
|
|
537
|
+
let source = null;
|
|
538
|
+
try {
|
|
539
|
+
source = fs.readFileSync(specPath, 'utf8');
|
|
540
|
+
} catch (err) {
|
|
541
|
+
source = null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (source !== null && isRealRecording(source)) {
|
|
545
|
+
// Import rewrite first: it is instant, so even a second interruption
|
|
546
|
+
// leaves behind a spec that works.
|
|
547
|
+
pointAtFixture(outFile);
|
|
548
|
+
const named = await nameRecording(outFile);
|
|
549
|
+
console.log('');
|
|
550
|
+
console.log(' Saved to ' + named);
|
|
551
|
+
console.log(' Run it any time with: kryptheon check');
|
|
552
|
+
console.log('');
|
|
553
|
+
return { code: 0, savedAs: named };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (source !== null) {
|
|
557
|
+
try {
|
|
558
|
+
fs.unlinkSync(specPath);
|
|
559
|
+
} catch (err) {
|
|
560
|
+
/* nothing more we can do */
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (!ctx.hadTestsDir) {
|
|
564
|
+
try {
|
|
565
|
+
if (fs.readdirSync(TESTS_DIR).length === 0) fs.rmdirSync(TESTS_DIR);
|
|
566
|
+
} catch (err) {
|
|
567
|
+
/* only tidying up */
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (ctx.reason === 'no-window') {
|
|
572
|
+
explainNoWindow(ctx.target);
|
|
573
|
+
} else if (ctx.reason === 'signal') {
|
|
574
|
+
console.log('');
|
|
575
|
+
console.log(' Recording stopped before anything was saved.');
|
|
576
|
+
console.log('');
|
|
577
|
+
console.log(' Nothing was recorded.');
|
|
578
|
+
console.log('');
|
|
579
|
+
} else if (source === null || /ERR_|Error:/.test(String(ctx.stderr || ''))) {
|
|
580
|
+
explainCodegenFailure(ctx.target, ctx.stderr);
|
|
581
|
+
} else {
|
|
582
|
+
console.log('');
|
|
583
|
+
console.log(' Nothing was recorded.');
|
|
584
|
+
console.log('');
|
|
585
|
+
console.log(' The browser closed before anything was clicked or typed.');
|
|
586
|
+
console.log(' Run the same command again and use your app before closing it.');
|
|
587
|
+
console.log('');
|
|
588
|
+
}
|
|
589
|
+
return { code: 1, savedAs: null };
|
|
590
|
+
}
|
|
297
591
|
|
|
298
592
|
async function record(url) {
|
|
299
593
|
if (!url) {
|
|
@@ -305,18 +599,37 @@ async function record(url) {
|
|
|
305
599
|
console.error('');
|
|
306
600
|
return 1;
|
|
307
601
|
}
|
|
602
|
+
|
|
603
|
+
const target = normaliseRecordUrl(url);
|
|
604
|
+
|
|
605
|
+
// No desktop means no window means nothing to record. Say so now rather
|
|
606
|
+
// than hanging until something kills us.
|
|
607
|
+
if (looksLikeNoDesktop()) {
|
|
608
|
+
explainNoWindow(target);
|
|
609
|
+
return 1;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Fail early and kindly rather than letting codegen throw a stack trace.
|
|
613
|
+
const reach = await reachability(target);
|
|
614
|
+
if (!reach.ok) {
|
|
615
|
+
explainUnreachable(target, reach.kind);
|
|
616
|
+
return 1;
|
|
617
|
+
}
|
|
618
|
+
|
|
308
619
|
if (!ensureBrowser()) return 1;
|
|
309
620
|
|
|
621
|
+
const hadTestsDir = fs.existsSync(TESTS_DIR);
|
|
310
622
|
try {
|
|
311
623
|
fs.mkdirSync(TESTS_DIR, { recursive: true });
|
|
312
624
|
} catch (err) {
|
|
313
625
|
/* codegen will report if it cannot write */
|
|
314
626
|
}
|
|
315
627
|
|
|
316
|
-
|
|
628
|
+
const outFile = path.join('tests', timestampName());
|
|
629
|
+
const specPath = path.join(USER_DIR, outFile);
|
|
317
630
|
|
|
318
631
|
console.log('');
|
|
319
|
-
console.log(' Opening ' +
|
|
632
|
+
console.log(' Opening ' + target + ' in a browser.');
|
|
320
633
|
console.log('');
|
|
321
634
|
console.log(' Use your app normally - click, type, sign in, whatever you want');
|
|
322
635
|
console.log(' covered. Every step is recorded as you go.');
|
|
@@ -324,20 +637,74 @@ async function record(url) {
|
|
|
324
637
|
console.log(' When you are done, close the browser window to save the test.');
|
|
325
638
|
console.log('');
|
|
326
639
|
|
|
327
|
-
const
|
|
640
|
+
const child = startCodegen(['codegen', '--target', 'playwright-test', '-o', outFile, target]);
|
|
641
|
+
if (!child) return 1;
|
|
328
642
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
643
|
+
let stderr = '';
|
|
644
|
+
if (child.stderr) child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
645
|
+
|
|
646
|
+
// Everything that has to happen no matter how this ends: tidy the file,
|
|
647
|
+
// wire it up, name it. Runs once, whether codegen exits on its own or we
|
|
648
|
+
// are interrupted.
|
|
649
|
+
let finished = false;
|
|
650
|
+
const finalise = async (reason) => {
|
|
651
|
+
if (finished) return 1;
|
|
652
|
+
finished = true;
|
|
653
|
+
const outcome = await finaliseRecording(outFile, {
|
|
654
|
+
hadTestsDir: hadTestsDir,
|
|
655
|
+
target: target,
|
|
656
|
+
stderr: stderr,
|
|
657
|
+
reason: reason,
|
|
658
|
+
});
|
|
659
|
+
return outcome.code;
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
const stopChild = () => {
|
|
663
|
+
try {
|
|
664
|
+
child.kill();
|
|
665
|
+
} catch (err) {
|
|
666
|
+
/* already gone */
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
return await new Promise((resolve) => {
|
|
671
|
+
// If no window ever appears, stop instead of waiting for someone to kill us.
|
|
672
|
+
let waited = 0;
|
|
673
|
+
const watchdog = setInterval(() => {
|
|
674
|
+
waited += WINDOW_POLL_MS;
|
|
675
|
+
if (browserWindowIsUp()) {
|
|
676
|
+
clearInterval(watchdog);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (waited >= WINDOW_TIMEOUT_MS) {
|
|
680
|
+
clearInterval(watchdog);
|
|
681
|
+
stopChild();
|
|
682
|
+
finalise('no-window').then(resolve);
|
|
683
|
+
}
|
|
684
|
+
}, WINDOW_POLL_MS);
|
|
685
|
+
|
|
686
|
+
const onSignal = () => {
|
|
687
|
+
clearInterval(watchdog);
|
|
688
|
+
stopChild();
|
|
689
|
+
finalise('signal').then(resolve);
|
|
690
|
+
};
|
|
691
|
+
process.once('SIGINT', onSignal);
|
|
692
|
+
process.once('SIGTERM', onSignal);
|
|
693
|
+
process.once('SIGHUP', onSignal);
|
|
694
|
+
|
|
695
|
+
child.on('error', () => {
|
|
696
|
+
clearInterval(watchdog);
|
|
697
|
+
finalise('error').then(resolve);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
child.on('close', () => {
|
|
701
|
+
clearInterval(watchdog);
|
|
702
|
+
process.removeListener('SIGINT', onSignal);
|
|
703
|
+
process.removeListener('SIGTERM', onSignal);
|
|
704
|
+
process.removeListener('SIGHUP', onSignal);
|
|
705
|
+
finalise('exit').then(resolve);
|
|
706
|
+
});
|
|
707
|
+
});
|
|
341
708
|
}
|
|
342
709
|
|
|
343
710
|
// Recordings import the fixture by package name, which only resolves if
|
|
@@ -371,6 +738,46 @@ function specsNeedThePackage() {
|
|
|
371
738
|
});
|
|
372
739
|
}
|
|
373
740
|
|
|
741
|
+
// Older versions, and any run that was killed part way, left specs behind that
|
|
742
|
+
// import Playwright directly (so they produce no diagnostics) or that contain
|
|
743
|
+
// nothing but the opening navigation (so they verify nothing). Repair the
|
|
744
|
+
// first kind and refuse to run the second, rather than reporting a false OK.
|
|
745
|
+
function triageSpecs() {
|
|
746
|
+
const repaired = [];
|
|
747
|
+
const notRecordings = [];
|
|
748
|
+
const runnable = [];
|
|
749
|
+
|
|
750
|
+
for (const name of listSpecFiles()) {
|
|
751
|
+
const full = path.join(TESTS_DIR, name);
|
|
752
|
+
let source;
|
|
753
|
+
try {
|
|
754
|
+
source = fs.readFileSync(full, 'utf8');
|
|
755
|
+
} catch (err) {
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (/(from|require\()\s*(['"])@playwright\/test\2/.test(source)) {
|
|
760
|
+
const relative = path.join('tests', name);
|
|
761
|
+
if (pointAtFixture(relative)) {
|
|
762
|
+
repaired.push(name);
|
|
763
|
+
try {
|
|
764
|
+
source = fs.readFileSync(full, 'utf8');
|
|
765
|
+
} catch (err) {
|
|
766
|
+
/* keep what we had */
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (!isRealRecording(source)) {
|
|
772
|
+
notRecordings.push(name);
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
runnable.push(name);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return { repaired: repaired, notRecordings: notRecordings, runnable: runnable };
|
|
779
|
+
}
|
|
780
|
+
|
|
374
781
|
function check() {
|
|
375
782
|
if (!listSpecFiles().length) {
|
|
376
783
|
console.log('');
|
|
@@ -396,8 +803,50 @@ function check() {
|
|
|
396
803
|
console.log('');
|
|
397
804
|
return 1;
|
|
398
805
|
}
|
|
806
|
+
const triage = triageSpecs();
|
|
807
|
+
|
|
808
|
+
for (const name of triage.repaired) {
|
|
809
|
+
console.log('');
|
|
810
|
+
console.log(' Repaired ' + path.join('tests', name));
|
|
811
|
+
console.log(' It was saved without the part that explains failures. Fixed now.');
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
for (const name of triage.notRecordings) {
|
|
815
|
+
console.log('');
|
|
816
|
+
console.log(' Skipped ' + path.join('tests', name));
|
|
817
|
+
console.log(' This is not a real recording - it only opens a page and stops,');
|
|
818
|
+
console.log(' so it cannot tell you whether anything works.');
|
|
819
|
+
console.log(' Record it again: kryptheon record <url>');
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (!triage.runnable.length) {
|
|
823
|
+
console.log('');
|
|
824
|
+
console.log(' Nothing could be checked.');
|
|
825
|
+
console.log('');
|
|
826
|
+
console.log(' None of the files in tests/ is a usable recording.');
|
|
827
|
+
console.log(' Record one with: kryptheon record <url>');
|
|
828
|
+
console.log('');
|
|
829
|
+
return 1;
|
|
830
|
+
}
|
|
831
|
+
|
|
399
832
|
if (!ensureBrowser()) return 1;
|
|
400
|
-
|
|
833
|
+
|
|
834
|
+
// Only the usable recordings are handed to the test runner, so a file that
|
|
835
|
+
// cannot produce diagnostics can never be counted as passing.
|
|
836
|
+
const only = triage.runnable.map((name) => path.join('tests', name).split(path.sep).join('/'));
|
|
837
|
+
const status = runPlaywright(['test', '--config', CONFIG].concat(only)).status;
|
|
838
|
+
|
|
839
|
+
// Without this, a green summary could still hide a recording that checked
|
|
840
|
+
// nothing at all.
|
|
841
|
+
if (triage.notRecordings.length) {
|
|
842
|
+
const many = triage.notRecordings.length > 1;
|
|
843
|
+
console.log(
|
|
844
|
+
' Note: ' + triage.notRecordings.length + ' recording' + (many ? 's' : '') +
|
|
845
|
+
' above checked nothing and ' + (many ? 'were' : 'was') + ' skipped.'
|
|
846
|
+
);
|
|
847
|
+
console.log('');
|
|
848
|
+
}
|
|
849
|
+
return status;
|
|
401
850
|
}
|
|
402
851
|
|
|
403
852
|
// --- accept -----------------------------------------------------------------
|
|
@@ -502,6 +951,29 @@ function accept(name) {
|
|
|
502
951
|
|
|
503
952
|
const [command, ...rest] = process.argv.slice(2);
|
|
504
953
|
|
|
954
|
+
// process.exit() cuts the process off mid-teardown. On Windows a terminal's
|
|
955
|
+
// output is written asynchronously, so exiting while the last line is still
|
|
956
|
+
// queued tears down a handle that is already closing - which is what produces
|
|
957
|
+
// "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)" in libuv. Setting
|
|
958
|
+
// the code and letting Node wind down on its own avoids that entirely, and
|
|
959
|
+
// still reports the same exit status.
|
|
960
|
+
async function finishWith(code) {
|
|
961
|
+
process.exitCode = code;
|
|
962
|
+
const streams = [process.stdout, process.stderr];
|
|
963
|
+
await Promise.all(
|
|
964
|
+
streams.map(
|
|
965
|
+
(stream) =>
|
|
966
|
+
new Promise((resolve) => {
|
|
967
|
+
if (!stream || typeof stream.write !== 'function' || !stream.writableLength) return resolve();
|
|
968
|
+
const done = () => resolve();
|
|
969
|
+
stream.once('drain', done);
|
|
970
|
+
const guard = setTimeout(done, 1000);
|
|
971
|
+
if (guard.unref) guard.unref();
|
|
972
|
+
})
|
|
973
|
+
)
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
505
977
|
async function main() {
|
|
506
978
|
if (nodeIsTooOld()) {
|
|
507
979
|
console.error('');
|
|
@@ -511,31 +983,31 @@ async function main() {
|
|
|
511
983
|
console.error('');
|
|
512
984
|
console.error(' Download the latest from https://nodejs.org and try again.');
|
|
513
985
|
console.error('');
|
|
514
|
-
|
|
986
|
+
return finishWith(1);
|
|
515
987
|
}
|
|
516
988
|
|
|
517
989
|
switch (command) {
|
|
518
990
|
case 'record':
|
|
519
|
-
|
|
991
|
+
return finishWith(await record(rest[0]));
|
|
520
992
|
break;
|
|
521
993
|
case 'check':
|
|
522
|
-
|
|
994
|
+
return finishWith(check());
|
|
523
995
|
break;
|
|
524
996
|
case 'accept':
|
|
525
|
-
|
|
997
|
+
return finishWith(accept(rest.join(' ').trim()));
|
|
526
998
|
break;
|
|
527
999
|
case undefined:
|
|
528
1000
|
case '-h':
|
|
529
1001
|
case '--help':
|
|
530
1002
|
case 'help':
|
|
531
1003
|
usage();
|
|
532
|
-
|
|
1004
|
+
return finishWith(0);
|
|
533
1005
|
break;
|
|
534
1006
|
default:
|
|
535
1007
|
console.error('');
|
|
536
1008
|
console.error(' Unknown command: ' + command);
|
|
537
1009
|
usage();
|
|
538
|
-
|
|
1010
|
+
return finishWith(1);
|
|
539
1011
|
}
|
|
540
1012
|
}
|
|
541
1013
|
|
|
@@ -548,6 +1020,16 @@ module.exports = {
|
|
|
548
1020
|
nameRecording: nameRecording,
|
|
549
1021
|
pointAtFixture: pointAtFixture,
|
|
550
1022
|
listSpecFiles: listSpecFiles,
|
|
1023
|
+
countRecordedActions: countRecordedActions,
|
|
1024
|
+
isRealRecording: isRealRecording,
|
|
1025
|
+
importsPlaywrightDirectly: importsPlaywrightDirectly,
|
|
1026
|
+
triageSpecs: triageSpecs,
|
|
1027
|
+
record: record,
|
|
1028
|
+
finaliseRecording: finaliseRecording,
|
|
1029
|
+
looksLikeNoDesktop: looksLikeNoDesktop,
|
|
1030
|
+
classifyFetchError: classifyFetchError,
|
|
1031
|
+
normaliseRecordUrl: normaliseRecordUrl,
|
|
1032
|
+
isLocalAddress: isLocalAddress,
|
|
551
1033
|
nodeIsTooOld: nodeIsTooOld,
|
|
552
1034
|
};
|
|
553
1035
|
|