kryptheon 0.1.28 → 0.1.29
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 +16 -2
- package/kryptheon-baselines.js +56 -0
- package/kryptheon-code.js +119 -4
- package/package.json +2 -2
package/bin/kryptheon.js
CHANGED
|
@@ -2659,8 +2659,22 @@ function accept(name) {
|
|
|
2659
2659
|
|
|
2660
2660
|
console.error('');
|
|
2661
2661
|
if (result.reason === 'nothing-pending') {
|
|
2662
|
-
|
|
2663
|
-
|
|
2662
|
+
// Said from the last run of this test, not assumed: a run that broke on
|
|
2663
|
+
// a step is not one that matched (see baselines.nothingToAcceptLines).
|
|
2664
|
+
const last = baselines.lastRunOf(store.pathsFor(USER_DIR).history, key);
|
|
2665
|
+
baselines.nothingToAcceptLines(titleOf(key), last).forEach((paragraph, i) => {
|
|
2666
|
+
if (i) console.error('');
|
|
2667
|
+
let current = '';
|
|
2668
|
+
for (const word of paragraph.split(' ')) {
|
|
2669
|
+
if ((current + ' ' + word).trim().length > 72) {
|
|
2670
|
+
console.error(' ' + current.trim());
|
|
2671
|
+
current = word;
|
|
2672
|
+
} else {
|
|
2673
|
+
current = (current + ' ' + word).trim();
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
if (current) console.error(' ' + current);
|
|
2677
|
+
});
|
|
2664
2678
|
} else if (result.reason === 'no-entry') {
|
|
2665
2679
|
console.error(' "' + titleOf(key) + '" has no saved result yet.');
|
|
2666
2680
|
} else {
|
package/kryptheon-baselines.js
CHANGED
|
@@ -166,6 +166,60 @@ function pruneLines(dropped) {
|
|
|
166
166
|
return lines;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The last run of one recorded test, from the run history: whether it
|
|
171
|
+
* passed, and where it broke if it did not. Null when it has never run.
|
|
172
|
+
*/
|
|
173
|
+
function lastRunOf(historyFile, key) {
|
|
174
|
+
let lines = [];
|
|
175
|
+
try {
|
|
176
|
+
lines = fs.readFileSync(historyFile, 'utf8').split('\n').filter(Boolean);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const spec = specOf(key);
|
|
181
|
+
const title = titleOf(key);
|
|
182
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
183
|
+
let entry;
|
|
184
|
+
try {
|
|
185
|
+
entry = JSON.parse(lines[i]);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const test = (entry.tests || []).find((t) => t && t.title === title && specOf(t.specFile) === spec);
|
|
190
|
+
if (test) return { status: test.status, at: entry.runAt, failure: test.failure || null };
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What accept says when there is nothing it can take, from the last run.
|
|
197
|
+
*
|
|
198
|
+
* Found on a blind test (HelixOps): a step was broken on purpose, and accept
|
|
199
|
+
* answered "it last matched its saved result" - the opposite of what had
|
|
200
|
+
* happened. accept only takes a run that went all the way through and ended
|
|
201
|
+
* on a different page; a step that no longer matches is a changed flow or a
|
|
202
|
+
* broken app, and neither is accepted.
|
|
203
|
+
*/
|
|
204
|
+
function nothingToAcceptLines(name, last) {
|
|
205
|
+
if (last && last.status === 'failed') {
|
|
206
|
+
const failure = last.failure || {};
|
|
207
|
+
const where = failure.line ? ' at line ' + failure.line + (failure.file ? ' of ' + specOf(failure.file) : '') : '';
|
|
208
|
+
const what = failure.plainLanguage ? ' (' + String(failure.plainLanguage).replace(/\.$/, '') + ')' : '';
|
|
209
|
+
return [
|
|
210
|
+
'"' + name + '" has nothing to accept: its last run broke' + where + ', before the end' + what + '.',
|
|
211
|
+
'accept only covers a run that went all the way through and finished on a different page - ' +
|
|
212
|
+
'a new address or a new title.',
|
|
213
|
+
'A step that no longer matches cannot be accepted. If the flow changed on purpose, record it ' +
|
|
214
|
+
'again (npx kryptheon record). If it did not, the app broke, and npx kryptheon check says what to fix.',
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
if (last && last.status === 'passed') {
|
|
218
|
+
return ['"' + name + '" has no new result waiting.', 'Nothing to accept - its last run matched its saved result.'];
|
|
219
|
+
}
|
|
220
|
+
return ['"' + name + '" has no new result waiting.', 'Nothing to accept - no run has finished on a different page.'];
|
|
221
|
+
}
|
|
222
|
+
|
|
169
223
|
module.exports = {
|
|
170
224
|
specOf: specOf,
|
|
171
225
|
titleOf: titleOf,
|
|
@@ -173,4 +227,6 @@ module.exports = {
|
|
|
173
227
|
classifyBaselines: classifyBaselines,
|
|
174
228
|
pruneBaselines: pruneBaselines,
|
|
175
229
|
pruneLines: pruneLines,
|
|
230
|
+
lastRunOf: lastRunOf,
|
|
231
|
+
nothingToAcceptLines: nothingToAcceptLines,
|
|
176
232
|
};
|
package/kryptheon-code.js
CHANGED
|
@@ -412,7 +412,7 @@ function riskyParts(node, scope, code, aliases, out, depth) {
|
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
414
|
const text = code.slice(node.start, node.end).replace(/\s+/g, ' ').slice(0, 80);
|
|
415
|
-
if (!out.some((p) => p.text === text)) out.push({ text: text, origin: origin });
|
|
415
|
+
if (!out.some((p) => p.text === text)) out.push({ text: text, origin: origin, line: node.loc ? node.loc.start.line : null });
|
|
416
416
|
}
|
|
417
417
|
|
|
418
418
|
function rootIdentifier(node) {
|
|
@@ -684,7 +684,89 @@ function checkedFirst(p, value, code) {
|
|
|
684
684
|
return false;
|
|
685
685
|
}
|
|
686
686
|
|
|
687
|
-
|
|
687
|
+
/**
|
|
688
|
+
* The helper a value is the result of: a call to a function this file
|
|
689
|
+
* declares, or one it imports from a file of the project. Its returns, each
|
|
690
|
+
* with its scope and its file's code, and the line where the first of them
|
|
691
|
+
* that is not plain text is built. Null for anything else - an import from a
|
|
692
|
+
* package, a default export, a function that cannot be found.
|
|
693
|
+
*/
|
|
694
|
+
function helperOf(value, scope, code, file, lineOffset, resolver) {
|
|
695
|
+
if (!value || !/CallExpression$/.test(value.type) || !value.callee || value.callee.type !== 'Identifier' || !scope) return null;
|
|
696
|
+
const name = value.callee.name;
|
|
697
|
+
let fnPath = localFunction(name, scope);
|
|
698
|
+
let helper = { code: code, file: null, lineOffset: lineOffset };
|
|
699
|
+
if (!fnPath) {
|
|
700
|
+
const binding = scope.getBinding(name);
|
|
701
|
+
const spec = binding && binding.kind === 'module' && binding.path && binding.path.node;
|
|
702
|
+
if (!spec || spec.type !== 'ImportSpecifier' || !resolver) return null;
|
|
703
|
+
const from = binding.path.parentPath && binding.path.parentPath.node.source && binding.path.parentPath.node.source.value;
|
|
704
|
+
const imported = spec.imported && (spec.imported.name || spec.imported.value);
|
|
705
|
+
const found = from && imported ? resolver(file, from, imported) : null;
|
|
706
|
+
if (!found) return null;
|
|
707
|
+
fnPath = found.fnPath;
|
|
708
|
+
helper = { code: found.code, file: found.file, lineOffset: 1 };
|
|
709
|
+
}
|
|
710
|
+
const returns = returnsOf(fnPath);
|
|
711
|
+
if (!returns.length) return null;
|
|
712
|
+
const risky = returns.find((r) => classify(r.node, r.scope, helper.code, 0) !== 'safe') || returns[0];
|
|
713
|
+
return {
|
|
714
|
+
fn: name,
|
|
715
|
+
file: helper.file,
|
|
716
|
+
line: (risky.node.loc ? risky.node.loc.start.line : 1) + helper.lineOffset - 1,
|
|
717
|
+
code: helper.code,
|
|
718
|
+
lineOffset: helper.lineOffset,
|
|
719
|
+
returns: returns,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Finds a function another file of the project exports, for helperOf. Parsed
|
|
725
|
+
* once a file; its functions are handed to the map() reader each time, since
|
|
726
|
+
* that table is rebuilt for every file read.
|
|
727
|
+
*/
|
|
728
|
+
function importResolver(parser, root) {
|
|
729
|
+
const parsed = new Map();
|
|
730
|
+
const EXTS = ['', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '/index.js', '/index.ts'];
|
|
731
|
+
return (fromFile, source, name) => {
|
|
732
|
+
if (!/^\.\.?\//.test(source)) return null;
|
|
733
|
+
const base = path.resolve(path.dirname(fromFile), source);
|
|
734
|
+
const full = EXTS.map((ext) => base + ext).find((candidate) => {
|
|
735
|
+
try {
|
|
736
|
+
return fs.statSync(candidate).isFile() && CODE_EXT.test(candidate);
|
|
737
|
+
} catch (err) {
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
if (!full) return null;
|
|
742
|
+
if (!parsed.has(full)) {
|
|
743
|
+
let entry = null;
|
|
744
|
+
try {
|
|
745
|
+
if (fs.statSync(full).size <= MAX_BYTES) {
|
|
746
|
+
const code = fs.readFileSync(full, 'utf8');
|
|
747
|
+
const ast = parser.babelParse(code, full, true);
|
|
748
|
+
const fns = [];
|
|
749
|
+
let program = null;
|
|
750
|
+
parser.traverse(ast, {
|
|
751
|
+
Program(p) { program = p.scope; },
|
|
752
|
+
Function(p) { fns.push(p); },
|
|
753
|
+
});
|
|
754
|
+
entry = { code: code, program: program, fns: fns, file: path.relative(root, full).split(path.sep).join('/') };
|
|
755
|
+
}
|
|
756
|
+
} catch (err) {
|
|
757
|
+
entry = null;
|
|
758
|
+
}
|
|
759
|
+
parsed.set(full, entry);
|
|
760
|
+
}
|
|
761
|
+
const entry = parsed.get(full);
|
|
762
|
+
if (!entry || !entry.program) return null;
|
|
763
|
+
for (const p of entry.fns) FN_PATHS.set(p.node, p);
|
|
764
|
+
const fnPath = localFunction(name, entry.program);
|
|
765
|
+
return fnPath ? { fnPath: fnPath, code: entry.code, file: entry.file } : null;
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function sinksIn(parser, code, file, lineOffset, resolver) {
|
|
688
770
|
const found = [];
|
|
689
771
|
const ast = parser.babelParse(code, file, true);
|
|
690
772
|
const lineOf = (node) => (node.loc ? node.loc.start.line : 1) + lineOffset - 1;
|
|
@@ -704,6 +786,25 @@ function sinksIn(parser, code, file, lineOffset) {
|
|
|
704
786
|
viaHelper.push({ node: node, sink: sink, value: value, scope: scope, param: param });
|
|
705
787
|
return;
|
|
706
788
|
}
|
|
789
|
+
// The HTML built by a helper - root.innerHTML = renderBoard(rows) - is
|
|
790
|
+
// judged by what the helper returns, in its own file, and the place it
|
|
791
|
+
// puts a value in is named. Found on a blind test (HelixOps): appShell(),
|
|
792
|
+
// a fixed string in ui.js, was reported as unknown, and renderBoard() was
|
|
793
|
+
// reported at its call with no word of ui.js:13, where the title goes in.
|
|
794
|
+
const built = helperOf(value, scope, code, file, lineOffset, resolver);
|
|
795
|
+
if (built) {
|
|
796
|
+
const origin = worst(built.returns.map((r) => classify(r.node, r.scope, built.code, 0)));
|
|
797
|
+
if (origin === 'safe') return;
|
|
798
|
+
const parts = [];
|
|
799
|
+
for (const r of built.returns) riskyParts(r.node, r.scope, built.code, undefined, parts, 0);
|
|
800
|
+
// The line of the first value that goes in, not of the return around it.
|
|
801
|
+
const first = parts.find((p) => p.line);
|
|
802
|
+
found.push({
|
|
803
|
+
line: lineOf(node), sink: sink, expression: textOf(value), origin: origin, parts: parts,
|
|
804
|
+
helper: { fn: built.fn, file: built.file, line: first ? first.line + built.lineOffset - 1 : built.line },
|
|
805
|
+
});
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
707
808
|
const origin = classify(value, scope, code, 0);
|
|
708
809
|
if (origin === 'safe') return;
|
|
709
810
|
found.push({ line: lineOf(node), sink: sink, expression: textOf(value), origin: origin, parts: partsOf(value, scope) });
|
|
@@ -834,6 +935,7 @@ function scanProject(root, options) {
|
|
|
834
935
|
}
|
|
835
936
|
}
|
|
836
937
|
const loadedBy = keys.scriptsLoadedBy(pages);
|
|
938
|
+
const resolver = importResolver(parser, root);
|
|
837
939
|
for (const full of files) {
|
|
838
940
|
let size = 0;
|
|
839
941
|
try {
|
|
@@ -852,7 +954,7 @@ function scanProject(root, options) {
|
|
|
852
954
|
const pieces = PAGE_EXT.test(full) ? scriptsIn(source) : [{ code: source, line: 1 }];
|
|
853
955
|
for (const piece of pieces) {
|
|
854
956
|
try {
|
|
855
|
-
for (const f of sinksIn(parser, piece.code, full, piece.line)) findings.push(Object.assign({ file: rel }, f));
|
|
957
|
+
for (const f of sinksIn(parser, piece.code, full, piece.line, resolver)) findings.push(Object.assign({ file: rel }, f));
|
|
856
958
|
} catch (err) {
|
|
857
959
|
unreadable.push({ file: rel + (piece.line > 1 ? ':' + piece.line : ''), why: 'could not be parsed: ' + String(err.message).split('\n')[0].slice(0, 120) });
|
|
858
960
|
}
|
|
@@ -931,9 +1033,14 @@ function describe(f) {
|
|
|
931
1033
|
if (f.kind === 'redirect') return describeRedirect(f);
|
|
932
1034
|
const severity = f.origin === 'unknown' ? 'MEDIUM' : 'HIGH';
|
|
933
1035
|
const how = f.sink === 'v-html' ? 'v-html' : f.sink;
|
|
1036
|
+
// Where the helper builds it: its own file when it is another one.
|
|
1037
|
+
const builtAt = f.helper ? (f.helper.file ? f.helper.file + ':' : 'line ') + f.helper.line : '';
|
|
934
1038
|
const where = f.via
|
|
935
1039
|
? 'In ' + f.file + ' at line ' + f.line + ', this value is passed to ' + f.via.fn + '(), which puts ' +
|
|
936
1040
|
'what it is given into the page as HTML with ' + how + ' at line ' + f.via.line + ':'
|
|
1041
|
+
: f.helper
|
|
1042
|
+
? 'In ' + f.file + ' at line ' + f.line + ', the HTML that ' + f.helper.fn + '() builds - at ' + builtAt +
|
|
1043
|
+
' - is put into the page with ' + how + ':'
|
|
937
1044
|
: 'In ' + f.file + ' at line ' + f.line + ', this value is put into the page as HTML with ' + how + ':';
|
|
938
1045
|
// The fix for a helper is different, and getting it wrong breaks the page:
|
|
939
1046
|
// switching the helper to textContent makes every other call - the ones
|
|
@@ -946,6 +1053,12 @@ function describe(f) {
|
|
|
946
1053
|
'this value before it is joined into the HTML (replace & < > " \' with their HTML entities), ' +
|
|
947
1054
|
'or pass it through a sanitizer such as DOMPurify if it must keep some formatting.',
|
|
948
1055
|
]
|
|
1056
|
+
: f.helper
|
|
1057
|
+
? [
|
|
1058
|
+
'Fix it inside ' + f.helper.fn + '() at ' + builtAt + ', where the values are joined into the HTML: ' +
|
|
1059
|
+
'escape each dynamic value there (replace & < > " \' with their HTML entities), or pass the ' +
|
|
1060
|
+
'result through a sanitizer such as DOMPurify if it must keep some formatting.',
|
|
1061
|
+
]
|
|
949
1062
|
: [
|
|
950
1063
|
'Fix it so the value is always shown as text: set textContent instead of innerHTML for the ' +
|
|
951
1064
|
'dynamic part, or escape it before it is joined into HTML (replace & < > " \' with their ' +
|
|
@@ -993,9 +1106,11 @@ function describe(f) {
|
|
|
993
1106
|
origin: f.origin,
|
|
994
1107
|
parts: (f.parts || []).map((p) => ({ text: p.text, origin: p.origin })),
|
|
995
1108
|
via: f.via || null,
|
|
1109
|
+
helper: f.helper || null,
|
|
996
1110
|
headline: 'Text ' + (f.origin === 'unknown' ? 'of unknown origin' : ORIGIN_WORDS[f.origin].split(',')[0]) +
|
|
997
1111
|
' is inserted as HTML in ' + f.file + ':' + f.line +
|
|
998
|
-
(f.via ? ' (through ' + f.via.fn + '() at line ' + f.via.line + ')' : '') +
|
|
1112
|
+
(f.via ? ' (through ' + f.via.fn + '() at line ' + f.via.line + ')' : '') +
|
|
1113
|
+
(f.helper ? ', built by ' + f.helper.fn + '() at ' + builtAt : '') + '.',
|
|
999
1114
|
fixPrompt: fixPrompt,
|
|
1000
1115
|
};
|
|
1001
1116
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"description": "Record what you do in your app and get plain-language reports when it breaks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@playwright/test": "^1.62.1"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
|
-
"check": "node kryptheon-store.check.js && node kryptheon-code.check.js && node kryptheon-keys.check.js && node kryptheon-dashboard.check.js && node kryptheon-changes.check.js && node kryptheon-look.check.js && node kryptheon-verify.check.js && node kryptheon-recheck.check.js && node kryptheon-reporter.check.js && node kryptheon-signature.check.js && node kryptheon-signature.stream.check.js && node kryptheon-signature.baseline.check.js && node kryptheon-signature.settle.check.js && node kryptheon-signature.number.check.js && node kryptheon-env.check.js && node kryptheon-watchdog.check.js && node kryptheon-record.check.js && node kryptheon-selectors.check.js && node kryptheon-recordings.check.js && node kryptheon-cleanup.check.js && node kryptheon-orphan.check.js && node kryptheon-project.check.js && node kryptheon-setup.check.js && node kryptheon-browser.check.js && node kryptheon-heal.check.js && node kryptheon-load.check.js && node kryptheon-package.check.js",
|
|
54
|
+
"check": "node kryptheon-store.check.js && node kryptheon-code.check.js && node kryptheon-accept.check.js && node kryptheon-keys.check.js && node kryptheon-dashboard.check.js && node kryptheon-changes.check.js && node kryptheon-look.check.js && node kryptheon-verify.check.js && node kryptheon-recheck.check.js && node kryptheon-reporter.check.js && node kryptheon-signature.check.js && node kryptheon-signature.stream.check.js && node kryptheon-signature.baseline.check.js && node kryptheon-signature.settle.check.js && node kryptheon-signature.number.check.js && node kryptheon-env.check.js && node kryptheon-watchdog.check.js && node kryptheon-record.check.js && node kryptheon-selectors.check.js && node kryptheon-recordings.check.js && node kryptheon-cleanup.check.js && node kryptheon-orphan.check.js && node kryptheon-project.check.js && node kryptheon-setup.check.js && node kryptheon-browser.check.js && node kryptheon-heal.check.js && node kryptheon-load.check.js && node kryptheon-package.check.js",
|
|
55
55
|
"check:store": "node kryptheon-store.check.js",
|
|
56
56
|
"check:code": "node kryptheon-code.check.js",
|
|
57
57
|
"check:dashboard": "node kryptheon-dashboard.check.js",
|