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 +86 -0
- package/bin/kryptheon.js +554 -0
- package/kryptheon-fixture.js +324 -0
- package/kryptheon-reporter.js +678 -0
- package/package.json +38 -0
- package/playwright.config.js +60 -0
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
|
package/bin/kryptheon.js
ADDED
|
@@ -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();
|