@feasibleone/blong-gogo 1.36.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 +13 -0
- package/package.json +4 -3
- package/src/Gateway.ts +3 -5
- package/src/RpcClient.ts +3 -3
- package/src/RpcServer.ts +2 -2
- package/src/SemanticLog.test.ts +13 -3
- package/src/SemanticLog.ts +1 -1
- package/src/Watch.ts +33 -4
- 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 +97 -8
- package/src/checkpoint.ts +69 -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/pino-cacache.ts +59 -3
- package/src/pino-pretty.ts +1 -1
- package/src/semanticContext.ts +9 -0
package/src/chain.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {TestExecutor, type ITestLogger} from '@feasibleone/blong-chain';
|
|
1
|
+
import {TestExecutor, type IStepProgress, type ITestLogger} from '@feasibleone/blong-chain';
|
|
2
2
|
import {withProgress} from '@feasibleone/blong-lib';
|
|
3
|
+
import type {IPlatformApi} from '@feasibleone/blong/types';
|
|
3
4
|
import assert from 'node:assert';
|
|
4
5
|
|
|
5
6
|
type Step = (a: typeof assert, results: object) => object | Promise<object>;
|
|
@@ -12,10 +13,227 @@ interface ITestContext {
|
|
|
12
13
|
test: (name: string, fn: (t: unknown) => void | Promise<void>) => unknown;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** Allure reporting options, as the framework config supplies them. */
|
|
17
|
+
export interface IAllureRunOptions {
|
|
18
|
+
/** Write results at all. Off by default, on for a captured (CI) run. */
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
/** Results directory (default `allure-results`). */
|
|
21
|
+
outputDir?: string;
|
|
22
|
+
/** Trend history file (default `.allure/history.jsonl`). */
|
|
23
|
+
historyPath?: string;
|
|
24
|
+
/** Generate the HTML report when the session ends. */
|
|
25
|
+
generateOnEnd?: boolean;
|
|
26
|
+
/** Pattern with `{traceId}`, used to link a result to its log trace. */
|
|
27
|
+
logUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface IChainOptions {
|
|
31
|
+
/** The group's wire name, e.g. `test.order.checkpoint` — what Allure labels come from. */
|
|
32
|
+
method?: string;
|
|
33
|
+
allure?: IAllureRunOptions;
|
|
34
|
+
/**
|
|
35
|
+
* The platform this run belongs to, as `IPlatformApi` names it.
|
|
36
|
+
*
|
|
37
|
+
* Allure 3's format is a directory of files, so producing one needs a
|
|
38
|
+
* filesystem — and the browser platform has none. The platform is therefore what
|
|
39
|
+
* decides whether the attempt is made at all (see `canWriteReport`). A caller
|
|
40
|
+
* that names no platform is read as the server, which is what a direct chain run
|
|
41
|
+
* in Node is: refusing to report there would be worse than trying.
|
|
42
|
+
*/
|
|
43
|
+
platform?: IPlatformApi['platform'];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `@feasibleone/blong-allure`, assembled rather than written out.
|
|
48
|
+
*
|
|
49
|
+
* A specifier written as a literal is *resolved* by the bundler even when the import
|
|
50
|
+
* is dynamic, so the whole integration was landing in every browser build: its modules
|
|
51
|
+
* import `node:fs`, `node:fs/promises`, `node:crypto`, `node:url` and
|
|
52
|
+
* `node:child_process`, none of which a browser can provide, and
|
|
53
|
+
* `browserBundle.test.ts` failed on exactly that list. Building the string at runtime
|
|
54
|
+
* leaves the specifier opaque to the bundler, which is the same trick `load.ts` uses
|
|
55
|
+
* (`extension` there) to keep the server half of the platform map out of a browser
|
|
56
|
+
* build — and it costs nothing on the server, where Node resolves the specifier when
|
|
57
|
+
* it is imported.
|
|
58
|
+
*
|
|
59
|
+
* The import is never *made* on a browser either: `canWriteReport` refuses the
|
|
60
|
+
* platform before it, because a browser has nowhere to write a report.
|
|
61
|
+
*/
|
|
62
|
+
const allurePackage = (): string => ['@feasibleone', 'blong-allure'].join('/');
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The one Allure session of this process, started by the first group that runs.
|
|
66
|
+
*
|
|
67
|
+
* A session clears its results directory, so it cannot be per group: the first
|
|
68
|
+
* group would wipe the results of the ones before it. It is started lazily
|
|
69
|
+
* instead, and ended by {@link endAllureSession} once every group has finished.
|
|
70
|
+
*/
|
|
71
|
+
let session: Promise<void> | undefined;
|
|
72
|
+
|
|
73
|
+
/** The options the running session was started with, for its end. */
|
|
74
|
+
let sessionOptions: IAllureRunOptions | undefined;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Why this process will produce no Allure report, once that is known.
|
|
78
|
+
*
|
|
79
|
+
* Held for the run rather than per group, because the answer is the run's: a
|
|
80
|
+
* second group that says the same thing again adds noise to a run that has already
|
|
81
|
+
* explained why the report it asked for will not exist. `session` is never set
|
|
82
|
+
* while this is, so a blocked run also does not try to close a session later.
|
|
83
|
+
*/
|
|
84
|
+
let blocked: string | undefined;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether this platform can write Allure's files at all.
|
|
88
|
+
*
|
|
89
|
+
* The browser platform is the one that cannot: it has no `node:fs`, so the lazy
|
|
90
|
+
* import of `blong-allure` there can only fail — and a failure caught per group is
|
|
91
|
+
* a failure reported per group over a run whose tests were fine. Its tests report
|
|
92
|
+
* through their own producer (Allure's Playwright integration), so the results this
|
|
93
|
+
* guard withholds are ones that were never going to exist on that platform.
|
|
94
|
+
*
|
|
95
|
+
* The allowed kinds are listed rather than the refused one, so a platform added
|
|
96
|
+
* later has to be considered here instead of inheriting a report nobody can write.
|
|
97
|
+
*/
|
|
98
|
+
function canWriteReport(platform: IChainOptions['platform']): boolean {
|
|
99
|
+
return platform === undefined || platform === 'server';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The results directory and history file, defaulted the way Allure documents them. */
|
|
103
|
+
function sessionConfig(options: IAllureRunOptions): {
|
|
104
|
+
outputDir: string;
|
|
105
|
+
historyPath: string;
|
|
106
|
+
logUrl?: string;
|
|
107
|
+
} {
|
|
108
|
+
return {
|
|
109
|
+
outputDir: options.outputDir ?? 'allure-results',
|
|
110
|
+
historyPath: options.historyPath ?? '.allure/history.jsonl',
|
|
111
|
+
logUrl: options.logUrl,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Start the Allure session if this run wants one, once.
|
|
117
|
+
*
|
|
118
|
+
* `blong-allure` is imported here rather than at the top of the file, for the
|
|
119
|
+
* reason the log implementation is chosen lazily: a run that does not report to
|
|
120
|
+
* Allure should not load it.
|
|
121
|
+
*
|
|
122
|
+
* The platform is checked *before* the attempt, and the reason it cannot report is
|
|
123
|
+
* said out loud — as a configuration mistake rather than an error in a test, so the
|
|
124
|
+
* absence of a report is explained rather than discovered.
|
|
125
|
+
*
|
|
126
|
+
* An unexpected failure (a package that cannot be imported on a platform that
|
|
127
|
+
* should be able to write results) is reported on the same terms, and the session
|
|
128
|
+
* is dropped rather than left rejected: there is nothing for the end of the run to
|
|
129
|
+
* close, and a later group must not await a promise that has already failed.
|
|
130
|
+
*
|
|
131
|
+
* This never throws. A report that cannot be written is not a test that failed —
|
|
132
|
+
* the run is judged on its tests — and a caller that had to guard the call would be
|
|
133
|
+
* one place too many for that decision to live.
|
|
134
|
+
*/
|
|
135
|
+
async function ensureSession(
|
|
136
|
+
options: IAllureRunOptions | undefined,
|
|
137
|
+
platform: IChainOptions['platform'],
|
|
138
|
+
log?: ITestLogger,
|
|
139
|
+
): Promise<boolean> {
|
|
140
|
+
if (options?.enabled !== true || blocked !== undefined) return false;
|
|
141
|
+
if (!canWriteReport(platform)) {
|
|
142
|
+
blocked =
|
|
143
|
+
`Allure reporting is enabled for this run but the ${platform} platform cannot write its ` +
|
|
144
|
+
'files, so a report will not be produced';
|
|
145
|
+
log?.warn?.(blocked);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (session === undefined) {
|
|
149
|
+
sessionOptions = options;
|
|
150
|
+
session = (async () => {
|
|
151
|
+
const {allureSessionStart} = await import(/* @vite-ignore */ allurePackage());
|
|
152
|
+
await allureSessionStart(sessionConfig(options));
|
|
153
|
+
})();
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
await session;
|
|
157
|
+
return true;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// The first group to lose the session reports it; a group that was awaiting
|
|
160
|
+
// the same start loses it too and stays quiet, because the reason is the
|
|
161
|
+
// run's rather than its own.
|
|
162
|
+
session = undefined;
|
|
163
|
+
sessionOptions = undefined;
|
|
164
|
+
if (blocked === undefined) {
|
|
165
|
+
blocked = `Allure reporting failed: ${String(error)}`;
|
|
166
|
+
log?.error?.(error);
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The Allure context for a group's wire name.
|
|
174
|
+
*
|
|
175
|
+
* `test.order.checkpoint` reads as realm `test`, collection `order`, group
|
|
176
|
+
* `checkpoint`. A name that does not split stays the realm, so a suite with its
|
|
177
|
+
* own naming still gets a label rather than a result filed under nothing.
|
|
178
|
+
*/
|
|
179
|
+
function contextOf(method: string | undefined, options: IAllureRunOptions) {
|
|
180
|
+
const [realm, collection, ...rest] = (method ?? '').split('.').filter(Boolean);
|
|
181
|
+
return {
|
|
182
|
+
realm,
|
|
183
|
+
collection,
|
|
184
|
+
group: rest.length > 0 ? rest.join('.') : undefined,
|
|
185
|
+
logUrl: options.logUrl,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The steps an executor recorded, in the order they completed. */
|
|
190
|
+
function stepsOf(executor: TestExecutor): IStepProgress[] {
|
|
191
|
+
return Array.from(executor.getProgress().steps.values());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Write one group result, standing in for a step list its executor may not have. */
|
|
195
|
+
async function writeGroupResult(
|
|
196
|
+
options: IAllureRunOptions,
|
|
197
|
+
name: string,
|
|
198
|
+
executor: TestExecutor,
|
|
199
|
+
method?: string,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
const steps = stepsOf(executor);
|
|
202
|
+
if (steps.length === 0) return;
|
|
203
|
+
const {allureGroupResultWrite} = await import(/* @vite-ignore */ allurePackage());
|
|
204
|
+
await allureGroupResultWrite(
|
|
205
|
+
sessionConfig(options).outputDir,
|
|
206
|
+
{name, steps},
|
|
207
|
+
contextOf(method, options),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* End the Allure session, if one was started, and optionally generate its report.
|
|
213
|
+
*
|
|
214
|
+
* Called by the runner once every group has finished, because the results
|
|
215
|
+
* directory belongs to the run rather than to any one group.
|
|
216
|
+
*/
|
|
217
|
+
export async function endAllureSession(): Promise<void> {
|
|
218
|
+
if (session === undefined) return;
|
|
219
|
+
const options = sessionOptions ?? {};
|
|
220
|
+
const closing = session;
|
|
221
|
+
session = undefined;
|
|
222
|
+
sessionOptions = undefined;
|
|
223
|
+
// Waiting on the start first: the end must not run the CLI over a results
|
|
224
|
+
// directory while the thing that lays it out is still opening it. A start that
|
|
225
|
+
// failed has already cleared `session` (see `ensureSession`), so this awaits one
|
|
226
|
+
// that opened, or that is opening.
|
|
227
|
+
await closing;
|
|
228
|
+
const {allureSessionEnd} = await import(/* @vite-ignore */ allurePackage());
|
|
229
|
+
await allureSessionEnd({...sessionConfig(options), generateOnEnd: options.generateOnEnd});
|
|
230
|
+
}
|
|
231
|
+
|
|
15
232
|
const runSteps =
|
|
16
233
|
(
|
|
17
234
|
steps: Steps,
|
|
18
235
|
log?: ITestLogger,
|
|
236
|
+
options: IChainOptions = {},
|
|
19
237
|
results = {$meta: {}},
|
|
20
238
|
): ((t: ITestContext) => Promise<void>) =>
|
|
21
239
|
async (t: ITestContext) => {
|
|
@@ -33,6 +251,11 @@ const runSteps =
|
|
|
33
251
|
resolvedSteps.push(await stepPromise);
|
|
34
252
|
}
|
|
35
253
|
|
|
254
|
+
// Reporting is a run-level concern, and this call never throws: a report that
|
|
255
|
+
// cannot be written says so once and the run carries on being judged on its
|
|
256
|
+
// tests (see `ensureSession`).
|
|
257
|
+
const reporting = await ensureSession(options.allure, options.platform, log);
|
|
258
|
+
|
|
36
259
|
// Execute with parallel executor, passing test context for nested output
|
|
37
260
|
try {
|
|
38
261
|
await withProgress(
|
|
@@ -57,6 +280,15 @@ const runSteps =
|
|
|
57
280
|
} catch (error) {
|
|
58
281
|
// Preserve error with context
|
|
59
282
|
throw error;
|
|
283
|
+
} finally {
|
|
284
|
+
// The group is the test case: one result per group, written whether the
|
|
285
|
+
// group passed or not — a scenario that failed is the one a reader opens
|
|
286
|
+
// the report for. The group's own name is what it was run as.
|
|
287
|
+
if (reporting && options.allure) {
|
|
288
|
+
await writeGroupResult(options.allure, steps.name, executor, options.method).catch(
|
|
289
|
+
error => log?.error?.(error),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
60
292
|
}
|
|
61
293
|
};
|
|
62
294
|
|
|
@@ -92,8 +324,12 @@ const runSteps =
|
|
|
92
324
|
export default async (
|
|
93
325
|
test: ITestContext,
|
|
94
326
|
log?: ITestLogger,
|
|
327
|
+
options: IChainOptions = {},
|
|
95
328
|
): Promise<(steps: Steps) => unknown> => {
|
|
96
329
|
const context = test || (await import('node:test')).default;
|
|
97
330
|
return steps =>
|
|
98
|
-
context.test(
|
|
331
|
+
context.test(
|
|
332
|
+
steps.name,
|
|
333
|
+
runSteps(steps, log, options) as (t: unknown) => void | Promise<void>,
|
|
334
|
+
);
|
|
99
335
|
};
|
package/src/checkpoint.test.ts
CHANGED
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
* no emitter is attached, because a dispatch path must never reach the emitter's ambient
|
|
9
9
|
* scope (F-197).
|
|
10
10
|
*/
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
IMeta,
|
|
13
|
+
IProgressEntry,
|
|
14
|
+
IProgressPoint,
|
|
15
|
+
IProgressRegion,
|
|
16
|
+
} from '@feasibleone/blong/types';
|
|
12
17
|
import {
|
|
13
18
|
attachSemanticVocabulary,
|
|
14
19
|
detachSemanticVocabulary,
|
|
@@ -19,6 +24,14 @@ import {createAttachCheckpoint} from './checkpoint.ts';
|
|
|
19
24
|
|
|
20
25
|
const MODES = ['production', 'debug', 'test'] as const;
|
|
21
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
|
+
|
|
22
35
|
t.test('a branch is taken and its rationale kept, in every mode', t => {
|
|
23
36
|
for (const mode of MODES) {
|
|
24
37
|
const meta: IMeta = {};
|
|
@@ -29,9 +42,10 @@ t.test('a branch is taken and its rationale kept, in every mode', t => {
|
|
|
29
42
|
]);
|
|
30
43
|
t.equal(result, 'declined', `${mode}: the branch is taken`);
|
|
31
44
|
t.same(
|
|
32
|
-
meta.
|
|
45
|
+
meta.progress,
|
|
33
46
|
[
|
|
34
47
|
{
|
|
48
|
+
kind: 'region',
|
|
35
49
|
discriminator: 'rate-within-limit',
|
|
36
50
|
candidates: ['decline', 'accept'],
|
|
37
51
|
chosen: 'decline',
|
|
@@ -56,10 +70,85 @@ t.test('a checkpoint exists only where points are recorded', t => {
|
|
|
56
70
|
const debug: IMeta = {};
|
|
57
71
|
createAttachCheckpoint('debug')(debug);
|
|
58
72
|
debug.checkpoint?.('total-calculated', {total: 200});
|
|
59
|
-
|
|
60
|
-
t.equal(debug.
|
|
61
|
-
t.
|
|
62
|
-
t.
|
|
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
|
+
);
|
|
63
152
|
t.end();
|
|
64
153
|
});
|
|
65
154
|
|
|
@@ -119,7 +208,7 @@ t.test('no branch matching is a recorded non-choice', t => {
|
|
|
119
208
|
const meta: IMeta = {};
|
|
120
209
|
createAttachCheckpoint('test')(meta);
|
|
121
210
|
t.equal(meta.decide?.('never', {}, [{name: 'a', when: () => false, run: () => 1}]), undefined);
|
|
122
|
-
t.equal(meta.
|
|
211
|
+
t.equal(regionsOf(meta.progress)[0]?.chosen, 'none', 'recorded as `none` rather than lost');
|
|
123
212
|
t.end();
|
|
124
213
|
});
|
|
125
214
|
|
|
@@ -159,7 +248,7 @@ t.test('a branch that throws records nothing', t => {
|
|
|
159
248
|
'the failure is not swallowed',
|
|
160
249
|
);
|
|
161
250
|
t.notOk(
|
|
162
|
-
meta.
|
|
251
|
+
meta.progress,
|
|
163
252
|
'a decision that never completed is not a decision, so nothing is recorded',
|
|
164
253
|
);
|
|
165
254
|
t.end();
|
package/src/checkpoint.ts
CHANGED
|
@@ -1,19 +1,50 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CheckpointFn,
|
|
3
|
+
DecideFn,
|
|
4
|
+
IMeta,
|
|
5
|
+
IProgressPoint,
|
|
6
|
+
IProgressRegion,
|
|
7
|
+
IRegionMark,
|
|
8
|
+
} from '@feasibleone/blong/types';
|
|
2
9
|
import {vocabulary} from '@feasibleone/semantic-log/attachable';
|
|
3
10
|
|
|
4
11
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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).
|
|
7
34
|
*
|
|
8
35
|
* Uses `this` binding — works correctly when called as
|
|
9
36
|
* $meta.checkpoint?.('name', data) with optional chaining.
|
|
10
37
|
*/
|
|
11
38
|
const checkpoint: CheckpointFn = function (this: IMeta, name: string, data?: unknown): void {
|
|
12
|
-
|
|
39
|
+
const regions = regionMarks();
|
|
40
|
+
const entry: IProgressPoint = {
|
|
41
|
+
kind: 'point',
|
|
13
42
|
name,
|
|
14
|
-
data,
|
|
43
|
+
...(data === undefined ? {} : {data}),
|
|
15
44
|
timestamp: Date.now(),
|
|
16
|
-
|
|
45
|
+
...(regions === undefined ? {} : {regions}),
|
|
46
|
+
};
|
|
47
|
+
(this.progress ??= []).push(entry);
|
|
17
48
|
// The same moment is announced to the log, so a checkpoint reaches the record the
|
|
18
49
|
// sequence diagram is drawn from rather than only the list a test asserts on. The
|
|
19
50
|
// vocabulary degrades to nothing where no emitter is attached, so this is one call
|
|
@@ -22,13 +53,14 @@ const checkpoint: CheckpointFn = function (this: IMeta, name: string, data?: unk
|
|
|
22
53
|
};
|
|
23
54
|
|
|
24
55
|
/**
|
|
25
|
-
* Takes a branch, and keeps
|
|
56
|
+
* Takes a branch, and keeps what it was and which one was taken on the invocation that took
|
|
57
|
+
* it (PRD R11/R26).
|
|
26
58
|
*
|
|
27
59
|
* `this`-bound like {@link checkpoint}. The branch's `run` is *wrapped* rather than the
|
|
28
60
|
* predicates re-evaluated: a predicate with a side effect must run exactly once, and the
|
|
29
61
|
* decision recorded has to be the one the code actually took. A branch whose `run` throws
|
|
30
|
-
* records nothing — the
|
|
31
|
-
* completed is not a decision.
|
|
62
|
+
* records nothing — the entry is withdrawn before the throw propagates, because a decision
|
|
63
|
+
* that never completed is not a decision.
|
|
32
64
|
*/
|
|
33
65
|
const decide: DecideFn = function <T>(
|
|
34
66
|
this: IMeta,
|
|
@@ -48,14 +80,37 @@ const decide: DecideFn = function <T>(
|
|
|
48
80
|
return branch.run();
|
|
49
81
|
},
|
|
50
82
|
}));
|
|
51
|
-
|
|
52
|
-
|
|
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',
|
|
53
90
|
discriminator,
|
|
54
91
|
candidates: branches.map(branch => branch.name),
|
|
55
|
-
chosen:
|
|
92
|
+
chosen: '',
|
|
56
93
|
values,
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
}
|
|
59
114
|
};
|
|
60
115
|
|
|
61
116
|
/**
|
|
@@ -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 {
|