kryptheon 0.1.19 → 0.1.21
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 +48 -10
- package/bin/kryptheon.js +466 -6
- package/kryptheon-changes.js +222 -0
- package/kryptheon-code.js +564 -0
- package/kryptheon-dashboard-page.js +1003 -0
- package/kryptheon-dashboard.html +446 -0
- package/kryptheon-dashboard.js +532 -0
- package/kryptheon-fixture.js +7 -3
- package/kryptheon-logo.png +0 -0
- package/kryptheon-look.js +259 -0
- package/kryptheon-reporter.js +18 -6
- package/kryptheon-secrets.js +5 -3
- package/kryptheon-store.js +332 -0
- package/package.json +93 -78
- package/playwright.config.js +5 -2
package/README.md
CHANGED
|
@@ -161,20 +161,58 @@ wanted, and one new name of the same kind. A button that is gone with nothing
|
|
|
161
161
|
in its place, or a rename next to other new buttons, fails as it always did.
|
|
162
162
|
Checks you added with the toolbar are never redirected.
|
|
163
163
|
|
|
164
|
-
##
|
|
164
|
+
## Dashboard
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
166
|
+
```
|
|
167
|
+
npx kryptheon
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
In a terminal, with no command, this opens a page on your own machine
|
|
171
|
+
(`http://127.0.0.1:4789/`) for this project: what is broken, what needs a
|
|
172
|
+
look, every run, recent changes, and switches for each check. `Run now`
|
|
173
|
+
replays your recordings or reads your code from the page. It is only
|
|
174
|
+
reachable from this machine; press Ctrl+C to stop it. From a script, use
|
|
175
|
+
`npx kryptheon dashboard`.
|
|
176
|
+
|
|
177
|
+
Checks that are not built yet are listed as "not available" - never as
|
|
178
|
+
passed.
|
|
179
|
+
|
|
180
|
+
## Reading your code
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
npx kryptheon code
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Reads your frontend code for values put into the page as HTML - `innerHTML`,
|
|
187
|
+
`insertAdjacentHTML`, `document.write`, React's `dangerouslySetInnerHTML`,
|
|
188
|
+
Vue's `v-html`. If such a value can hold text someone typed, a `<` in it can
|
|
189
|
+
run script with the signed-in person's session. Nothing is run; every result
|
|
190
|
+
is "verification required", ranked HIGH when the value comes from a server
|
|
191
|
+
response or an error message, MEDIUM when the code alone cannot say. The fix
|
|
192
|
+
prompt for each one names the file, line and value.
|
|
193
|
+
|
|
194
|
+
## What goes where
|
|
195
|
+
|
|
196
|
+
Only your recordings go in your project, in `tests/`. Everything Kryptheon
|
|
197
|
+
remembers about the project is kept outside it, so it can never end up in your
|
|
198
|
+
repo:
|
|
199
|
+
|
|
200
|
+
~/.kryptheon/projects/<folder-name>-<id>/
|
|
201
|
+
baselines.json the remembered result for each test
|
|
202
|
+
history.jsonl one line per run, used for "this was working on …"
|
|
203
|
+
test-results/ the screenshot of the last failure
|
|
204
|
+
|
|
205
|
+
Set `KRYPTHEON_HOME` to keep them somewhere else. Each project folder gets its
|
|
206
|
+
own store, so two projects are never mixed. A project folder that is moved or
|
|
207
|
+
renamed starts a new store.
|
|
208
|
+
|
|
209
|
+
Older versions kept `kryptheon-baselines.json`, `kryptheon-history.jsonl` and
|
|
210
|
+
`test-results/` in the project. The first run of this version moves them out,
|
|
211
|
+
once, and says so.
|
|
171
212
|
|
|
172
213
|
A `.env` in the same folder is loaded automatically, and anything you type into a
|
|
173
214
|
password box is replaced with an environment variable rather than written into
|
|
174
|
-
the test.
|
|
175
|
-
|
|
176
|
-
Worth adding to `.gitignore`: `.env`, `kryptheon-baselines.json`,
|
|
177
|
-
`kryptheon-history.jsonl`, `test-results/`.
|
|
215
|
+
the test. When there is a password, `.env` is added to an existing `.gitignore`.
|
|
178
216
|
|
|
179
217
|
## Requirements
|
|
180
218
|
|
package/bin/kryptheon.js
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Two roots matter, and they are not the same once this is installed:
|
|
7
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
|
-
//
|
|
8
|
+
// USER_DIR - the folder the command was run in, which owns tests/ and .env
|
|
9
|
+
//
|
|
10
|
+
// Everything Kryptheon remembers about USER_DIR - baselines, history, the last
|
|
11
|
+
// run's screenshots - is kept in its store under ~/.kryptheon, never in the
|
|
12
|
+
// project (kryptheon-store.js).
|
|
10
13
|
|
|
11
14
|
const fs = require('fs');
|
|
12
15
|
const path = require('path');
|
|
@@ -19,6 +22,11 @@ const recordings = require('../kryptheon-recordings.js');
|
|
|
19
22
|
const cleanup = require('../kryptheon-cleanup.js');
|
|
20
23
|
const baselines = require('../kryptheon-baselines.js');
|
|
21
24
|
const project = require('../kryptheon-project.js');
|
|
25
|
+
const store = require('../kryptheon-store.js');
|
|
26
|
+
|
|
27
|
+
// The first version that keeps its records in the store. An older copy in a
|
|
28
|
+
// project folder still looks for them inside the project.
|
|
29
|
+
const FIRST_STORE_VERSION = '0.1.20';
|
|
22
30
|
|
|
23
31
|
const PACKAGE_DIR = path.join(__dirname, '..');
|
|
24
32
|
const USER_DIR = process.cwd();
|
|
@@ -77,6 +85,10 @@ function usage() {
|
|
|
77
85
|
console.log(' npx kryptheon accept <name> agree that one test\'s new result is the correct one');
|
|
78
86
|
console.log(' npx kryptheon remove [name] list the recordings, or remove one you no longer want');
|
|
79
87
|
console.log(' npx kryptheon setup-ai tell your AI assistant to check its work');
|
|
88
|
+
console.log(' npx kryptheon verify after a change: what changed, what broke, what got fixed');
|
|
89
|
+
console.log(' npx kryptheon recheck [id] prove a fix: run the check again, and say FIXED or not');
|
|
90
|
+
console.log(' npx kryptheon code read your frontend code for text inserted as HTML');
|
|
91
|
+
console.log(' npx kryptheon dashboard open this project\'s dashboard (also: npx kryptheon, in a terminal)');
|
|
80
92
|
console.log('');
|
|
81
93
|
console.log(' add --quiet to check for one line when everything passes');
|
|
82
94
|
console.log(' npx kryptheon --version print the version you have installed');
|
|
@@ -864,15 +876,49 @@ function takeOutSecrets(relativeFile) {
|
|
|
864
876
|
return writeSpec(relativeFile, result.source) ? result.replacements : [];
|
|
865
877
|
}
|
|
866
878
|
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
|
|
879
|
+
// The password file is the one thing of kryptheon's in a project that must not
|
|
880
|
+
// be committed. If there is already a .gitignore, it belongs in it - but only
|
|
881
|
+
// once there is a password to protect: a .env here, or a recording that just
|
|
882
|
+
// had one taken out. A landing page with no sign-in has nothing to hide, and
|
|
883
|
+
// an edit to its .gitignore would be one more change of Kryptheon's in the repo.
|
|
884
|
+
function keepArtefactsOutOfGit(passwordTakenOut) {
|
|
885
|
+
if (!passwordTakenOut && !fs.existsSync(path.join(USER_DIR, '.env'))) return;
|
|
870
886
|
const added = secrets.updateGitignore(USER_DIR);
|
|
871
887
|
if (!added.length) return;
|
|
872
888
|
console.log(' Added to .gitignore: ' + added.join(', '));
|
|
873
889
|
console.log('');
|
|
874
890
|
}
|
|
875
891
|
|
|
892
|
+
// The project's store, opened before anything reads a record, so whatever an
|
|
893
|
+
// older version left in this folder is moved out first - and said once, here,
|
|
894
|
+
// rather than by the fixture or the reporter, which run inside the test runner.
|
|
895
|
+
//
|
|
896
|
+
// Returns null when the store cannot be made. Carrying on would mean every
|
|
897
|
+
// recording is compared against nothing, which reads as "all fine" and is
|
|
898
|
+
// the one answer that must never be given by accident.
|
|
899
|
+
function openStore() {
|
|
900
|
+
let opened;
|
|
901
|
+
try {
|
|
902
|
+
opened = store.open(USER_DIR);
|
|
903
|
+
} catch (err) {
|
|
904
|
+
console.error('');
|
|
905
|
+
console.error(' Kryptheon keeps what it remembers about this project in');
|
|
906
|
+
console.error(' ' + store.dirFor(USER_DIR));
|
|
907
|
+
console.error(' and could not write there: ' + err.message);
|
|
908
|
+
console.error('');
|
|
909
|
+
console.error(' Without it every recording would look new, so nothing was checked.');
|
|
910
|
+
console.error(' Set KRYPTHEON_HOME to a folder you can write to, and run this again.');
|
|
911
|
+
console.error('');
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
const lines = store.migrationLines(opened);
|
|
915
|
+
if (lines.length) {
|
|
916
|
+
console.log('');
|
|
917
|
+
for (const line of lines) console.log(line);
|
|
918
|
+
}
|
|
919
|
+
return opened;
|
|
920
|
+
}
|
|
921
|
+
|
|
876
922
|
function reportSecrets(replacements) {
|
|
877
923
|
if (!replacements || !replacements.length) return;
|
|
878
924
|
const names = replacements.map((item) => item.envName);
|
|
@@ -1170,7 +1216,7 @@ async function finaliseRecording(outFile, context) {
|
|
|
1170
1216
|
|
|
1171
1217
|
reportTidying(tidied);
|
|
1172
1218
|
reportSecrets(secrets);
|
|
1173
|
-
keepArtefactsOutOfGit();
|
|
1219
|
+
keepArtefactsOutOfGit(!!(secrets && secrets.length));
|
|
1174
1220
|
await offerToDropLogout(named);
|
|
1175
1221
|
reportReplayRisks(named);
|
|
1176
1222
|
reportFragileSelectors(named);
|
|
@@ -1321,6 +1367,7 @@ async function remove(name) {
|
|
|
1321
1367
|
console.error("");
|
|
1322
1368
|
return 1;
|
|
1323
1369
|
}
|
|
1370
|
+
if (!openStore()) return 1;
|
|
1324
1371
|
|
|
1325
1372
|
const gone = [];
|
|
1326
1373
|
const failed = [];
|
|
@@ -1494,6 +1541,9 @@ async function record(url) {
|
|
|
1494
1541
|
// gets a folder set up; before the browser, which is the expensive part.
|
|
1495
1542
|
const setup = await prepareFolder(url);
|
|
1496
1543
|
if (setup !== null) return setup;
|
|
1544
|
+
// Before an old recording can be replaced, because replacing one also drops
|
|
1545
|
+
// its saved result - which has to be found where it now lives.
|
|
1546
|
+
if (!openStore()) return 1;
|
|
1497
1547
|
|
|
1498
1548
|
if (!ensureBrowser()) return 1;
|
|
1499
1549
|
|
|
@@ -1705,6 +1755,7 @@ function check(options) {
|
|
|
1705
1755
|
for (const line of project.noProjectLines(here)) console.error(line);
|
|
1706
1756
|
return 1;
|
|
1707
1757
|
}
|
|
1758
|
+
if (!openStore()) return 1;
|
|
1708
1759
|
|
|
1709
1760
|
// Deliberately not a failure. An assistant told to run this after every
|
|
1710
1761
|
// change will read a non-zero exit as "something broke" and start fixing
|
|
@@ -1937,6 +1988,377 @@ function setupAi() {
|
|
|
1937
1988
|
return results.some((r) => r.action === 'failed') ? 1 : 0;
|
|
1938
1989
|
}
|
|
1939
1990
|
|
|
1991
|
+
// --- code -------------------------------------------------------------------
|
|
1992
|
+
//
|
|
1993
|
+
// A read of the project's own frontend code for text inserted as HTML
|
|
1994
|
+
// (kryptheon-code.js). Nothing is run and nothing is sent; every finding is
|
|
1995
|
+
// "verification required", so the exit code is 0 unless the read could not
|
|
1996
|
+
// happen at all - a script must never take an unproven risk for a break.
|
|
1997
|
+
|
|
1998
|
+
// How many fix prompts are printed in full. The rest are saved in the store
|
|
1999
|
+
// and shown by the dashboard; forty prompts in a terminal is a wall nobody reads.
|
|
2000
|
+
const CODE_PROMPTS_SHOWN = 5;
|
|
2001
|
+
|
|
2002
|
+
// The read itself, saved to the store. Shared by the command and the
|
|
2003
|
+
// dashboard's "Run now", so both leave the same record behind.
|
|
2004
|
+
function readCodeAndSave(opened) {
|
|
2005
|
+
const reader = require(path.join(PACKAGE_DIR, 'kryptheon-code.js'));
|
|
2006
|
+
const result = reader.scanProject(USER_DIR, { packageDir: PACKAGE_DIR });
|
|
2007
|
+
if (!result.ran) return result;
|
|
2008
|
+
try {
|
|
2009
|
+
fs.writeFileSync(opened.codeFindings, JSON.stringify({
|
|
2010
|
+
checkedAt: new Date().toISOString(),
|
|
2011
|
+
filesRead: result.filesRead,
|
|
2012
|
+
findings: result.findings,
|
|
2013
|
+
unreadable: result.unreadable,
|
|
2014
|
+
}, null, 2) + String.fromCharCode(10), 'utf8');
|
|
2015
|
+
} catch (err) {
|
|
2016
|
+
result.saveError = err.message;
|
|
2017
|
+
}
|
|
2018
|
+
return result;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
function codeRead() {
|
|
2022
|
+
const here = project.inspectProject(USER_DIR);
|
|
2023
|
+
if (!here.ok) {
|
|
2024
|
+
for (const line of project.noProjectLines(here)) console.error(line);
|
|
2025
|
+
return 1;
|
|
2026
|
+
}
|
|
2027
|
+
const opened = openStore();
|
|
2028
|
+
if (!opened) return 1;
|
|
2029
|
+
|
|
2030
|
+
const result = readCodeAndSave(opened);
|
|
2031
|
+
if (!result.ran) {
|
|
2032
|
+
console.error('');
|
|
2033
|
+
console.error(' I could not read your code this time: ' + result.why + '.');
|
|
2034
|
+
console.error(' Nothing was checked - this is not a pass.');
|
|
2035
|
+
console.error('');
|
|
2036
|
+
return 2;
|
|
2037
|
+
}
|
|
2038
|
+
if (result.saveError) console.error(' (could not save this read: ' + result.saveError + ')');
|
|
2039
|
+
|
|
2040
|
+
const high = result.findings.filter((f) => f.severity === 'HIGH');
|
|
2041
|
+
const medium = result.findings.filter((f) => f.severity !== 'HIGH');
|
|
2042
|
+
console.log('');
|
|
2043
|
+
console.log(' I read ' + result.filesRead + ' file' + (result.filesRead === 1 ? '' : 's') + ' of your app\'s code.');
|
|
2044
|
+
if (!result.findings.length) {
|
|
2045
|
+
console.log(' I found no place where text that could come from outside the page is');
|
|
2046
|
+
console.log(' inserted as HTML. That is what this read looks for, and only that.');
|
|
2047
|
+
} else {
|
|
2048
|
+
console.log(' ' + result.findings.length + ' place' + (result.findings.length === 1 ? '' : 's') +
|
|
2049
|
+
' where text may be inserted as HTML (' + high.length + ' HIGH, ' + medium.length + ' MEDIUM).');
|
|
2050
|
+
console.log(' I did not run anything, so each one needs a look before it is called a bug.');
|
|
2051
|
+
for (const f of high.concat(medium)) {
|
|
2052
|
+
console.log('');
|
|
2053
|
+
console.log(' ' + f.severity + ' ' + f.file + ':' + f.line + ' (verification required)');
|
|
2054
|
+
console.log(' ' + f.headline);
|
|
2055
|
+
console.log(' ' + f.sink + ': ' + f.expression);
|
|
2056
|
+
}
|
|
2057
|
+
const prompted = high.slice(0, CODE_PROMPTS_SHOWN);
|
|
2058
|
+
if (prompted.length) {
|
|
2059
|
+
console.log('');
|
|
2060
|
+
console.log(' ' + '-'.repeat(68));
|
|
2061
|
+
console.log(' For the HIGH ones, paste this into your AI tool - one at a time:');
|
|
2062
|
+
for (const f of prompted) {
|
|
2063
|
+
console.log('');
|
|
2064
|
+
f.fixPrompt.split(String.fromCharCode(10)).forEach((l) => console.log(' ' + l));
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
if (result.findings.length > prompted.length) {
|
|
2068
|
+
console.log('');
|
|
2069
|
+
console.log(' The fix for every one is saved in:');
|
|
2070
|
+
console.log(' ' + opened.codeFindings);
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
if (result.unreadable.length) {
|
|
2074
|
+
console.log('');
|
|
2075
|
+
console.log(' What I could not read (unknown, not clear):');
|
|
2076
|
+
for (const u of result.unreadable) console.log(' ' + u.file + ' - ' + u.why);
|
|
2077
|
+
}
|
|
2078
|
+
console.log('');
|
|
2079
|
+
return 0;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// --- verify -----------------------------------------------------------------
|
|
2083
|
+
//
|
|
2084
|
+
// The command for the end of an AI task: what changed since Kryptheon last
|
|
2085
|
+
// looked, which checks that calls for, run the ones that can run here, and
|
|
2086
|
+
// say what broke and what got fixed. "The AI said done" is not the answer;
|
|
2087
|
+
// this is. The looking itself is kryptheon-look.js, shared with the dashboard.
|
|
2088
|
+
|
|
2089
|
+
function verify() {
|
|
2090
|
+
const here = project.inspectProject(USER_DIR);
|
|
2091
|
+
if (!here.ok) {
|
|
2092
|
+
for (const line of project.noProjectLines(here)) console.error(line);
|
|
2093
|
+
return 1;
|
|
2094
|
+
}
|
|
2095
|
+
const opened = openStore();
|
|
2096
|
+
if (!opened) return 1;
|
|
2097
|
+
const looker = require(path.join(PACKAGE_DIR, 'kryptheon-look.js'));
|
|
2098
|
+
|
|
2099
|
+
const result = looker.look(USER_DIR, {
|
|
2100
|
+
beforeRun: (diff, now) => {
|
|
2101
|
+
console.log('');
|
|
2102
|
+
if (diff.first) {
|
|
2103
|
+
console.log(' First look at this project: ' + Object.keys(now.files).length + ' files noted.');
|
|
2104
|
+
console.log(' Next time, I will say exactly what changed since now.');
|
|
2105
|
+
} else if (!diff.files.length) {
|
|
2106
|
+
console.log(' Nothing changed since the last look.');
|
|
2107
|
+
} else {
|
|
2108
|
+
console.log(' WHAT CHANGED ' + diff.files.length + ' file' + (diff.files.length === 1 ? '' : 's') + ', in: ' + diff.parts.join(', '));
|
|
2109
|
+
for (const part of diff.parts) {
|
|
2110
|
+
const inPart = diff.files.filter((f) => f.part === part);
|
|
2111
|
+
console.log('');
|
|
2112
|
+
console.log(' ' + part);
|
|
2113
|
+
for (const f of inPart.slice(0, 15)) console.log(' ' + f.state.padEnd(9) + f.path);
|
|
2114
|
+
if (inPart.length > 15) console.log(' ... and ' + (inPart.length - 15) + ' more');
|
|
2115
|
+
if (part === 'dependencies') {
|
|
2116
|
+
for (const d of diff.dependencies) {
|
|
2117
|
+
console.log(' package ' + d.name + ': ' + (d.change === 'added' ? 'added ' + d.to : d.change === 'removed' ? 'removed (was ' + d.from + ')' : d.from + ' -> ' + d.to));
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
},
|
|
2123
|
+
frontend: () => readCodeAndSave(opened),
|
|
2124
|
+
regression: () => {
|
|
2125
|
+
console.log('');
|
|
2126
|
+
console.log(' Replaying your recordings...');
|
|
2127
|
+
const r = spawnSync(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'check', '--quiet'], {
|
|
2128
|
+
cwd: USER_DIR,
|
|
2129
|
+
stdio: 'inherit',
|
|
2130
|
+
env: process.env,
|
|
2131
|
+
});
|
|
2132
|
+
return r.status;
|
|
2133
|
+
},
|
|
2134
|
+
}, { by: 'verify' });
|
|
2135
|
+
|
|
2136
|
+
const { diff, plan, fresh, fixed, removed, openConfirmed, after } = result;
|
|
2137
|
+
console.log('');
|
|
2138
|
+
if (plan.length) {
|
|
2139
|
+
console.log(' CHECKS');
|
|
2140
|
+
for (const step of plan) console.log(' ' + step.id.padEnd(12) + step.state);
|
|
2141
|
+
} else if (!diff.first && diff.files.length) {
|
|
2142
|
+
console.log(' CHECKS none apply to this change (' + diff.parts.join(', ') + ').');
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
console.log('');
|
|
2146
|
+
if (diff.first) {
|
|
2147
|
+
console.log(' STARTING POINT ' + after.findings.length + ' finding' + (after.findings.length === 1 ? '' : 's') +
|
|
2148
|
+
' already here (see npx kryptheon dashboard). Only what changes from now on will be called new.');
|
|
2149
|
+
} else {
|
|
2150
|
+
if (fresh.length) {
|
|
2151
|
+
console.log(' WHAT BROKE ' + fresh.length + ' new');
|
|
2152
|
+
for (const f of fresh) {
|
|
2153
|
+
console.log(' ' + f.severity + ' ' + f.status + ' ' + f.where);
|
|
2154
|
+
console.log(' ' + f.headline);
|
|
2155
|
+
}
|
|
2156
|
+
} else {
|
|
2157
|
+
console.log(' WHAT BROKE nothing new that the checks above could see.');
|
|
2158
|
+
}
|
|
2159
|
+
if (fixed.length) {
|
|
2160
|
+
console.log('');
|
|
2161
|
+
console.log(' WHAT GOT FIXED ' + fixed.length);
|
|
2162
|
+
for (const f of fixed) console.log(' ' + f.where + ' ' + f.headline);
|
|
2163
|
+
}
|
|
2164
|
+
if (removed.length) {
|
|
2165
|
+
console.log('');
|
|
2166
|
+
console.log(' GONE WITH ITS FILE ' + removed.length + ' (the file was deleted - not the same as fixed)');
|
|
2167
|
+
for (const f of removed) console.log(' ' + f.where + ' ' + f.headline);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
if (openConfirmed.length) {
|
|
2171
|
+
console.log('');
|
|
2172
|
+
console.log(' Still broken: ' + openConfirmed.length + ' confirmed problem' + (openConfirmed.length === 1 ? '' : 's') + '. The fix prompts are in the dashboard.');
|
|
2173
|
+
}
|
|
2174
|
+
console.log('');
|
|
2175
|
+
if (result.saveError) console.error(' (could not save this look: ' + result.saveError + ')');
|
|
2176
|
+
|
|
2177
|
+
// Non-zero while anything confirmed is broken - new or not - so an AI tool
|
|
2178
|
+
// reading only the exit code is never told its work left things fine when
|
|
2179
|
+
// they are not. Findings that only need a look do not count.
|
|
2180
|
+
return openConfirmed.length || result.regressionExit === 1 ? 1 : 0;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// --- recheck ----------------------------------------------------------------
|
|
2184
|
+
//
|
|
2185
|
+
// The end of the fix loop: prove a fix. The finding's own check runs again,
|
|
2186
|
+
// and so does everything the changes since call for, so "fixed" never means
|
|
2187
|
+
// "the code changed", and a fix that broke something else says so.
|
|
2188
|
+
|
|
2189
|
+
function replayForLoop() {
|
|
2190
|
+
console.log('');
|
|
2191
|
+
console.log(' Replaying your recordings...');
|
|
2192
|
+
const r = spawnSync(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'check', '--quiet'], {
|
|
2193
|
+
cwd: USER_DIR,
|
|
2194
|
+
stdio: 'inherit',
|
|
2195
|
+
env: process.env,
|
|
2196
|
+
});
|
|
2197
|
+
return r.status;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
const VERDICT_WORDS = {
|
|
2201
|
+
fixed: 'FIXED',
|
|
2202
|
+
'still open': 'STILL OPEN',
|
|
2203
|
+
'gone with its file': 'GONE WITH ITS FILE (not the same as fixed)',
|
|
2204
|
+
'could not confirm': 'COULD NOT CONFIRM',
|
|
2205
|
+
};
|
|
2206
|
+
|
|
2207
|
+
function recheckCommand(id) {
|
|
2208
|
+
const here = project.inspectProject(USER_DIR);
|
|
2209
|
+
if (!here.ok) {
|
|
2210
|
+
for (const line of project.noProjectLines(here)) console.error(line);
|
|
2211
|
+
return 1;
|
|
2212
|
+
}
|
|
2213
|
+
const opened = openStore();
|
|
2214
|
+
if (!opened) return 1;
|
|
2215
|
+
const looker = require(path.join(PACKAGE_DIR, 'kryptheon-look.js'));
|
|
2216
|
+
const dash = require(path.join(PACKAGE_DIR, 'kryptheon-dashboard.js'));
|
|
2217
|
+
|
|
2218
|
+
if (!id) {
|
|
2219
|
+
const open = dash.buildState(USER_DIR).findings;
|
|
2220
|
+
console.log('');
|
|
2221
|
+
if (!open.length) {
|
|
2222
|
+
console.log(' Nothing is open. There is nothing to re-check.');
|
|
2223
|
+
} else {
|
|
2224
|
+
console.log(' Open findings - re-check one after fixing it: npx kryptheon recheck <id>');
|
|
2225
|
+
console.log('');
|
|
2226
|
+
for (const f of open) console.log(' ' + f.id + ' ' + f.severity.padEnd(8) + f.status.padEnd(23) + f.where + ' ' + f.headline);
|
|
2227
|
+
}
|
|
2228
|
+
console.log('');
|
|
2229
|
+
return 0;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
const r = looker.recheck(USER_DIR, { frontend: () => readCodeAndSave(opened), regression: replayForLoop }, String(id).trim());
|
|
2233
|
+
console.log('');
|
|
2234
|
+
if (!r.found) {
|
|
2235
|
+
console.error(' There is no open finding with the id ' + id + '.');
|
|
2236
|
+
console.error(' npx kryptheon recheck, on its own, lists the ones that are open.');
|
|
2237
|
+
console.error('');
|
|
2238
|
+
return 1;
|
|
2239
|
+
}
|
|
2240
|
+
console.log(' RE-CHECK ' + r.id + ' ' + r.where);
|
|
2241
|
+
console.log(' ' + r.headline);
|
|
2242
|
+
console.log('');
|
|
2243
|
+
console.log(' VERDICT ' + VERDICT_WORDS[r.verdict] + (r.why ? ' - ' + r.why : ''));
|
|
2244
|
+
if (r.newProblems.length) {
|
|
2245
|
+
console.log('');
|
|
2246
|
+
console.log(' NEW PROBLEMS ' + r.newProblems.length + ' appeared with this change:');
|
|
2247
|
+
for (const p of r.newProblems) console.log(' ' + p.id + ' ' + p.severity + ' ' + p.status + ' ' + p.where + ' ' + p.headline);
|
|
2248
|
+
} else if (r.verdict === 'fixed') {
|
|
2249
|
+
console.log('');
|
|
2250
|
+
console.log(' Nothing new broke that the checks could see.');
|
|
2251
|
+
}
|
|
2252
|
+
console.log('');
|
|
2253
|
+
if (r.verdict === 'could not confirm') return 2;
|
|
2254
|
+
return r.verdict === 'fixed' && !r.newProblems.length ? 0 : 1;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// --- dashboard --------------------------------------------------------------
|
|
2258
|
+
//
|
|
2259
|
+
// One page on this machine for everything Kryptheon knows about this project
|
|
2260
|
+
// (kryptheon-dashboard.js). Runs until Ctrl+C.
|
|
2261
|
+
|
|
2262
|
+
const DASHBOARD_PORT = 4789;
|
|
2263
|
+
|
|
2264
|
+
function runFromDashboard(id, opened) {
|
|
2265
|
+
if (id === 'frontend') {
|
|
2266
|
+
const result = readCodeAndSave(opened);
|
|
2267
|
+
return result.ran ? { ok: true } : { ok: false, why: result.why };
|
|
2268
|
+
}
|
|
2269
|
+
if (id === 'regression') {
|
|
2270
|
+
// The same command a person types, as a separate process, so the page
|
|
2271
|
+
// stays answerable while a browser replays the flows.
|
|
2272
|
+
return new Promise((resolve) => {
|
|
2273
|
+
const child = spawn(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'check', '--quiet'], {
|
|
2274
|
+
cwd: USER_DIR,
|
|
2275
|
+
stdio: 'ignore',
|
|
2276
|
+
env: process.env,
|
|
2277
|
+
});
|
|
2278
|
+
child.on('error', (err) => resolve({ ok: false, why: err.message }));
|
|
2279
|
+
// A run that found broken flows is a run that finished; its results are
|
|
2280
|
+
// in the history the page reads next.
|
|
2281
|
+
child.on('close', (code) => resolve(code === 0 || code === 1 ? { ok: true, exit: code } : { ok: false, why: 'the run stopped with exit ' + code }));
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
return { ok: false, why: '"' + id + '" cannot be run from the dashboard' };
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
async function dashboardCommand() {
|
|
2288
|
+
const here = project.inspectProject(USER_DIR);
|
|
2289
|
+
if (!here.ok) {
|
|
2290
|
+
for (const line of project.noProjectLines(here)) console.error(line);
|
|
2291
|
+
return 1;
|
|
2292
|
+
}
|
|
2293
|
+
const opened = openStore();
|
|
2294
|
+
if (!opened) return 1;
|
|
2295
|
+
const dash = require(path.join(PACKAGE_DIR, 'kryptheon-dashboard.js'));
|
|
2296
|
+
const app = dash.createServer(USER_DIR, {
|
|
2297
|
+
run: (id) => runFromDashboard(id, opened),
|
|
2298
|
+
// The same `recheck` a person types, in its own process, so the page stays
|
|
2299
|
+
// answerable while a flow is replayed. Its verdict is read from the store.
|
|
2300
|
+
recheck: (id) => new Promise((resolve) => {
|
|
2301
|
+
// Only a verdict written by this re-check counts: an older one for the
|
|
2302
|
+
// same finding, read after a child that failed early, would be a stale
|
|
2303
|
+
// answer shown as a fresh one.
|
|
2304
|
+
const startedAt = new Date().toISOString();
|
|
2305
|
+
const child = spawn(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'recheck', id], {
|
|
2306
|
+
cwd: USER_DIR,
|
|
2307
|
+
stdio: 'ignore',
|
|
2308
|
+
env: process.env,
|
|
2309
|
+
});
|
|
2310
|
+
child.on('error', (err) => resolve({ ok: false, why: err.message }));
|
|
2311
|
+
child.on('close', () => {
|
|
2312
|
+
let last = null;
|
|
2313
|
+
try {
|
|
2314
|
+
const lines = fs.readFileSync(opened.fixes, 'utf8').split(String.fromCharCode(10)).filter(Boolean);
|
|
2315
|
+
for (let i = lines.length - 1; i >= 0 && !last; i--) {
|
|
2316
|
+
const a = JSON.parse(lines[i]);
|
|
2317
|
+
if (a.id === id && a.at >= startedAt) last = a;
|
|
2318
|
+
}
|
|
2319
|
+
} catch (err) {
|
|
2320
|
+
last = null;
|
|
2321
|
+
}
|
|
2322
|
+
resolve(last || { ok: false, why: 'the re-check did not record a verdict' });
|
|
2323
|
+
});
|
|
2324
|
+
}),
|
|
2325
|
+
});
|
|
2326
|
+
let port;
|
|
2327
|
+
try {
|
|
2328
|
+
port = await app.listen(DASHBOARD_PORT);
|
|
2329
|
+
} catch (err) {
|
|
2330
|
+
// Taken - by another project's dashboard, most likely. Any free port will do.
|
|
2331
|
+
port = await app.listen(0);
|
|
2332
|
+
}
|
|
2333
|
+
console.log('');
|
|
2334
|
+
console.log(' Kryptheon dashboard for ' + path.basename(USER_DIR) + ':');
|
|
2335
|
+
console.log('');
|
|
2336
|
+
console.log(' http://127.0.0.1:' + port + '/');
|
|
2337
|
+
console.log('');
|
|
2338
|
+
console.log(' Open that address in your browser. It is only reachable from this');
|
|
2339
|
+
console.log(' machine. Press Ctrl+C here to stop it.');
|
|
2340
|
+
console.log('');
|
|
2341
|
+
|
|
2342
|
+
// While the page is open, Kryptheon notices changes by itself: once a change
|
|
2343
|
+
// has settled, it looks, runs the fast checks, and the page shows what
|
|
2344
|
+
// changed and what came of it. Recordings are replayed only from the page's
|
|
2345
|
+
// Run now - a replay while the app is down would call every flow broken.
|
|
2346
|
+
const looker = require(path.join(PACKAGE_DIR, 'kryptheon-look.js'));
|
|
2347
|
+
looker.startWatch(USER_DIR, {
|
|
2348
|
+
frontend: () => readCodeAndSave(opened),
|
|
2349
|
+
regression: () => null,
|
|
2350
|
+
}, {
|
|
2351
|
+
onLook: (r) => {
|
|
2352
|
+
if (r.diff.first) return;
|
|
2353
|
+
const when = new Date().toLocaleTimeString();
|
|
2354
|
+
console.log(' ' + when + ' ' + r.diff.files.length + ' file' + (r.diff.files.length === 1 ? '' : 's') + ' changed (' +
|
|
2355
|
+
r.diff.parts.join(', ') + ') - ' + r.fresh.length + ' new, ' + r.fixed.length + ' fixed. See the page.');
|
|
2356
|
+
},
|
|
2357
|
+
onError: (err) => console.error(' (could not look at a change: ' + err.message + ')'),
|
|
2358
|
+
});
|
|
2359
|
+
return new Promise(() => {});
|
|
2360
|
+
}
|
|
2361
|
+
|
|
1940
2362
|
// --- accept -----------------------------------------------------------------
|
|
1941
2363
|
|
|
1942
2364
|
function baselineApi() {
|
|
@@ -1983,6 +2405,9 @@ function listBaselines(api) {
|
|
|
1983
2405
|
}
|
|
1984
2406
|
|
|
1985
2407
|
function accept(name) {
|
|
2408
|
+
// Only where there are recordings: anywhere else there is nothing to accept,
|
|
2409
|
+
// and no reason to make a store for a folder that is not a project.
|
|
2410
|
+
if (fs.existsSync(TESTS_DIR) && !openStore()) return 1;
|
|
1986
2411
|
const api = baselineApi();
|
|
1987
2412
|
if (!api) return 1;
|
|
1988
2413
|
if (!name) return listBaselines(api);
|
|
@@ -2121,6 +2546,26 @@ function handToFolderCopy() {
|
|
|
2121
2546
|
}
|
|
2122
2547
|
}
|
|
2123
2548
|
|
|
2549
|
+
// A copy from before the store looks for its records inside the project. If
|
|
2550
|
+
// they have already moved out, it finds none, takes every recording as new,
|
|
2551
|
+
// and learns whatever the app does today as correct - a broken page would
|
|
2552
|
+
// pass. So it is not run; the one fix is to bring it up to date.
|
|
2553
|
+
if (project.olderVersion(theirVersion, FIRST_STORE_VERSION)) {
|
|
2554
|
+
const kept = store.pathsFor(USER_DIR);
|
|
2555
|
+
if (fs.existsSync(kept.baselines) || fs.existsSync(kept.history)) {
|
|
2556
|
+
console.error('');
|
|
2557
|
+
console.error(' This folder has kryptheon ' + theirVersion + ', which looks for its saved results');
|
|
2558
|
+
console.error(' inside the project. They have moved out, to');
|
|
2559
|
+
console.error(' ' + kept.dir);
|
|
2560
|
+
console.error(' so ' + theirVersion + ' would see none, treat every recording as new, and could');
|
|
2561
|
+
console.error(' miss a real change. Nothing was checked. Update this folder\'s copy:');
|
|
2562
|
+
console.error(' npm i -D kryptheon@latest');
|
|
2563
|
+
console.error(' then run this again.');
|
|
2564
|
+
console.error('');
|
|
2565
|
+
return 1;
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2124
2569
|
if (theirVersion !== mine) {
|
|
2125
2570
|
// Not an error: the folder's copy is the one its recordings are written
|
|
2126
2571
|
// against. Said, so an older one is never mistaken for this one.
|
|
@@ -2173,12 +2618,27 @@ async function main() {
|
|
|
2173
2618
|
case 'accept':
|
|
2174
2619
|
return finishWith(accept(rest.join(' ').trim()));
|
|
2175
2620
|
break;
|
|
2621
|
+
case 'code':
|
|
2622
|
+
return finishWith(codeRead());
|
|
2623
|
+
case 'verify':
|
|
2624
|
+
return finishWith(verify());
|
|
2625
|
+
case 'recheck':
|
|
2626
|
+
return finishWith(recheckCommand(rest[0]));
|
|
2176
2627
|
case '-v':
|
|
2177
2628
|
case '--version':
|
|
2178
2629
|
case 'version':
|
|
2179
2630
|
console.log(packageVersion());
|
|
2180
2631
|
return finishWith(0);
|
|
2632
|
+
case 'dashboard':
|
|
2633
|
+
return finishWith(await dashboardCommand());
|
|
2181
2634
|
case undefined:
|
|
2635
|
+
// One command starts Kryptheon - in a terminal a person is looking at.
|
|
2636
|
+
// Run by a script or an AI tool there is nobody to open a page for, and
|
|
2637
|
+
// a server that never exits would hang it, so it prints the usage as it
|
|
2638
|
+
// always did.
|
|
2639
|
+
if (process.stdin.isTTY && process.stdout.isTTY) return finishWith(await dashboardCommand());
|
|
2640
|
+
usage();
|
|
2641
|
+
return finishWith(0);
|
|
2182
2642
|
case '-h':
|
|
2183
2643
|
case '--help':
|
|
2184
2644
|
case 'help':
|