@feasibleone/blong-gogo 1.35.0 → 1.37.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/CHANGELOG.md +27 -0
- package/package.json +4 -3
- package/src/AdapterBase.ts +33 -5
- package/src/Gateway.ts +14 -16
- package/src/Registry.ts +15 -2
- package/src/RpcClient.ts +3 -3
- package/src/RpcServer.ts +2 -2
- package/src/SemanticLog.test.ts +67 -8
- package/src/SemanticLog.ts +1 -1
- package/src/Watch.ts +33 -4
- package/src/adapterProgress.test.ts +190 -0
- package/src/browserEagerImports.test.ts +62 -0
- package/src/chain.allure.test.ts +216 -0
- package/src/chain.ts +238 -2
- package/src/checkpoint.test.ts +265 -0
- package/src/checkpoint.ts +115 -14
- package/src/ci.intent.test.ts +94 -0
- package/src/codec/adapter/jsonrpc/receive.ts +4 -4
- package/src/codec/adapter/mle/ready.ts +39 -11
- package/src/load.ts +133 -20
- package/src/mle.ts +3 -3
- package/src/orchestrator/common/dispatch.ts +6 -5
- package/src/pino-cacache.ts +59 -3
- package/src/pino-pretty.ts +1 -1
- package/src/registry.lib.test.ts +97 -0
- package/src/semanticContext.test.ts +125 -35
- package/src/semanticContext.ts +51 -22
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework's two progress-point handles (PRD R11/R26).
|
|
3
|
+
*
|
|
4
|
+
* A checkpoint reports a moment; a decision selects a branch. That difference is the whole
|
|
5
|
+
* reason one is optional and the other is not, so it is pinned here: a checkpoint exists
|
|
6
|
+
* only where points are recorded, a decision exists in every mode, and both reach the log
|
|
7
|
+
* through the attachable vocabulary — degrading to nothing and to a plain selection where
|
|
8
|
+
* no emitter is attached, because a dispatch path must never reach the emitter's ambient
|
|
9
|
+
* scope (F-197).
|
|
10
|
+
*/
|
|
11
|
+
import type {
|
|
12
|
+
IMeta,
|
|
13
|
+
IProgressEntry,
|
|
14
|
+
IProgressPoint,
|
|
15
|
+
IProgressRegion,
|
|
16
|
+
} from '@feasibleone/blong/types';
|
|
17
|
+
import {
|
|
18
|
+
attachSemanticVocabulary,
|
|
19
|
+
detachSemanticVocabulary,
|
|
20
|
+
} from '@feasibleone/semantic-log/attachable';
|
|
21
|
+
import * as semantic from '@feasibleone/semantic-log/emitter';
|
|
22
|
+
import t from 'tap';
|
|
23
|
+
import {createAttachCheckpoint} from './checkpoint.ts';
|
|
24
|
+
|
|
25
|
+
const MODES = ['production', 'debug', 'test'] as const;
|
|
26
|
+
|
|
27
|
+
/** The points an invocation recorded, in the order it announced them. */
|
|
28
|
+
const pointsOf = (progress: IProgressEntry[] | undefined): IProgressPoint[] =>
|
|
29
|
+
(progress ?? []).filter((entry): entry is IProgressPoint => entry.kind === 'point');
|
|
30
|
+
|
|
31
|
+
/** The branches an invocation took, in the order it took them. */
|
|
32
|
+
const regionsOf = (progress: IProgressEntry[] | undefined): IProgressRegion[] =>
|
|
33
|
+
(progress ?? []).filter((entry): entry is IProgressRegion => entry.kind === 'region');
|
|
34
|
+
|
|
35
|
+
t.test('a branch is taken and its rationale kept, in every mode', t => {
|
|
36
|
+
for (const mode of MODES) {
|
|
37
|
+
const meta: IMeta = {};
|
|
38
|
+
createAttachCheckpoint(mode)(meta);
|
|
39
|
+
const result = meta.decide?.('rate-within-limit', {rate: 2}, [
|
|
40
|
+
{name: 'decline', when: values => (values.rate as number) > 1, run: () => 'declined'},
|
|
41
|
+
{name: 'accept', when: () => true, run: () => 'accepted'},
|
|
42
|
+
]);
|
|
43
|
+
t.equal(result, 'declined', `${mode}: the branch is taken`);
|
|
44
|
+
t.same(
|
|
45
|
+
meta.progress,
|
|
46
|
+
[
|
|
47
|
+
{
|
|
48
|
+
kind: 'region',
|
|
49
|
+
discriminator: 'rate-within-limit',
|
|
50
|
+
candidates: ['decline', 'accept'],
|
|
51
|
+
chosen: 'decline',
|
|
52
|
+
values: {rate: 2},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
`${mode}: and the invocation keeps which one it was`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
t.end();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
t.test('a checkpoint exists only where points are recorded', t => {
|
|
62
|
+
const production: IMeta = {};
|
|
63
|
+
createAttachCheckpoint('production')(production);
|
|
64
|
+
t.notOk(
|
|
65
|
+
production.checkpoint,
|
|
66
|
+
'absent in production, which is what makes the `?.` call cost nothing',
|
|
67
|
+
);
|
|
68
|
+
t.ok(production.decide, 'while the branch helper is there, because a branch cannot be skipped');
|
|
69
|
+
|
|
70
|
+
const debug: IMeta = {};
|
|
71
|
+
createAttachCheckpoint('debug')(debug);
|
|
72
|
+
debug.checkpoint?.('total-calculated', {total: 200});
|
|
73
|
+
const [point] = pointsOf(debug.progress);
|
|
74
|
+
t.equal(debug.progress?.length, 1, 'recorded where points are');
|
|
75
|
+
t.equal(point?.name, 'total-calculated');
|
|
76
|
+
t.same(point?.data, {total: 200});
|
|
77
|
+
t.type(point?.timestamp, 'number', 'and stamped when it happened');
|
|
78
|
+
t.end();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
t.test('a point announced inside a branch names the branch it sat in', t => {
|
|
82
|
+
attachSemanticVocabulary(semantic);
|
|
83
|
+
t.teardown(() => detachSemanticVocabulary());
|
|
84
|
+
const meta: IMeta = {};
|
|
85
|
+
createAttachCheckpoint('test')(meta);
|
|
86
|
+
meta.checkpoint?.('before-the-branch');
|
|
87
|
+
meta.decide?.('rate-within-limit', {rate: 2}, [
|
|
88
|
+
{
|
|
89
|
+
name: 'decline',
|
|
90
|
+
when: () => true,
|
|
91
|
+
run: () => {
|
|
92
|
+
meta.checkpoint?.('declined');
|
|
93
|
+
return 'declined';
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{name: 'accept', when: () => true, run: () => 'accepted'},
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
t.same(
|
|
100
|
+
meta.progress?.map(entry => entry.kind),
|
|
101
|
+
['point', 'region', 'point'],
|
|
102
|
+
'the branch is announced where it was taken, between the points on either side of it',
|
|
103
|
+
);
|
|
104
|
+
t.same(
|
|
105
|
+
pointsOf(meta.progress).map(point => point.regions?.length ?? 0),
|
|
106
|
+
[0, 1],
|
|
107
|
+
'and only the point announced inside the branch names it, which is what nests a report',
|
|
108
|
+
);
|
|
109
|
+
t.same(
|
|
110
|
+
pointsOf(meta.progress)[1]?.regions,
|
|
111
|
+
[
|
|
112
|
+
{
|
|
113
|
+
discriminator: 'rate-within-limit',
|
|
114
|
+
candidates: ['decline', 'accept'],
|
|
115
|
+
chosen: 'decline',
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
'by the names of the branch, not by a position the log mints for itself',
|
|
119
|
+
);
|
|
120
|
+
t.same(
|
|
121
|
+
regionsOf(meta.progress)[0]?.regions,
|
|
122
|
+
undefined,
|
|
123
|
+
'while the branch itself names nothing above it',
|
|
124
|
+
);
|
|
125
|
+
t.end();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
t.test('a branch taken inside a branch names the one it sits in', t => {
|
|
129
|
+
attachSemanticVocabulary(semantic);
|
|
130
|
+
t.teardown(() => detachSemanticVocabulary());
|
|
131
|
+
const meta: IMeta = {};
|
|
132
|
+
createAttachCheckpoint('test')(meta);
|
|
133
|
+
meta.decide?.('outer', {}, [
|
|
134
|
+
{
|
|
135
|
+
name: 'taken',
|
|
136
|
+
when: () => true,
|
|
137
|
+
run: () =>
|
|
138
|
+
meta.decide?.('inner', {}, [{name: 'only', when: () => true, run: () => 'inner'}]),
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
t.same(
|
|
143
|
+
regionsOf(meta.progress).map(region => region.discriminator),
|
|
144
|
+
['outer', 'inner'],
|
|
145
|
+
'both are announced, outermost first',
|
|
146
|
+
);
|
|
147
|
+
t.same(
|
|
148
|
+
regionsOf(meta.progress)[1]?.regions?.map(region => region.discriminator),
|
|
149
|
+
['outer'],
|
|
150
|
+
'and the nested one is in the chain of the one that encloses it',
|
|
151
|
+
);
|
|
152
|
+
t.end();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
t.test('a checkpoint is announced to the log, and announces nothing without one', t => {
|
|
156
|
+
detachSemanticVocabulary();
|
|
157
|
+
const detached: IMeta = {};
|
|
158
|
+
createAttachCheckpoint('test')(detached);
|
|
159
|
+
detached.checkpoint?.('no-log');
|
|
160
|
+
t.equal(
|
|
161
|
+
semantic.takePoints(),
|
|
162
|
+
undefined,
|
|
163
|
+
'nothing is announced where no emitter is attached: the degradation is the whole point',
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
attachSemanticVocabulary(semantic);
|
|
167
|
+
t.teardown(() => detachSemanticVocabulary());
|
|
168
|
+
const attached: IMeta = {};
|
|
169
|
+
createAttachCheckpoint('test')(attached);
|
|
170
|
+
attached.checkpoint?.('total-calculated', {total: 200});
|
|
171
|
+
t.same(
|
|
172
|
+
semantic.takePoints(),
|
|
173
|
+
[{name: 'total-calculated', data: {total: 200}}],
|
|
174
|
+
'PRD R26: the milestone reaches the log, so a diagram can draw it as a note',
|
|
175
|
+
);
|
|
176
|
+
t.equal(semantic.takePoints(), undefined, 'and is taken once, by the record that carries it');
|
|
177
|
+
t.end();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
t.test('a decision is announced to the log too, and still selects without one', t => {
|
|
181
|
+
detachSemanticVocabulary();
|
|
182
|
+
const detached: IMeta = {};
|
|
183
|
+
createAttachCheckpoint('test')(detached);
|
|
184
|
+
t.equal(
|
|
185
|
+
detached.decide?.('d', {}, [{name: 'only', when: () => true, run: () => 7}]),
|
|
186
|
+
7,
|
|
187
|
+
'the branch is taken with nothing attached',
|
|
188
|
+
);
|
|
189
|
+
t.equal(semantic.takeDecision(), undefined, 'and no rationale is staged to record');
|
|
190
|
+
|
|
191
|
+
attachSemanticVocabulary(semantic);
|
|
192
|
+
t.teardown(() => detachSemanticVocabulary());
|
|
193
|
+
const attached: IMeta = {};
|
|
194
|
+
createAttachCheckpoint('test')(attached);
|
|
195
|
+
attached.decide?.('route-selection', {}, [
|
|
196
|
+
{name: 'hubB', when: () => true, run: () => 'hubB'},
|
|
197
|
+
{name: 'hold', when: () => true, run: () => undefined},
|
|
198
|
+
]);
|
|
199
|
+
t.same(
|
|
200
|
+
semantic.takeDecision()?.candidates,
|
|
201
|
+
['hubB', 'hold'],
|
|
202
|
+
'the rationale is staged for the next record, in evaluation order (PRD R11)',
|
|
203
|
+
);
|
|
204
|
+
t.end();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
t.test('no branch matching is a recorded non-choice', t => {
|
|
208
|
+
const meta: IMeta = {};
|
|
209
|
+
createAttachCheckpoint('test')(meta);
|
|
210
|
+
t.equal(meta.decide?.('never', {}, [{name: 'a', when: () => false, run: () => 1}]), undefined);
|
|
211
|
+
t.equal(regionsOf(meta.progress)[0]?.chosen, 'none', 'recorded as `none` rather than lost');
|
|
212
|
+
t.end();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
t.test('each predicate is evaluated once', t => {
|
|
216
|
+
const meta: IMeta = {};
|
|
217
|
+
createAttachCheckpoint('test')(meta);
|
|
218
|
+
let evaluations = 0;
|
|
219
|
+
meta.decide?.('counting', {}, [
|
|
220
|
+
{
|
|
221
|
+
name: 'only',
|
|
222
|
+
when: () => {
|
|
223
|
+
evaluations++;
|
|
224
|
+
return true;
|
|
225
|
+
},
|
|
226
|
+
run: () => 'taken',
|
|
227
|
+
},
|
|
228
|
+
]);
|
|
229
|
+
t.equal(evaluations, 1, 'the `run` is wrapped, not the predicate re-evaluated');
|
|
230
|
+
t.end();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
t.test('a branch that throws records nothing', t => {
|
|
234
|
+
const meta: IMeta = {};
|
|
235
|
+
createAttachCheckpoint('test')(meta);
|
|
236
|
+
t.throws(
|
|
237
|
+
() =>
|
|
238
|
+
meta.decide?.('boom', {}, [
|
|
239
|
+
{
|
|
240
|
+
name: 'a',
|
|
241
|
+
when: () => true,
|
|
242
|
+
run: () => {
|
|
243
|
+
throw new Error('handler failed');
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
]),
|
|
247
|
+
/handler failed/,
|
|
248
|
+
'the failure is not swallowed',
|
|
249
|
+
);
|
|
250
|
+
t.notOk(
|
|
251
|
+
meta.progress,
|
|
252
|
+
'a decision that never completed is not a decision, so nothing is recorded',
|
|
253
|
+
);
|
|
254
|
+
t.end();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
t.test('attaching twice does not replace a handle already there', t => {
|
|
258
|
+
const meta: IMeta = {};
|
|
259
|
+
const attach = createAttachCheckpoint('test');
|
|
260
|
+
attach(meta);
|
|
261
|
+
const first = meta.decide;
|
|
262
|
+
attach(meta);
|
|
263
|
+
t.equal(meta.decide, first, 'a dispatch that was already prepared is left alone');
|
|
264
|
+
t.end();
|
|
265
|
+
});
|
package/src/checkpoint.ts
CHANGED
|
@@ -1,32 +1,133 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CheckpointFn,
|
|
3
|
+
DecideFn,
|
|
4
|
+
IMeta,
|
|
5
|
+
IProgressPoint,
|
|
6
|
+
IProgressRegion,
|
|
7
|
+
IRegionMark,
|
|
8
|
+
} from '@feasibleone/blong/types';
|
|
9
|
+
import {vocabulary} from '@feasibleone/semantic-log/attachable';
|
|
2
10
|
|
|
3
11
|
/**
|
|
4
|
-
*
|
|
12
|
+
* The branches the calling scope is running inside, named the way `$meta` names them.
|
|
13
|
+
*
|
|
14
|
+
* Names only, and never the log's own mark: the mark carries the position it was minted at,
|
|
15
|
+
* which describes a place in *this* process' execution. An entry that travelled here from
|
|
16
|
+
* another process over a call would bring a position that means nothing here, while the three
|
|
17
|
+
* names still say exactly which branches it sat in — which is all a report groups by.
|
|
18
|
+
*/
|
|
19
|
+
const regionMarks = (): IRegionMark[] | undefined => {
|
|
20
|
+
const chain = vocabulary.currentRegions();
|
|
21
|
+
if (chain === undefined || chain.length === 0) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return chain.map(region => ({
|
|
25
|
+
discriminator: region.discriminator,
|
|
26
|
+
candidates: region.candidates,
|
|
27
|
+
chosen: region.chosen,
|
|
28
|
+
}));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reports a checkpoint into `$meta.progress`, and announces it as a progress point
|
|
33
|
+
* (PRD R26).
|
|
34
|
+
*
|
|
5
35
|
* Uses `this` binding — works correctly when called as
|
|
6
36
|
* $meta.checkpoint?.('name', data) with optional chaining.
|
|
7
37
|
*/
|
|
8
38
|
const checkpoint: CheckpointFn = function (this: IMeta, name: string, data?: unknown): void {
|
|
9
|
-
|
|
39
|
+
const regions = regionMarks();
|
|
40
|
+
const entry: IProgressPoint = {
|
|
41
|
+
kind: 'point',
|
|
10
42
|
name,
|
|
11
|
-
data,
|
|
43
|
+
...(data === undefined ? {} : {data}),
|
|
12
44
|
timestamp: Date.now(),
|
|
13
|
-
|
|
45
|
+
...(regions === undefined ? {} : {regions}),
|
|
46
|
+
};
|
|
47
|
+
(this.progress ??= []).push(entry);
|
|
48
|
+
// The same moment is announced to the log, so a checkpoint reaches the record the
|
|
49
|
+
// sequence diagram is drawn from rather than only the list a test asserts on. The
|
|
50
|
+
// vocabulary degrades to nothing where no emitter is attached, so this is one call
|
|
51
|
+
// and never a way for a dispatch path to import the emitter's ambient scope (F-197).
|
|
52
|
+
vocabulary.point(name, data);
|
|
14
53
|
};
|
|
15
54
|
|
|
16
55
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
56
|
+
* Takes a branch, and keeps what it was and which one was taken on the invocation that took
|
|
57
|
+
* it (PRD R11/R26).
|
|
58
|
+
*
|
|
59
|
+
* `this`-bound like {@link checkpoint}. The branch's `run` is *wrapped* rather than the
|
|
60
|
+
* predicates re-evaluated: a predicate with a side effect must run exactly once, and the
|
|
61
|
+
* decision recorded has to be the one the code actually took. A branch whose `run` throws
|
|
62
|
+
* records nothing — the entry is withdrawn before the throw propagates, because a decision
|
|
63
|
+
* that never completed is not a decision.
|
|
19
64
|
*/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
65
|
+
const decide: DecideFn = function <T>(
|
|
66
|
+
this: IMeta,
|
|
67
|
+
discriminator: string,
|
|
68
|
+
values: Record<string, unknown>,
|
|
69
|
+
branches: ReadonlyArray<{
|
|
70
|
+
name: string;
|
|
71
|
+
when: (values: Record<string, unknown>) => boolean;
|
|
72
|
+
run: () => T;
|
|
73
|
+
}>,
|
|
74
|
+
): T | undefined {
|
|
75
|
+
let chosen: string | undefined;
|
|
76
|
+
const wrapped = branches.map(branch => ({
|
|
77
|
+
...branch,
|
|
78
|
+
run: () => {
|
|
79
|
+
chosen = branch.name;
|
|
80
|
+
return branch.run();
|
|
81
|
+
},
|
|
82
|
+
}));
|
|
83
|
+
// The entry is pushed *before* the branch runs, which is what puts it where the
|
|
84
|
+
// announcement order wants it: the points the branch announces come after it in the
|
|
85
|
+
// array, and a report that groups by those points therefore draws them inside the
|
|
86
|
+
// branch. Nothing else in the entry is known yet, so `chosen` is filled in below.
|
|
87
|
+
const outer = regionMarks();
|
|
88
|
+
const entry: IProgressRegion = {
|
|
89
|
+
kind: 'region',
|
|
90
|
+
discriminator,
|
|
91
|
+
candidates: branches.map(branch => branch.name),
|
|
92
|
+
chosen: '',
|
|
93
|
+
values,
|
|
94
|
+
...(outer === undefined ? {} : {regions: outer}),
|
|
95
|
+
};
|
|
96
|
+
const held = this.progress;
|
|
97
|
+
const progress = (this.progress ??= []);
|
|
98
|
+
progress.push(entry);
|
|
99
|
+
try {
|
|
100
|
+
const result = vocabulary.decide<T>(discriminator, values, wrapped);
|
|
101
|
+
entry.chosen = chosen ?? 'none';
|
|
102
|
+
return result;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// A branch whose `run` throws records nothing, which is the rule this recorder has
|
|
105
|
+
// always kept: a decision that never completed is not a decision. The entry was
|
|
106
|
+
// pushed early, so it is withdrawn again — and, where the array was this call's own,
|
|
107
|
+
// the slot goes with it, so a throwing branch leaves `$meta.progress` exactly as
|
|
108
|
+
// absent as it found it. The throw still propagates.
|
|
109
|
+
const at = progress.lastIndexOf(entry);
|
|
110
|
+
if (at !== -1) progress.splice(at, 1);
|
|
111
|
+
if (held === undefined && progress.length === 0) delete this.progress;
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
23
115
|
|
|
24
116
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
117
|
+
* Attach the framework's progress-point handles to a dispatch's `$meta`.
|
|
118
|
+
*
|
|
119
|
+
* `decide` is attached in **every** mode: it selects a branch, so a missing one would skip
|
|
120
|
+
* the work rather than cost a note. `checkpoint` is attached only where points are
|
|
121
|
+
* recorded, which is what keeps its `?.` call free in production.
|
|
27
122
|
*/
|
|
28
123
|
export function createAttachCheckpoint(
|
|
29
124
|
mode: 'test' | 'debug' | 'production',
|
|
30
|
-
): (
|
|
31
|
-
|
|
125
|
+
): (meta: IMeta) => void {
|
|
126
|
+
const recording = mode !== 'production';
|
|
127
|
+
return (meta: IMeta): void => {
|
|
128
|
+
meta.decide ??= decide;
|
|
129
|
+
if (recording) {
|
|
130
|
+
meta.checkpoint ??= checkpoint;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
32
133
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {server} from '@feasibleone/blong';
|
|
2
|
+
import {test} from 'tap';
|
|
3
|
+
|
|
4
|
+
import load from './loadServer.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `ci` block is a captured run's configuration — colours off, Allure results
|
|
8
|
+
* written — and a CI run is one by definition, so `activeConfigs` activates the
|
|
9
|
+
* block for it whether or not the entry named the intent.
|
|
10
|
+
*
|
|
11
|
+
* What made this worth a test: only the entries that remembered to pass `ci` in CI
|
|
12
|
+
* wrote Allure results, so a package whose only runner is tap produced no report and
|
|
13
|
+
* the CI summary had nothing to link for it. The observable here is the same trick
|
|
14
|
+
* `exit.intent.test.ts` uses — a config key that only the `ci` block sets — because
|
|
15
|
+
* the resolved `watch.allure.enabled` is not exposed on the registry.
|
|
16
|
+
*
|
|
17
|
+
* Only `load` is exercised, never `start`: the assertions are about which config
|
|
18
|
+
* blocks merged. Every load is stopped, since the `integration` intent opens the
|
|
19
|
+
* semantic-log cluster service and a registry that is never stopped leaves the suite
|
|
20
|
+
* reporting `timeout!` instead of passing (`exit.intent.test.ts` explains it at
|
|
21
|
+
* length).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The suite the load is built from. `pkg` is supplied so the loader skips its
|
|
26
|
+
* `createRequire(mod.url)('./package.json')` lookup, and `parentConfig` is an object
|
|
27
|
+
* rather than a name so the test does not depend on the working directory.
|
|
28
|
+
*/
|
|
29
|
+
const suite = (config: object) =>
|
|
30
|
+
server(
|
|
31
|
+
() =>
|
|
32
|
+
({
|
|
33
|
+
url: import.meta.url,
|
|
34
|
+
pkg: {name: 'ci-intent-test', version: '0.0.0'},
|
|
35
|
+
children: [],
|
|
36
|
+
config: {default: {}, ...config},
|
|
37
|
+
}) as never,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** The `exit` the run resolves, with `ci` as the only thing that sets it. */
|
|
41
|
+
async function exitWithCiBlock(intents: string[]): Promise<boolean | undefined> {
|
|
42
|
+
const registry = await load(
|
|
43
|
+
suite({ci: {exit: true}}) as unknown as Parameters<typeof load>[0],
|
|
44
|
+
'ci-intent-test',
|
|
45
|
+
{},
|
|
46
|
+
intents,
|
|
47
|
+
);
|
|
48
|
+
try {
|
|
49
|
+
return registry.exit;
|
|
50
|
+
} finally {
|
|
51
|
+
await registry.stop();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Run `fn` with `CI` set the way a runner sets it, and restore it afterwards. */
|
|
56
|
+
async function withCi<T>(value: string | undefined, fn: () => Promise<T>): Promise<T> {
|
|
57
|
+
const previous = process.env['CI'];
|
|
58
|
+
if (value === undefined) delete process.env['CI'];
|
|
59
|
+
else process.env['CI'] = value;
|
|
60
|
+
try {
|
|
61
|
+
return await fn();
|
|
62
|
+
} finally {
|
|
63
|
+
if (previous === undefined) delete process.env['CI'];
|
|
64
|
+
else process.env['CI'] = previous;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('a CI run activates the ci block without being asked', async t => {
|
|
69
|
+
t.equal(
|
|
70
|
+
await withCi('true', () => exitWithCiBlock([])),
|
|
71
|
+
true,
|
|
72
|
+
'the block merged although no intent named it',
|
|
73
|
+
);
|
|
74
|
+
t.equal(
|
|
75
|
+
await withCi('true', () => exitWithCiBlock(['dev', 'integration'])),
|
|
76
|
+
true,
|
|
77
|
+
'the intents a run normally passes do not take it away',
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a run that is not CI needs the intent', async t => {
|
|
82
|
+
// `false`, not `undefined`: the registry always carries a boolean once loaded.
|
|
83
|
+
// Deleting `CI` rather than inheriting it, because this suite runs in CI itself.
|
|
84
|
+
t.equal(
|
|
85
|
+
await withCi(undefined, () => exitWithCiBlock([])),
|
|
86
|
+
false,
|
|
87
|
+
'the block stays out of a run whose output is watched',
|
|
88
|
+
);
|
|
89
|
+
t.equal(
|
|
90
|
+
await withCi(undefined, () => exitWithCiBlock(['ci'])),
|
|
91
|
+
true,
|
|
92
|
+
'and naming it still does what it always did',
|
|
93
|
+
);
|
|
94
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {handler, type IMeta, type ITypedError} from '@feasibleone/blong/types';
|
|
1
|
+
import {handler, type IMeta, type IProgressEntry, type ITypedError} from '@feasibleone/blong/types';
|
|
2
2
|
import {type Response} from 'got';
|
|
3
3
|
|
|
4
4
|
export default handler(({errors}) => ({
|
|
@@ -8,7 +8,7 @@ export default handler(({errors}) => ({
|
|
|
8
8
|
error?: unknown;
|
|
9
9
|
validation?: unknown;
|
|
10
10
|
debug?: unknown;
|
|
11
|
-
|
|
11
|
+
progress?: unknown[];
|
|
12
12
|
}>,
|
|
13
13
|
$meta?: IMeta,
|
|
14
14
|
) {
|
|
@@ -54,8 +54,8 @@ export default handler(({errors}) => ({
|
|
|
54
54
|
}),
|
|
55
55
|
});
|
|
56
56
|
} else if (typeof body === 'object' && 'result' in body && !('error' in body)) {
|
|
57
|
-
if ($meta && body.
|
|
58
|
-
($meta.
|
|
57
|
+
if ($meta && body.progress?.length) {
|
|
58
|
+
($meta.progress ??= []).push(...(body.progress as IProgressEntry[]));
|
|
59
59
|
}
|
|
60
60
|
return body.result;
|
|
61
61
|
} else {
|
|
@@ -99,10 +99,13 @@ export default handler<{
|
|
|
99
99
|
* expired session) the tokens are cleared so the next request surfaces a
|
|
100
100
|
* clean 401 to the caller, which the UI turns into a login prompt.
|
|
101
101
|
*/
|
|
102
|
-
async function refresh(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
async function refresh(
|
|
103
|
+
this: {
|
|
104
|
+
exec?(...params: unknown[]): Promise<unknown>;
|
|
105
|
+
error?(error: unknown, $meta?: unknown): void;
|
|
106
|
+
},
|
|
107
|
+
opts: {force?: boolean} = {},
|
|
108
|
+
): Promise<void> {
|
|
106
109
|
const now = Date.now();
|
|
107
110
|
if (token && (opts.force || tokenExpire < now)) {
|
|
108
111
|
if (refreshToken && refreshTokenExpire > now) {
|
|
@@ -126,7 +129,10 @@ export default handler<{
|
|
|
126
129
|
{},
|
|
127
130
|
)) as {
|
|
128
131
|
statusCode?: number;
|
|
129
|
-
body?: {
|
|
132
|
+
body?: {
|
|
133
|
+
result?: IToken;
|
|
134
|
+
error?: {type?: string; message?: string; statusCode?: number};
|
|
135
|
+
};
|
|
130
136
|
};
|
|
131
137
|
return result;
|
|
132
138
|
})();
|
|
@@ -134,7 +140,10 @@ export default handler<{
|
|
|
134
140
|
if (pending !== null) pending = null;
|
|
135
141
|
const {body, statusCode} = result as {
|
|
136
142
|
statusCode?: number;
|
|
137
|
-
body?: {
|
|
143
|
+
body?: {
|
|
144
|
+
result?: IToken;
|
|
145
|
+
error?: {type?: string; message?: string; statusCode?: number};
|
|
146
|
+
};
|
|
138
147
|
};
|
|
139
148
|
// The gateway MLE-encrypts the response with the handshake
|
|
140
149
|
// keys, so decrypt the result before reading the token.
|
|
@@ -263,7 +272,14 @@ export default handler<{
|
|
|
263
272
|
},
|
|
264
273
|
async loginTokenCreateResponseReceive(result: Response<{result: unknown}>, $meta: unknown) {
|
|
265
274
|
await decrypt(result.body, 'result');
|
|
266
|
-
if ((result.body as {error?: unknown})?.error)
|
|
275
|
+
if ((result.body as {error?: unknown})?.error) {
|
|
276
|
+
// The base `receive` reads an error from the *decrypted* body and this hook
|
|
277
|
+
// delegates to it, so the error has to be decrypted here: without this the
|
|
278
|
+
// encrypted envelope is what a failed login throws, and the reason for the
|
|
279
|
+
// failure is unreadable to the caller that has to act on it.
|
|
280
|
+
await decrypt(result.body, 'error');
|
|
281
|
+
return super.receive(result, $meta);
|
|
282
|
+
}
|
|
267
283
|
readToken(result.body.result as IToken);
|
|
268
284
|
return super.receive(result, $meta);
|
|
269
285
|
},
|
|
@@ -272,9 +288,15 @@ export default handler<{
|
|
|
272
288
|
async loginTokenRefreshRequestSend(params: {$http?: unknown}, $meta: unknown) {
|
|
273
289
|
return super.send(await encryptPublic(params), $meta);
|
|
274
290
|
},
|
|
275
|
-
async loginTokenRefreshResponseReceive(
|
|
291
|
+
async loginTokenRefreshResponseReceive(
|
|
292
|
+
result: Response<{result: unknown}>,
|
|
293
|
+
$meta: unknown,
|
|
294
|
+
) {
|
|
276
295
|
await decrypt(result.body, 'result');
|
|
277
|
-
if ((result.body as {error?: unknown})?.error)
|
|
296
|
+
if ((result.body as {error?: unknown})?.error) {
|
|
297
|
+
await decrypt(result.body, 'error');
|
|
298
|
+
return super.receive(result, $meta);
|
|
299
|
+
}
|
|
278
300
|
readToken(result.body.result as IToken);
|
|
279
301
|
return super.receive(result, $meta);
|
|
280
302
|
},
|
|
@@ -290,9 +312,15 @@ export default handler<{
|
|
|
290
312
|
async loginTokenRestoreRequestSend(params: {$http?: unknown}, $meta: unknown) {
|
|
291
313
|
return super.send(await encryptPublic(params), $meta);
|
|
292
314
|
},
|
|
293
|
-
async loginTokenRestoreResponseReceive(
|
|
315
|
+
async loginTokenRestoreResponseReceive(
|
|
316
|
+
result: Response<{result: unknown}>,
|
|
317
|
+
$meta: unknown,
|
|
318
|
+
) {
|
|
294
319
|
await decrypt(result.body, 'result');
|
|
295
|
-
if ((result.body as {error?: unknown})?.error)
|
|
320
|
+
if ((result.body as {error?: unknown})?.error) {
|
|
321
|
+
await decrypt(result.body, 'error');
|
|
322
|
+
return super.receive(result, $meta);
|
|
323
|
+
}
|
|
296
324
|
readToken(result.body.result as IToken);
|
|
297
325
|
return super.receive(result, $meta);
|
|
298
326
|
},
|