cut-at-k 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/LICENSE +21 -0
- package/README.md +127 -0
- package/package.json +37 -0
- package/results/record.mjs +144 -0
- package/results/run-ag-ui.mjs +84 -0
- package/results/sever-results.json +4482 -0
- package/src/bytes.js +131 -0
- package/src/index.js +3 -0
- package/src/report.js +117 -0
- package/src/sever.js +101 -0
package/src/bytes.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cut inside a frame, not just between events.
|
|
3
|
+
*
|
|
4
|
+
* Severing between events tests the state machine: does the consumer handle a
|
|
5
|
+
* run that stopped after three events rather than nine. Severing inside a frame
|
|
6
|
+
* tests the parser underneath it, which is a different and usually softer
|
|
7
|
+
* target — a cut in the middle of a UTF-8 sequence, between `data:` and its
|
|
8
|
+
* newline, or halfway through a JSON payload.
|
|
9
|
+
*
|
|
10
|
+
* The interesting offsets are not evenly spread. Most bytes of a frame are
|
|
11
|
+
* unremarkable; the ones that break things sit at structural seams. So this
|
|
12
|
+
* offers both: every offset when you can afford it, and the seams when you
|
|
13
|
+
* cannot.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
|
|
18
|
+
/** Serialise events as an SSE body, one event per frame. */
|
|
19
|
+
export function toSSE(events, { event: eventName } = {}) {
|
|
20
|
+
return events
|
|
21
|
+
.map((e) => {
|
|
22
|
+
const data = typeof e === 'string' ? e : JSON.stringify(e);
|
|
23
|
+
const name = eventName ? `event: ${eventName}\n` : '';
|
|
24
|
+
return `${name}data: ${data}\n\n`;
|
|
25
|
+
})
|
|
26
|
+
.join('');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Offsets worth cutting at, for a body you do not want to cut 40,000 times.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} body
|
|
33
|
+
* @returns {{offset: number, why: string}[]}
|
|
34
|
+
*/
|
|
35
|
+
export function seams(body) {
|
|
36
|
+
const bytes = encoder.encode(body);
|
|
37
|
+
/** @type {Map<number, string>} */
|
|
38
|
+
const found = new Map();
|
|
39
|
+
const note = (offset, why) => {
|
|
40
|
+
if (offset > 0 && offset < bytes.length && !found.has(offset)) found.set(offset, why);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Structural positions in the SSE framing itself.
|
|
44
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
45
|
+
const b = bytes[i];
|
|
46
|
+
|
|
47
|
+
// Inside a multi-byte UTF-8 sequence. A continuation byte is 10xxxxxx, so
|
|
48
|
+
// cutting immediately before one splits a character in half.
|
|
49
|
+
if ((b & 0b1100_0000) === 0b1000_0000) note(i, 'mid-UTF-8 sequence');
|
|
50
|
+
|
|
51
|
+
// Between a field name and its value: `data:` and what follows.
|
|
52
|
+
if (b === 0x3a /* : */) note(i + 1, 'after a field colon');
|
|
53
|
+
|
|
54
|
+
// Between the two newlines that end a frame.
|
|
55
|
+
if (b === 0x0a /* \n */ && bytes[i + 1] === 0x0a) note(i + 1, 'between frame newlines');
|
|
56
|
+
|
|
57
|
+
// Inside the JSON payload, at its own structural marks.
|
|
58
|
+
if (b === 0x7b /* { */) note(i + 1, 'just inside an object');
|
|
59
|
+
if (b === 0x22 /* " */) note(i + 1, 'just inside a string');
|
|
60
|
+
if (b === 0x2c /* , */) note(i + 1, 'after a comma');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return [...found.entries()]
|
|
64
|
+
.map(([offset, why]) => ({ offset, why }))
|
|
65
|
+
.sort((a, b) => a.offset - b.offset);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Cut a serialised body at chosen byte offsets and record what the consumer is
|
|
70
|
+
* left with.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} options
|
|
73
|
+
* @param {string} options.body The whole serialised stream.
|
|
74
|
+
* @param {(bytes: string, label: string) => Promise<import('./sever.js').Observation>} options.replay
|
|
75
|
+
* @param {string} [options.label]
|
|
76
|
+
* @param {'seams' | 'every'} [options.offsets] Which offsets to try. Defaults
|
|
77
|
+
* to the structural seams, which is the version you can run in CI.
|
|
78
|
+
* @param {(a: import('./sever.js').Observation, b: import('./sever.js').Observation) => boolean} [options.same]
|
|
79
|
+
*/
|
|
80
|
+
export async function severAtEveryByte({
|
|
81
|
+
body,
|
|
82
|
+
replay,
|
|
83
|
+
label = 'stream',
|
|
84
|
+
offsets = 'seams',
|
|
85
|
+
same,
|
|
86
|
+
}) {
|
|
87
|
+
if (typeof body !== 'string' || body.length === 0) {
|
|
88
|
+
throw new Error('severAtEveryByte: body must be a non-empty string');
|
|
89
|
+
}
|
|
90
|
+
const indistinguishable = same ?? ((a, b) => a.settled === b.settled);
|
|
91
|
+
const bytes = encoder.encode(body);
|
|
92
|
+
|
|
93
|
+
const points =
|
|
94
|
+
offsets === 'every'
|
|
95
|
+
? Array.from({ length: bytes.length - 1 }, (_, i) => ({ offset: i + 1, why: 'byte' }))
|
|
96
|
+
: seams(body);
|
|
97
|
+
|
|
98
|
+
const whole = await replay(body, `${label}:whole`);
|
|
99
|
+
const cuts = [];
|
|
100
|
+
|
|
101
|
+
for (const { offset, why } of points) {
|
|
102
|
+
// Slice the bytes, then decode. A cut inside a multi-byte sequence leaves a
|
|
103
|
+
// partial character, which is the whole point — decoding with a fatal
|
|
104
|
+
// decoder here would throw away the case being tested.
|
|
105
|
+
const prefix = new TextDecoder('utf-8').decode(bytes.slice(0, offset));
|
|
106
|
+
|
|
107
|
+
let observation;
|
|
108
|
+
let threw;
|
|
109
|
+
try {
|
|
110
|
+
observation = await replay(prefix, `${label}:@${offset}`);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
threw = err instanceof Error ? err.message : String(err);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!observation) {
|
|
116
|
+
cuts.push({ offset, why, of: bytes.length, threw });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
cuts.push({
|
|
121
|
+
offset,
|
|
122
|
+
why,
|
|
123
|
+
of: bytes.length,
|
|
124
|
+
observation,
|
|
125
|
+
settledAlike: indistinguishable(observation, whole),
|
|
126
|
+
terminal: (observation.delivered ?? []).at(-1) ?? '(nothing)',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { label, whole, cuts, points: points.length, bytes: bytes.length };
|
|
131
|
+
}
|
package/src/index.js
ADDED
package/src/report.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const TERMINALS = new Set(['RUN_FINISHED', 'RUN_ERROR']);
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turn a pile of cuts into the few things worth reading.
|
|
5
|
+
*
|
|
6
|
+
* A count of failures is not a finding. Three things have to be separated or
|
|
7
|
+
* the number means nothing:
|
|
8
|
+
*
|
|
9
|
+
* - A cut that lost nothing. Cutting after the last content event and before
|
|
10
|
+
* some trailing bookkeeping removes no information, so of course the
|
|
11
|
+
* consumer reports the same thing. Not a defect.
|
|
12
|
+
* - A stream that is invalid on purpose. Cutting it before its deliberate
|
|
13
|
+
* error removes the error, so the whole stream delivers less than the
|
|
14
|
+
* prefix and every comparison is noise.
|
|
15
|
+
* - A cut that genuinely lost content and was reported as though it had not.
|
|
16
|
+
* That is the finding.
|
|
17
|
+
*
|
|
18
|
+
* The first version of this file reported the first two as findings. On the
|
|
19
|
+
* corpus in results/ that means calling 154 cuts a problem when 54 are real —
|
|
20
|
+
* `node results/run-ag-ui.mjs results/sever-results.json` prints both numbers.
|
|
21
|
+
* A tool that cries wolf is worse than no tool, so the distinction is now the
|
|
22
|
+
* whole design.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {import('./sever.js').CutReport[]} reports
|
|
27
|
+
* @param {object} [options]
|
|
28
|
+
* @param {(label: string) => boolean} [options.ignore]
|
|
29
|
+
* Streams to leave out of the findings — typically the ones a conformance
|
|
30
|
+
* corpus ships as deliberately invalid. Their count is reported separately
|
|
31
|
+
* rather than silently dropped.
|
|
32
|
+
* @param {(cut: import('./sever.js').Observation, whole: import('./sever.js').Observation) => boolean} [options.lostContent]
|
|
33
|
+
* Did this cut actually lose something the consumer would have surfaced?
|
|
34
|
+
* Without it, a cut that changed nothing cannot be told from one that did,
|
|
35
|
+
* and the summary says so instead of guessing.
|
|
36
|
+
*/
|
|
37
|
+
export function summarise(reports, { ignore, lostContent } = {}) {
|
|
38
|
+
const considered = ignore ? reports.filter((r) => !ignore(r.label)) : reports;
|
|
39
|
+
const excluded = reports.length - considered.length;
|
|
40
|
+
|
|
41
|
+
const all = considered.flatMap((r) => r.cuts.map((c) => ({ ...c, label: r.label, whole: r.whole })));
|
|
42
|
+
const threw = all.filter((c) => c.threw);
|
|
43
|
+
const usable = all.filter((c) => !c.threw);
|
|
44
|
+
|
|
45
|
+
// Reported the same as the whole run. On its own this is not yet a finding.
|
|
46
|
+
const reportedAlike = usable.filter((c) => c.settledAlike === true);
|
|
47
|
+
|
|
48
|
+
// The finding: something was lost, and the consumer reported otherwise.
|
|
49
|
+
const findings = lostContent
|
|
50
|
+
? reportedAlike.filter((c) => lostContent(c.observation, c.whole))
|
|
51
|
+
: [];
|
|
52
|
+
|
|
53
|
+
// Both channels quiet — no terminal event delivered at all — is the worse
|
|
54
|
+
// shape. Checking only the LAST event is not enough: a stream carrying more
|
|
55
|
+
// than one run can deliver a terminal for an earlier run and still end mid-
|
|
56
|
+
// content, and that consumer did get a signal, just not about this part.
|
|
57
|
+
const noTerminal = findings.filter(
|
|
58
|
+
(c) => !(c.observation?.delivered ?? []).some((e) => TERMINALS.has(e)),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const prefixBroken = usable.filter((c) => c.prefixHeld === false);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
streams: considered.length,
|
|
65
|
+
excluded,
|
|
66
|
+
cuts: all.length,
|
|
67
|
+
threw: threw.length,
|
|
68
|
+
reportedAlike: reportedAlike.length,
|
|
69
|
+
lostContentChecked: Boolean(lostContent),
|
|
70
|
+
findings,
|
|
71
|
+
noTerminal,
|
|
72
|
+
prefixBroken,
|
|
73
|
+
byStream: countBy(findings, (c) => c.label),
|
|
74
|
+
byTerminal: countBy(findings, (c) => c.terminal),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A short human-readable version, for a terminal or a pull request body. */
|
|
79
|
+
export function format(summary) {
|
|
80
|
+
const lines = [];
|
|
81
|
+
lines.push(
|
|
82
|
+
`${summary.streams} streams, ${summary.cuts} cuts` +
|
|
83
|
+
(summary.excluded ? `, ${summary.excluded} streams excluded as invalid on purpose` : ''),
|
|
84
|
+
);
|
|
85
|
+
if (summary.threw) lines.push(`${summary.threw} cuts threw during replay`);
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push(`reported the same as the whole run : ${summary.reportedAlike}`);
|
|
88
|
+
|
|
89
|
+
if (!summary.lostContentChecked) {
|
|
90
|
+
lines.push('');
|
|
91
|
+
lines.push('No lostContent check was supplied, so none of the above can be');
|
|
92
|
+
lines.push('called a defect: a cut that removed nothing reports the same for');
|
|
93
|
+
lines.push('a good reason. Supply lostContent to separate them.');
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
lines.push(` of which something was actually lost : ${summary.findings.length}`);
|
|
98
|
+
lines.push(` of those, no terminal event either : ${summary.noTerminal.length}`);
|
|
99
|
+
lines.push(`spread across : ${Object.keys(summary.byStream).length} streams`);
|
|
100
|
+
|
|
101
|
+
if (summary.prefixBroken.length) {
|
|
102
|
+
lines.push('');
|
|
103
|
+
lines.push(`prefix property broken : ${summary.prefixBroken.length}`);
|
|
104
|
+
lines.push(' Check these by hand before reporting them. A stream that is');
|
|
105
|
+
lines.push(' invalid on purpose looks exactly like this and is not a defect.');
|
|
106
|
+
}
|
|
107
|
+
return lines.join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function countBy(list, key) {
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const item of list) {
|
|
113
|
+
const k = key(item);
|
|
114
|
+
out[k] = (out[k] ?? 0) + 1;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
package/src/sever.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cut a stream at every point and record what the consumer is left with.
|
|
3
|
+
*
|
|
4
|
+
* The idea is small enough to state in a sentence: a stream that stops early is
|
|
5
|
+
* not a rare case, it is Tuesday — a dropped connection, a proxy timeout, a
|
|
6
|
+
* producer that died, a person who pressed stop. So run the same stream through
|
|
7
|
+
* a consumer once whole, then once for every prefix of it, and compare.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here knows about any particular protocol. You supply the events and a
|
|
10
|
+
* function that replays them through whatever client you are testing; this
|
|
11
|
+
* supplies the loop and the comparison.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} Observation
|
|
16
|
+
* Whatever the consumer ended up with. Only two fields are interpreted here;
|
|
17
|
+
* anything else you attach is carried through to the report untouched.
|
|
18
|
+
* @property {string[]} [delivered] Event types the consumer actually surfaced.
|
|
19
|
+
* @property {string} [settled] How the awaited call reported, as a string.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @template E
|
|
24
|
+
* @param {object} options
|
|
25
|
+
* @param {E[]} options.events The whole stream, in order.
|
|
26
|
+
* @param {(prefix: E[], label: string) => Promise<Observation>} options.replay
|
|
27
|
+
* Replays a prefix through the consumer and returns what it was left with.
|
|
28
|
+
* It is called once per cut, plus once with the whole stream.
|
|
29
|
+
* @param {string} [options.label] Names this stream in the report.
|
|
30
|
+
* @param {(a: Observation, b: Observation) => boolean} [options.same]
|
|
31
|
+
* Decides whether two observations are indistinguishable to application code.
|
|
32
|
+
* Defaults to comparing `settled`.
|
|
33
|
+
* @returns {Promise<CutReport>}
|
|
34
|
+
*/
|
|
35
|
+
export async function severAtEveryPoint({ events, replay, label = 'stream', same }) {
|
|
36
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
37
|
+
throw new Error('severAtEveryPoint: events must be a non-empty array');
|
|
38
|
+
}
|
|
39
|
+
const indistinguishable = same ?? ((a, b) => a.settled === b.settled);
|
|
40
|
+
|
|
41
|
+
const whole = await replay(events, `${label}:whole`);
|
|
42
|
+
/** @type {Cut[]} */
|
|
43
|
+
const cuts = [];
|
|
44
|
+
|
|
45
|
+
// k = events.length is the whole stream, which is the baseline, not a cut.
|
|
46
|
+
for (let k = 1; k < events.length; k++) {
|
|
47
|
+
let observation;
|
|
48
|
+
let threw;
|
|
49
|
+
try {
|
|
50
|
+
observation = await replay(events.slice(0, k), `${label}:k${k}`);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
threw = err instanceof Error ? err.message : String(err);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!observation) {
|
|
56
|
+
cuts.push({ k, of: events.length, threw });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const delivered = observation.delivered ?? [];
|
|
61
|
+
cuts.push({
|
|
62
|
+
k,
|
|
63
|
+
of: events.length,
|
|
64
|
+
observation,
|
|
65
|
+
// Truncation may lose the tail. It must never change the head.
|
|
66
|
+
prefixHeld: isPrefix(delivered, whole.delivered ?? []),
|
|
67
|
+
// The property that matters: a run that was cut short must not report
|
|
68
|
+
// what the same run reports when it finishes.
|
|
69
|
+
settledAlike: indistinguishable(observation, whole),
|
|
70
|
+
terminal: lastOf(delivered),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { label, whole, cuts };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @typedef {object} Cut
|
|
79
|
+
* @property {number} k
|
|
80
|
+
* @property {number} of
|
|
81
|
+
* @property {Observation} [observation]
|
|
82
|
+
* @property {string} [threw]
|
|
83
|
+
* @property {boolean} [prefixHeld]
|
|
84
|
+
* @property {boolean} [settledAlike]
|
|
85
|
+
* @property {string} [terminal]
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @typedef {object} CutReport
|
|
90
|
+
* @property {string} label
|
|
91
|
+
* @property {Observation} whole
|
|
92
|
+
* @property {Cut[]} cuts
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
function isPrefix(short, long) {
|
|
96
|
+
return short.length <= long.length && short.every((v, i) => v === long[i]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function lastOf(list) {
|
|
100
|
+
return list.length ? list[list.length - 1] : '(nothing)';
|
|
101
|
+
}
|