@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.37.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.36.0...blong-gogo-v1.37.0) (2026-09-26)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* progress points in test reports ([fb73036](https://github.com/feasibleone/blong/commit/fb730360877eb371ab3d1603ae7bf35976c170dc))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* chain dynamic import ([7db222f](https://github.com/feasibleone/blong/commit/7db222f0a6469b624fa1935027f63b0593585ee6))
|
|
14
|
+
* misc CI failures ([950289d](https://github.com/feasibleone/blong/commit/950289d65e6187d10658dd0fd76361bd4d3d56e8))
|
|
15
|
+
|
|
16
|
+
## [1.36.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.35.0...blong-gogo-v1.36.0) (2026-09-24)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* add from property to leg ([4925e76](https://github.com/feasibleone/blong/commit/4925e76a7d69000e83804cd9a5fa1ea58ab26d81))
|
|
22
|
+
* docs screenshots and diagrams ([65e37d9](https://github.com/feasibleone/blong/commit/65e37d93ef3e71d65b340541e14b50a25f14e3c8))
|
|
23
|
+
* sequence diagram progress points ([dc5b153](https://github.com/feasibleone/blong/commit/dc5b15327f31698a26a75ec73bdf9a0956a43dd9))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
* build errors ([145a424](https://github.com/feasibleone/blong/commit/145a424ffc2dc4dfc14801f1002c7fa1e7a543ce))
|
|
29
|
+
|
|
3
30
|
## [1.35.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.34.0...blong-gogo-v1.35.0) (2026-09-21)
|
|
4
31
|
|
|
5
32
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/feasibleone/blong.git"
|
|
6
6
|
},
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@apidevtools/swagger-parser": "^12.1.0",
|
|
23
|
-
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
24
23
|
"@aws-sdk/client-s3": "^3.1037.0",
|
|
25
24
|
"@fastify/basic-auth": "^6.2.0",
|
|
26
25
|
"@fastify/bearer-auth": "^10.1.2",
|
|
@@ -33,6 +32,7 @@
|
|
|
33
32
|
"@fastify/swagger-ui": "^5.2.5",
|
|
34
33
|
"@fastify/type-provider-typebox": "^6.1.0",
|
|
35
34
|
"@feasibleone/blong": "^1.0.0",
|
|
35
|
+
"@feasibleone/blong-allure": "^1.0.0",
|
|
36
36
|
"@feasibleone/blong-chain": "^1.0.0",
|
|
37
37
|
"@feasibleone/blong-config": "^1.0.0",
|
|
38
38
|
"@feasibleone/blong-lib": "^1.0.0",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"@feasibleone/semantic-log": "^1.0.0",
|
|
42
42
|
"@keycloak/keycloak-admin-client": "^26.6.2",
|
|
43
43
|
"@kubernetes/client-node": "^1.4.0",
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
44
45
|
"@octokit/rest": "^22.0.1",
|
|
45
46
|
"@sinclair/typebox-codegen": "^0.11.1",
|
|
46
47
|
"@slack/webhook": "^7.0.7",
|
|
@@ -91,7 +92,7 @@
|
|
|
91
92
|
"tap": "^21.6.3",
|
|
92
93
|
"typescript": "^6.0.3",
|
|
93
94
|
"vite": "^8.2.1",
|
|
94
|
-
"@feasibleone/blong-dev": "1.
|
|
95
|
+
"@feasibleone/blong-dev": "1.7.0"
|
|
95
96
|
},
|
|
96
97
|
"scripts": {
|
|
97
98
|
"browser-check": "node scripts/browser-compat-check.mjs",
|
package/src/AdapterBase.ts
CHANGED
|
@@ -15,7 +15,14 @@ import PQueue from 'p-queue';
|
|
|
15
15
|
import ConfigRuntime from './ConfigRuntime.ts';
|
|
16
16
|
import {isExpectedError, portLogCalls} from './lib.ts';
|
|
17
17
|
import loop from './loop.ts';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
adoptInbound,
|
|
20
|
+
attachProgress,
|
|
21
|
+
beginProgress,
|
|
22
|
+
inboundIdentities,
|
|
23
|
+
isFlowId,
|
|
24
|
+
takeProgress,
|
|
25
|
+
} from './semanticContext.ts';
|
|
19
26
|
|
|
20
27
|
const errorMap: IErrorMap = {
|
|
21
28
|
'adapter.configValidation': 'Adapter config validation:\r\n{message}',
|
|
@@ -376,15 +383,36 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
|
|
|
376
383
|
// leg that arrived: that is what turns the caller's declaration into a receipt
|
|
377
384
|
// the ledger can match, and it happens only when a flow was traced at all, so an
|
|
378
385
|
// untraced request still pays nothing.
|
|
379
|
-
const answered = (): unknown => {
|
|
386
|
+
const answered = async (): Promise<unknown> => {
|
|
380
387
|
const inbound = inboundIdentities($meta as IMeta);
|
|
381
|
-
|
|
388
|
+
const traced = isFlowId(inbound.flow) && inbound.leg !== undefined;
|
|
389
|
+
if (traced) {
|
|
382
390
|
// The receipt is the receiver's record for the leg it was handed. It carries
|
|
383
391
|
// no declared target - the caller named the ends - so the ledger can tell a
|
|
384
392
|
// receipt from a declaration by the leg alone, in one process or many.
|
|
385
|
-
portLogCalls(this)?.received(inbound.leg);
|
|
393
|
+
portLogCalls(this)?.received(inbound.leg as string);
|
|
394
|
+
// And the collection starts *before* the handler runs, because the boxes it will
|
|
395
|
+
// read are installed with `enterWith`: one created after the handler's first wait
|
|
396
|
+
// never reaches the frame that answers for the call (T-136).
|
|
397
|
+
beginProgress();
|
|
386
398
|
}
|
|
387
|
-
|
|
399
|
+
const result = await run();
|
|
400
|
+
// What the handler announced while it ran (`$meta.checkpoint`, `$meta.decide`) is
|
|
401
|
+
// staged in the scope around it and written on no record of its own: the records
|
|
402
|
+
// of a call are the framework's, written before the handler starts and after it
|
|
403
|
+
// returns. Reading the progress here — inside that scope, once the handler is
|
|
404
|
+
// done — is what lets the call answer with its own work: a point becomes a note
|
|
405
|
+
// beside this hop, and a branch becomes the alternative that was taken
|
|
406
|
+
// (PRD R26/R27). Nothing is written when a handler announced nothing, so an
|
|
407
|
+
// uninstrumented call still costs one scope read.
|
|
408
|
+
if (traced) {
|
|
409
|
+
const progress = takeProgress();
|
|
410
|
+
if (progress !== undefined) {
|
|
411
|
+
attachProgress(progress);
|
|
412
|
+
portLogCalls(this)?.answered(inbound.leg as string);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return result;
|
|
388
416
|
};
|
|
389
417
|
// The callee adopts the identity its caller declared, so the records its
|
|
390
418
|
// handler writes belong to the caller's execution and its own calls are
|
package/src/Gateway.ts
CHANGED
|
@@ -462,7 +462,7 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
462
462
|
Type.Number(),
|
|
463
463
|
]),
|
|
464
464
|
result: value.result,
|
|
465
|
-
|
|
465
|
+
progress: Type.Optional(
|
|
466
466
|
Type.Array(Type.Any()),
|
|
467
467
|
),
|
|
468
468
|
}),
|
|
@@ -592,17 +592,17 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
592
592
|
// Declare the hop this route is making. The caller is
|
|
593
593
|
// **`public`** - the surface that answered the request,
|
|
594
594
|
// named apart from the namespace it calls. The gateway realm
|
|
595
|
-
// is a namespace called `gateway`, so a
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
595
|
+
// is a namespace called `gateway`, so a caller that was also
|
|
596
|
+
// called `gateway` drew the public surface and that namespace
|
|
597
|
+
// as one participant, and a realm answering a call to its own
|
|
598
|
+
// namespace read as a self-call. The API surface is a unit of
|
|
599
|
+
// its own - not the process either: one process hosts a whole
|
|
600
|
+
// suite, so naming the process here would draw every flow of a
|
|
601
|
+
// monolith as the same participant.
|
|
602
|
+
// `methodName` is the wire name the request asked for, and it
|
|
603
|
+
// is the leg: the caller is declared beside it, so a diagram
|
|
604
|
+
// labels the arrow with the method and draws its source from
|
|
605
|
+
// the unit. The callee's receipt then carries the same leg,
|
|
606
606
|
// which is what makes the drawing a call rather than a
|
|
607
607
|
// participation.
|
|
608
608
|
declareCall(
|
|
@@ -643,10 +643,8 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
643
643
|
jsonrpc: '2.0',
|
|
644
644
|
id,
|
|
645
645
|
result,
|
|
646
|
-
...((resultMeta as {
|
|
647
|
-
|
|
648
|
-
checkpoints: (resultMeta as {checkpoints?: unknown[]})
|
|
649
|
-
.checkpoints,
|
|
646
|
+
...((resultMeta as {progress?: unknown[]})?.progress?.length && {
|
|
647
|
+
progress: (resultMeta as {progress?: unknown[]}).progress,
|
|
650
648
|
}),
|
|
651
649
|
};
|
|
652
650
|
}
|
package/src/Registry.ts
CHANGED
|
@@ -25,6 +25,7 @@ import merge from 'ut-function.merge';
|
|
|
25
25
|
|
|
26
26
|
import * as blongLib from '@feasibleone/blong-lib';
|
|
27
27
|
import {renderAll} from '@feasibleone/blong-template';
|
|
28
|
+
import {vocabulary} from '@feasibleone/semantic-log/attachable';
|
|
28
29
|
import {createAttachCheckpoint} from './checkpoint.ts';
|
|
29
30
|
import {methodId, methodParts} from './lib.ts';
|
|
30
31
|
import type {IResolution} from './Resolution.ts';
|
|
@@ -369,13 +370,25 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
369
370
|
port: object | undefined,
|
|
370
371
|
): Promise<{local: object; literals: object[]}> {
|
|
371
372
|
const attachCheckpoint = this.#attachCheckpoint;
|
|
373
|
+
const recording = (this.#config.checkpointMode ?? 'production') !== 'production';
|
|
372
374
|
const lib = {
|
|
375
|
+
// The library's own surface first, so an injected handle can never be silently
|
|
376
|
+
// overwritten by a name it happens to share: `blongLib` exports `checkpoint`
|
|
377
|
+
// for the snapshot marker, and the spread used to sit *after* these members.
|
|
378
|
+
...blongLib,
|
|
373
379
|
error: this.#error.register.bind(this.#error),
|
|
374
|
-
assert:
|
|
380
|
+
assert: recording ? nodeAssert : undefined,
|
|
381
|
+
// The progress-point pair (PRD R26). `checkpoint` reports a milestone from code that
|
|
382
|
+
// has no `$meta` — it is the emitter's own `point` reached through the attachable
|
|
383
|
+
// facade, and it is `undefined` in production for the same reason `$meta.checkpoint`
|
|
384
|
+
// is: a note nobody records must cost nothing. `decide` selects, so it is always
|
|
385
|
+
// there, and it is the same function in every mode — what the mode decides is whether
|
|
386
|
+
// a record carries the rationale, not whether the branch is taken.
|
|
387
|
+
checkpoint: recording ? vocabulary.point : undefined,
|
|
388
|
+
decide: vocabulary.decide,
|
|
375
389
|
render: (what: object[] | object) =>
|
|
376
390
|
renderAll(Array.isArray(what) ? merge(...what) : what, this.#platform.context),
|
|
377
391
|
timing: this.#platform.timing,
|
|
378
|
-
...blongLib,
|
|
379
392
|
// The runtime services a `library()` function needs, mirroring the
|
|
380
393
|
// port API (`registry: this`, `platform: …`).
|
|
381
394
|
//
|
package/src/RpcClient.ts
CHANGED
|
@@ -195,9 +195,9 @@ export default class RpcClientImpl extends RemoteImpl implements IRpcClient {
|
|
|
195
195
|
const result = await decode(body.result);
|
|
196
196
|
if (/\.service\.get$/.test(method!))
|
|
197
197
|
Object.assign((result as Record<string, unknown>[])[0], requestParams);
|
|
198
|
-
if ((body as unknown as {
|
|
199
|
-
((callerMeta as {
|
|
200
|
-
...(body as unknown as {
|
|
198
|
+
if ((body as unknown as {progress?: unknown[]}).progress?.length) {
|
|
199
|
+
((callerMeta as {progress?: unknown[]}).progress ??= []).push(
|
|
200
|
+
...(body as unknown as {progress: unknown[]}).progress,
|
|
201
201
|
);
|
|
202
202
|
}
|
|
203
203
|
return result;
|
package/src/RpcServer.ts
CHANGED
|
@@ -85,8 +85,8 @@ export default class RpcServer extends Internal implements IRpcServer {
|
|
|
85
85
|
jsonrpc: '2.0',
|
|
86
86
|
id,
|
|
87
87
|
result,
|
|
88
|
-
...((newMeta as {
|
|
89
|
-
|
|
88
|
+
...((newMeta as {progress?: unknown[]}).progress?.length && {
|
|
89
|
+
progress: (newMeta as {progress?: unknown[]}).progress,
|
|
90
90
|
}),
|
|
91
91
|
};
|
|
92
92
|
}
|
package/src/SemanticLog.test.ts
CHANGED
|
@@ -52,6 +52,54 @@ t.test('a cache configured without a bound takes the default', async t => {
|
|
|
52
52
|
t.equal(log.store.stats().size, 1, 'the default bound retains a record comfortably');
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
t.test('a record carries the branch and the points of the scope that wrote it', async t => {
|
|
56
|
+
// PRD R26/R27: the framework's logger must not have to know about progress points for them to
|
|
57
|
+
// reach a record — they ride the emitter's ambient scope, and what this pins is that the
|
|
58
|
+
// framework's own path (`semanticRecord.ts`, `LogBase`, the call channel that goes through the
|
|
59
|
+
// same logger) reads that scope rather than writing a record of its own.
|
|
60
|
+
const dir = await mkdtemp(join(tmpdir(), 'blong-semantic-log-'));
|
|
61
|
+
t.teardown(() => rm(dir, {recursive: true, force: true}));
|
|
62
|
+
const log = new SemanticLog({level: 'info', cache: {dir}});
|
|
63
|
+
await log.init();
|
|
64
|
+
const at = log.logger('info', {name: 'progress'});
|
|
65
|
+
|
|
66
|
+
const chosen = vocabulary.decide('route-selection', {corridor: 'EUR-USD'}, [
|
|
67
|
+
{name: 'hold', when: () => false, run: () => 'hold'},
|
|
68
|
+
{
|
|
69
|
+
name: 'hubB',
|
|
70
|
+
when: () => true,
|
|
71
|
+
run: () => {
|
|
72
|
+
vocabulary.point('corridor-resolved', {hub: 'B'});
|
|
73
|
+
at.info?.('routing to the far ecosystem');
|
|
74
|
+
return 'hubB';
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
t.equal(chosen, 'hubB', 'the branch ran');
|
|
79
|
+
await log.stop();
|
|
80
|
+
|
|
81
|
+
let record: {msg?: string; progress?: {regions?: unknown[]; points?: unknown[]}} | undefined;
|
|
82
|
+
for (const id of await cacheRecordIds(dir)) {
|
|
83
|
+
const candidate = await log.store.get(id);
|
|
84
|
+
if (candidate?.msg === 'routing to the far ecosystem') {
|
|
85
|
+
record = candidate;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
t.ok(record, 'the record the branch wrote was retained');
|
|
89
|
+
t.same(
|
|
90
|
+
(record?.progress?.regions as Array<{discriminator: string; chosen: string}>).map(
|
|
91
|
+
region => [region.discriminator, region.chosen],
|
|
92
|
+
),
|
|
93
|
+
[['route-selection', 'hubB']],
|
|
94
|
+
'it was emitted inside the branch, which is what a diagram draws as an alt block',
|
|
95
|
+
);
|
|
96
|
+
t.same(
|
|
97
|
+
(record?.progress?.points as Array<{name: string}>).map(point => point.name),
|
|
98
|
+
['corridor-resolved'],
|
|
99
|
+
'and the point announced in that scope rides the same record',
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
55
103
|
t.test('the home-relative cache path the framework uses is expanded', t => {
|
|
56
104
|
// The framework's own configuration names the shared log cache as
|
|
57
105
|
// `~/.blong/log-cache`, and the pino transport expands that itself — so a
|
|
@@ -276,7 +324,17 @@ t.test('every level is bound, and emits at its own level', async t => {
|
|
|
276
324
|
// `fatal` is written synchronously, so it is staged rather than stored: it is
|
|
277
325
|
// the one level whose record survives an exit that drains no I/O, and it
|
|
278
326
|
// reaches the store on the next open. The other five take the queued path.
|
|
279
|
-
|
|
327
|
+
//
|
|
328
|
+
// An error appears twice, and that is the tenant model rather than a duplicate
|
|
329
|
+
// write: every record is stored once under its **shape** (keyed by the shape
|
|
330
|
+
// reference, standing for every occurrence of it), and a record whose denoise
|
|
331
|
+
// would hide something — an error, or one that withheld a payload — is stored
|
|
332
|
+
// under its own id as well, because that occurrence is what is being diagnosed.
|
|
333
|
+
t.same(
|
|
334
|
+
levels.sort(),
|
|
335
|
+
['debug', 'error', 'error', 'info', 'trace', 'warn'],
|
|
336
|
+
'every level is stored, and the error under its shape and its own id',
|
|
337
|
+
);
|
|
280
338
|
const staged = readFileSync(cachePaths.sidecarFile(dir), 'utf8')
|
|
281
339
|
.trim()
|
|
282
340
|
.split('\n')
|
|
@@ -284,8 +342,8 @@ t.test('every level is bound, and emits at its own level', async t => {
|
|
|
284
342
|
.map(entry => JSON.parse(entry.json) as {levelName: string});
|
|
285
343
|
t.same(
|
|
286
344
|
staged.map(entry => entry.levelName),
|
|
287
|
-
['fatal'],
|
|
288
|
-
'the fatal record is staged,
|
|
345
|
+
['fatal', 'fatal'],
|
|
346
|
+
'the fatal record is staged under its shape and its own id, and never stored',
|
|
289
347
|
);
|
|
290
348
|
await cache.close();
|
|
291
349
|
});
|
|
@@ -329,13 +387,14 @@ t.test('a record made inside a flow names the flow, the call and the position',
|
|
|
329
387
|
);
|
|
330
388
|
const [id] = await cacheRecordIds(dir);
|
|
331
389
|
const record = (await log.store.get(id ?? '')) as unknown as {
|
|
332
|
-
flow?: {id?: string; leg?: string; legTo?: string; legSeq?: string};
|
|
390
|
+
flow?: {id?: string; leg?: string; legFrom?: string; legTo?: string; legSeq?: string};
|
|
333
391
|
};
|
|
334
392
|
t.equal(
|
|
335
393
|
record.flow?.leg,
|
|
336
|
-
'
|
|
337
|
-
'the record names the call it was made in',
|
|
394
|
+
'party.subject.find',
|
|
395
|
+
'the record names the call it was made in, by the method it was made by',
|
|
338
396
|
);
|
|
397
|
+
t.equal(record.flow?.legFrom, 'access.db', 'and the unit that made it, beside the method');
|
|
339
398
|
t.equal(record.flow?.legTo, 'party', 'and the participant the caller aimed at');
|
|
340
399
|
t.match(String(record.flow?.legSeq), /^[0-9]+$/, 'and its position in the execution');
|
|
341
400
|
t.equal(isFlowId(record.flow?.id), true, 'and the execution it is part of');
|
|
@@ -382,8 +441,8 @@ t.test('the records of a flow reach the cluster service and come back as a diagr
|
|
|
382
441
|
t.equal(diagram?.statusCode, 200, 'the execution can be drawn from the records alone');
|
|
383
442
|
t.match(
|
|
384
443
|
diagram?.body ?? '',
|
|
385
|
-
/
|
|
386
|
-
'and the drawing names the call the caller declared',
|
|
444
|
+
/party\.subject\.find/,
|
|
445
|
+
'and the drawing names the call the caller declared, by its method',
|
|
387
446
|
);
|
|
388
447
|
});
|
|
389
448
|
|
package/src/SemanticLog.ts
CHANGED
|
@@ -85,7 +85,7 @@ export interface SemanticLogConfig {
|
|
|
85
85
|
/**
|
|
86
86
|
* Retain records on disk so a printed reference resolves later. The directory is
|
|
87
87
|
* the framework's log cache, which the pino transport also writes — one store,
|
|
88
|
-
* shared, so a `
|
|
88
|
+
* shared, so a `semlog://r/<id>` reference resolves whichever
|
|
89
89
|
* implementation emitted it.
|
|
90
90
|
*/
|
|
91
91
|
cache?: RetentionOptions;
|
package/src/Watch.ts
CHANGED
|
@@ -22,6 +22,12 @@ import type {Dirent} from 'node:fs';
|
|
|
22
22
|
import merge from 'ut-function.merge';
|
|
23
23
|
|
|
24
24
|
import layerProxy from './layerProxy.ts';
|
|
25
|
+
// The chain is server-side code — it pulls `node:assert` and the event emitter the test
|
|
26
|
+
// executor is built on — so only its types may be imported statically: a value import
|
|
27
|
+
// puts the chain in the browser's eager module graph, where linking its `node:events`
|
|
28
|
+
// import fails before a single handler runs. The Allure session is ended through a
|
|
29
|
+
// dynamic import for the same reason.
|
|
30
|
+
import type {IAllureRunOptions} from './chain.ts';
|
|
25
31
|
import {runInFlow, withStep} from './semanticContext.ts';
|
|
26
32
|
|
|
27
33
|
export interface IWatch {
|
|
@@ -82,6 +88,8 @@ interface IConfig {
|
|
|
82
88
|
ignored: string[];
|
|
83
89
|
configs: string[];
|
|
84
90
|
logLevel: Parameters<ILog['logger']>[0];
|
|
91
|
+
/** Allure reporting options, as the framework config supplies them. */
|
|
92
|
+
allure?: IAllureRunOptions;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
// ---------------------------------------------------------------------------
|
|
@@ -115,6 +123,7 @@ export default class Watch extends Internal implements IWatch {
|
|
|
115
123
|
ignored: [],
|
|
116
124
|
configs: [],
|
|
117
125
|
logLevel: 'debug',
|
|
126
|
+
allure: undefined,
|
|
118
127
|
};
|
|
119
128
|
|
|
120
129
|
#handlerFolders: Map<string, {name: string; pkg: IModuleConfig['pkg']; base: string}> =
|
|
@@ -739,10 +748,6 @@ export default class Watch extends Internal implements IWatch {
|
|
|
739
748
|
test?: unknown;
|
|
740
749
|
};
|
|
741
750
|
try {
|
|
742
|
-
const chain = await (
|
|
743
|
-
await import('./chain.ts')
|
|
744
|
-
).default(test as ITestContext, this.log);
|
|
745
|
-
|
|
746
751
|
const groups = ([] as string[]).concat(this.#config.test);
|
|
747
752
|
let executed = 0;
|
|
748
753
|
await withProgress(
|
|
@@ -763,6 +768,22 @@ export default class Watch extends Internal implements IWatch {
|
|
|
763
768
|
// were then made in no flow at all, and the observed flow the
|
|
764
769
|
// run was supposed to leave behind never existed.
|
|
765
770
|
const meta: IMeta = {mtid: 'event', method};
|
|
771
|
+
// The chain is built per group because it carries the
|
|
772
|
+
// group's wire name: Allure labels come from it, and the
|
|
773
|
+
// result's own name is the group's display name, which is
|
|
774
|
+
// set on the steps just below.
|
|
775
|
+
const chain = await (
|
|
776
|
+
await import('./chain.ts')
|
|
777
|
+
).default(test as ITestContext, this.log, {
|
|
778
|
+
method,
|
|
779
|
+
allure: this.#config.allure,
|
|
780
|
+
// The Allure integration writes files, which a browser
|
|
781
|
+
// platform has nowhere to do: the platform is handed over
|
|
782
|
+
// so the chain can explain the report a browser run asked
|
|
783
|
+
// for and cannot have, instead of failing to import its
|
|
784
|
+
// way to the same end once per group.
|
|
785
|
+
platform: this.#platform.platform,
|
|
786
|
+
});
|
|
766
787
|
await runInFlow(meta, method, async () => {
|
|
767
788
|
const steps = await remote.remote(method)({}, meta);
|
|
768
789
|
if (Array.isArray(steps) && !('name' in steps)) {
|
|
@@ -795,6 +816,14 @@ export default class Watch extends Internal implements IWatch {
|
|
|
795
816
|
this.log?.error?.(error);
|
|
796
817
|
done?.(error);
|
|
797
818
|
return;
|
|
819
|
+
} finally {
|
|
820
|
+
// The results directory belongs to the run, not to any one group,
|
|
821
|
+
// so the session closes once every group has reported. The chain is
|
|
822
|
+
// imported here rather than at the top of the file so that a
|
|
823
|
+
// platform which never runs a group never links it.
|
|
824
|
+
await (await import('./chain.ts'))
|
|
825
|
+
.endAllureSession()
|
|
826
|
+
.catch(error => this.log?.error?.(error));
|
|
798
827
|
}
|
|
799
828
|
done?.();
|
|
800
829
|
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a handler announced, on the answer of the call it handled (PRD R26/R27).
|
|
3
|
+
*
|
|
4
|
+
* The gap this closes, measured before it was closed: a realm handler announced five points and
|
|
5
|
+
* one branch through `$meta.checkpoint` / `$meta.decide`, and **no record carried any of them** —
|
|
6
|
+
* 0 of 5056 retained records. The handles work; the records do not. A call's records are written
|
|
7
|
+
* by the framework *around* the handler (the receipt before it runs, the answer from the caller's
|
|
8
|
+
* end), so the ambient scope the handler staged into never emits one, and everything it announced
|
|
9
|
+
* is dropped with that scope.
|
|
10
|
+
*
|
|
11
|
+
* What is pinned here is the receiver's half: the adapter reads what its handler announced once
|
|
12
|
+
* the handler returns and reports it on the leg it answered, which is a record the ledger already
|
|
13
|
+
* matches. Two properties matter and both are asserted below — the progress is *re-announced* (so
|
|
14
|
+
* the logger's own facet machinery puts it on the record, rather than a second way of writing
|
|
15
|
+
* records being invented), and nothing is written when the handler announced nothing.
|
|
16
|
+
*/
|
|
17
|
+
import type {IMeta} from '@feasibleone/blong/types';
|
|
18
|
+
import {attachSemanticVocabulary} from '@feasibleone/semantic-log/attachable';
|
|
19
|
+
import * as vocabulary from '@feasibleone/semantic-log/emitter';
|
|
20
|
+
import t from 'tap';
|
|
21
|
+
import {AdapterBase} from './AdapterBase.ts';
|
|
22
|
+
import {createAttachCheckpoint} from './checkpoint.ts';
|
|
23
|
+
|
|
24
|
+
// The framework reaches the vocabulary by attachment (the emitter's scope is Node-only, and the
|
|
25
|
+
// browser bootstrap loads this module too), so a tap process attaches it exactly as `loadServer.ts`
|
|
26
|
+
// does. Without it the handles degrade and there is nothing to report.
|
|
27
|
+
attachSemanticVocabulary(vocabulary);
|
|
28
|
+
|
|
29
|
+
/** A canonical flow ULID. */
|
|
30
|
+
const FLOW = '01ARZ3NDEKTSV4RRFFQ69G5FAV';
|
|
31
|
+
|
|
32
|
+
/** The leg the caller declared for the call this adapter answers. */
|
|
33
|
+
const LEG = 'gateway.bundle.merge';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The `$meta` a receiver is handed, built the way the wire builds it: the identities travel in
|
|
37
|
+
* the header bag, and the leg is in it because the caller declared one.
|
|
38
|
+
*/
|
|
39
|
+
function inboundMeta(): IMeta {
|
|
40
|
+
const forward = vocabulary.withFlow({id: FLOW, kind: 'transfer.single'}, () =>
|
|
41
|
+
vocabulary.bindLeg({id: LEG, from: 'public', to: 'gateway'}, () =>
|
|
42
|
+
vocabulary.identityHeaders(),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
return {mtid: 'event', method: 'exec', forward};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the fake call channel saw, with the progress staged at the moment it was asked. */
|
|
49
|
+
interface ISeen {
|
|
50
|
+
phase: string;
|
|
51
|
+
leg: string;
|
|
52
|
+
points?: unknown;
|
|
53
|
+
regions?: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The adapter under test: the real `handle`, on an instance built without a constructor.
|
|
58
|
+
*
|
|
59
|
+
* `handle` reads exactly three things — the imported handler table, the checkpoint attach hook and
|
|
60
|
+
* the call channel — so those are the three that are set. The alternative, going through
|
|
61
|
+
* `adapterFactory`, would drag in the registry, the schema and the config runtime to test a
|
|
62
|
+
* twenty-line method.
|
|
63
|
+
*/
|
|
64
|
+
function receiver(handler: (params: unknown[]) => unknown, seen: ISeen[]) {
|
|
65
|
+
const adapter = Object.create(AdapterBase.prototype) as AdapterBase<unknown, never>;
|
|
66
|
+
adapter.imported = {exec: handler as never};
|
|
67
|
+
adapter._attachCheckpoint = createAttachCheckpoint('debug');
|
|
68
|
+
// The method-id hook is the constructor's, and it is asked before the handler is found. Identity
|
|
69
|
+
// is the honest stub for a test: the real one names a method the way its realm does.
|
|
70
|
+
adapter._methodId = (name: string) => name;
|
|
71
|
+
(adapter as unknown as {log: unknown}).log = {
|
|
72
|
+
calls: {
|
|
73
|
+
enabled: () => true,
|
|
74
|
+
start: () => undefined,
|
|
75
|
+
end: () => undefined,
|
|
76
|
+
error: () => undefined,
|
|
77
|
+
received: (leg: string) => void seen.push({phase: 'received', leg}),
|
|
78
|
+
// Read at the moment the answer is written, which is the whole point: what the
|
|
79
|
+
// framework staged onto the scope is what the record will carry.
|
|
80
|
+
answered: (leg: string) => {
|
|
81
|
+
const points = vocabulary.takePoints();
|
|
82
|
+
const regions = vocabulary.currentRegions();
|
|
83
|
+
seen.push({
|
|
84
|
+
phase: 'answered',
|
|
85
|
+
leg,
|
|
86
|
+
...(points ? {points} : {}),
|
|
87
|
+
...(regions ? {regions} : {}),
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
return adapter;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
t.test('the answer of a call carries what its handler announced', async t => {
|
|
96
|
+
const seen: ISeen[] = [];
|
|
97
|
+
const meta = inboundMeta();
|
|
98
|
+
const adapter = receiver(() => {
|
|
99
|
+
meta.checkpoint?.('merge-started', {bundles: 1});
|
|
100
|
+
return meta.decide?.('role-bit', {declared: null}, [
|
|
101
|
+
{name: 'allocated', when: () => true, run: () => 'allocated'},
|
|
102
|
+
{name: 'declared', when: () => false, run: () => 'declared'},
|
|
103
|
+
]);
|
|
104
|
+
}, seen);
|
|
105
|
+
|
|
106
|
+
const result = await adapter.handle({}, meta);
|
|
107
|
+
t.equal(result, 'allocated', 'the handler answered');
|
|
108
|
+
|
|
109
|
+
t.same(
|
|
110
|
+
seen.map(event => event.phase),
|
|
111
|
+
['received', 'answered'],
|
|
112
|
+
'the receiver writes a receipt when the call arrives and an answer when its work is done',
|
|
113
|
+
);
|
|
114
|
+
t.equal(seen[1]?.leg, LEG, 'and the answer names the leg it was handed');
|
|
115
|
+
t.same(
|
|
116
|
+
seen[1]?.points,
|
|
117
|
+
[{name: 'merge-started', data: {bundles: 1}}],
|
|
118
|
+
'the point the handler announced is staged for the answer',
|
|
119
|
+
);
|
|
120
|
+
t.same(
|
|
121
|
+
(
|
|
122
|
+
seen[1]?.regions as Array<{
|
|
123
|
+
discriminator: string;
|
|
124
|
+
chosen: string;
|
|
125
|
+
candidates: string[];
|
|
126
|
+
}>
|
|
127
|
+
).map(region => [region.discriminator, region.chosen, region.candidates]),
|
|
128
|
+
[['role-bit', 'allocated', ['allocated', 'declared']]],
|
|
129
|
+
'and so is the branch it took, so the block can be drawn around this hop',
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
t.test('a handler that waits before it announces is still answered for', async t => {
|
|
134
|
+
// The regression this pins: the collection's boxes are installed with `enterWith`, which
|
|
135
|
+
// reaches the current context and what follows it — never a frame that is already suspended.
|
|
136
|
+
// A handler that awaits before it announces anything resumes in a context whose boxes were
|
|
137
|
+
// created after the receiver's continuation was bound, so without installing them first the
|
|
138
|
+
// branch mark (and every point staged after the wait) reached no record: measured live, the
|
|
139
|
+
// points survived and the branch did not.
|
|
140
|
+
const seen: ISeen[] = [];
|
|
141
|
+
const meta = inboundMeta();
|
|
142
|
+
const adapter = receiver(async () => {
|
|
143
|
+
await new Promise(resolve => setTimeout(resolve, 1));
|
|
144
|
+
meta.checkpoint?.('after-the-wait');
|
|
145
|
+
return meta.decide?.('role-bit', {declared: null}, [
|
|
146
|
+
{name: 'allocated', when: () => true, run: () => 'allocated'},
|
|
147
|
+
{name: 'declared', when: () => false, run: () => 'declared'},
|
|
148
|
+
]);
|
|
149
|
+
}, seen);
|
|
150
|
+
|
|
151
|
+
await adapter.handle({}, meta);
|
|
152
|
+
t.same(
|
|
153
|
+
seen.map(event => event.phase),
|
|
154
|
+
['received', 'answered'],
|
|
155
|
+
'the answer is still written after a wait',
|
|
156
|
+
);
|
|
157
|
+
t.same(seen[1]?.points, [{name: 'after-the-wait'}], 'with the point announced after it');
|
|
158
|
+
t.same(
|
|
159
|
+
(
|
|
160
|
+
seen[1]?.regions as Array<{
|
|
161
|
+
discriminator: string;
|
|
162
|
+
chosen: string;
|
|
163
|
+
candidates: string[];
|
|
164
|
+
}>
|
|
165
|
+
).map(region => [region.discriminator, region.chosen, region.candidates]),
|
|
166
|
+
[['role-bit', 'allocated', ['allocated', 'declared']]],
|
|
167
|
+
'and the branch it took, which is what the alt block is drawn from',
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
t.test('a handler that announced nothing writes no answer', async t => {
|
|
172
|
+
const seen: ISeen[] = [];
|
|
173
|
+
const meta = inboundMeta();
|
|
174
|
+
const adapter = receiver(() => ({success: true}), seen);
|
|
175
|
+
await adapter.handle({}, meta);
|
|
176
|
+
t.same(
|
|
177
|
+
seen.map(event => event.phase),
|
|
178
|
+
['received'],
|
|
179
|
+
'an uninstrumented call still pays nothing beyond its receipt',
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
t.test('an untraced call records nothing at all', async t => {
|
|
184
|
+
const seen: ISeen[] = [];
|
|
185
|
+
const adapter = receiver(() => {
|
|
186
|
+
return {success: true};
|
|
187
|
+
}, seen);
|
|
188
|
+
await adapter.handle({}, {mtid: 'event', method: 'exec'});
|
|
189
|
+
t.same(seen, [], 'no flow on the call, so no records — the gate is asked first');
|
|
190
|
+
});
|