@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 CHANGED
@@ -1,5 +1,18 @@
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
+
3
16
  ## [1.36.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.35.0...blong-gogo-v1.36.0) (2026-09-24)
4
17
 
5
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.36.0",
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.6.0"
95
+ "@feasibleone/blong-dev": "1.7.0"
95
96
  },
96
97
  "scripts": {
97
98
  "browser-check": "node scripts/browser-compat-check.mjs",
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
- checkpoints: Type.Optional(
465
+ progress: Type.Optional(
466
466
  Type.Array(Type.Any()),
467
467
  ),
468
468
  }),
@@ -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 {checkpoints?: unknown[]})?.checkpoints
647
- ?.length && {
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/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 {checkpoints?: unknown[]}).checkpoints?.length) {
199
- ((callerMeta as {checkpoints?: unknown[]}).checkpoints ??= []).push(
200
- ...(body as unknown as {checkpoints: unknown[]}).checkpoints,
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 {checkpoints?: unknown[]}).checkpoints?.length && {
89
- checkpoints: (newMeta as {checkpoints?: unknown[]}).checkpoints,
88
+ ...((newMeta as {progress?: unknown[]}).progress?.length && {
89
+ progress: (newMeta as {progress?: unknown[]}).progress,
90
90
  }),
91
91
  };
92
92
  }
@@ -324,7 +324,17 @@ t.test('every level is bound, and emits at its own level', async t => {
324
324
  // `fatal` is written synchronously, so it is staged rather than stored: it is
325
325
  // the one level whose record survives an exit that drains no I/O, and it
326
326
  // reaches the store on the next open. The other five take the queued path.
327
- t.same(levels.sort(), ['debug', 'error', 'info', 'trace', 'warn']);
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
+ );
328
338
  const staged = readFileSync(cachePaths.sidecarFile(dir), 'utf8')
329
339
  .trim()
330
340
  .split('\n')
@@ -332,8 +342,8 @@ t.test('every level is bound, and emits at its own level', async t => {
332
342
  .map(entry => JSON.parse(entry.json) as {levelName: string});
333
343
  t.same(
334
344
  staged.map(entry => entry.levelName),
335
- ['fatal'],
336
- 'the fatal record is staged, not stored',
345
+ ['fatal', 'fatal'],
346
+ 'the fatal record is staged under its shape and its own id, and never stored',
337
347
  );
338
348
  await cache.close();
339
349
  });
@@ -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 `semantic-log://record/<id>` reference resolves whichever
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,62 @@
1
+ import {readFileSync, readdirSync} from 'node:fs';
2
+ import {dirname, join} from 'node:path';
3
+ import {fileURLToPath} from 'node:url';
4
+ import {test} from 'tap';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Node-only modules must not be imported for a value.
8
+ //
9
+ // A browser build stubs what it cannot resolve and carries on, so an eager import of a
10
+ // node-only module survives CI and only shows up in a teardown-free dev server: vite
11
+ // links the module graph before any code runs and refuses a *named* import from its
12
+ // browser-external stub, which takes the whole page down ("The requested module
13
+ // '/@id/__vite-browser-external:node:events' does not provide an export named
14
+ // 'EventEmitter'"). The browser entry reaches `Watch.ts` through a dynamic import the
15
+ // loader executes at startup, so a static import inside it is as eager as one made by
16
+ // the entry itself — this scans the sources rather than a bundle because a dynamic edge
17
+ // hides the module from a bundler's static view.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const srcDir = dirname(fileURLToPath(import.meta.url));
21
+
22
+ interface INodeOnlyModule {
23
+ /** Import specifier as written in the sources. */
24
+ specifier: string;
25
+ /** What makes it unusable in a browser. */
26
+ reason: string;
27
+ }
28
+
29
+ const NODE_ONLY: INodeOnlyModule[] = [
30
+ {
31
+ specifier: './chain.ts',
32
+ reason: 'it needs node:assert, node:test and the event emitter its executor extends',
33
+ },
34
+ ];
35
+
36
+ /** `import x from '...'`, `export {x} from '...'` and side-effect imports, but not `import type`. */
37
+ const eagerImport = (specifier: string) =>
38
+ new RegExp(
39
+ `^\\s*(?:import|export)\\s+(?!type\\s)[^;]*?from\\s*['"]${specifier}['"]|^\\s*import\\s*['"]${specifier}['"]`,
40
+ 'gm',
41
+ );
42
+
43
+ test('a node-only module is never imported for a value', async t => {
44
+ const files = readdirSync(srcDir).filter(
45
+ name => name.endsWith('.ts') && !name.endsWith('.test.ts'),
46
+ );
47
+ t.ok(files.length > 0, 'the sources were found');
48
+
49
+ for (const {specifier, reason} of NODE_ONLY) {
50
+ const pattern = eagerImport(specifier);
51
+ const offenders = files.flatMap(name =>
52
+ [...readFileSync(join(srcDir, name), 'utf8').matchAll(pattern)].map(
53
+ match => `${name}: ${match[0].trim()}`,
54
+ ),
55
+ );
56
+ t.same(
57
+ offenders,
58
+ [],
59
+ `${specifier} is imported eagerly (${reason}); import it dynamically, or for its types only:\n ${offenders.join('\n ')}`,
60
+ );
61
+ }
62
+ });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * What the chain runner does with Allure reporting when it cannot have it.
3
+ *
4
+ * Allure 3's format is a directory of files, so the integration needs a filesystem.
5
+ * A browser platform has none, and the lazy import of `blong-allure` there can only
6
+ * fail — which the runner used to discover once per group, leaving a run with no
7
+ * results and no explanation of why. These tests pin the two halves of the fix: the
8
+ * platform decides *before* the attempt, and the reason is reported once for the run
9
+ * rather than once per group.
10
+ *
11
+ * Each test imports the module fresh: the session, the run's options and the reason
12
+ * it cannot report are module-level state, and a test that inherited them from the
13
+ * one before it would be asserting the previous test's outcome.
14
+ */
15
+
16
+ import {existsSync} from 'node:fs';
17
+ import {mkdtemp, readdir, readFile, rm} from 'node:fs/promises';
18
+ import {tmpdir} from 'node:os';
19
+ import {join} from 'node:path';
20
+ import {test} from 'tap';
21
+
22
+ /** A test context that runs what it is given, the way the runner's own does. */
23
+ interface IContext {
24
+ test: (name: string, fn: (t: IContext) => unknown) => Promise<unknown>;
25
+ }
26
+
27
+ function contextAt(): IContext {
28
+ return {
29
+ test: async (_name: string, fn: (t: IContext) => unknown) => fn(contextAt()),
30
+ };
31
+ }
32
+
33
+ /** A group's steps, named the way the runner names them from the wire name. */
34
+ function group(name: string, steps: Array<() => unknown>): Array<() => unknown> {
35
+ const wrapped = [...steps];
36
+ Object.defineProperty(wrapped, 'name', {value: name});
37
+ return wrapped;
38
+ }
39
+
40
+ /** The log the runner is handed, recording what a run would have shown a reader. */
41
+ function recordingLog(): {
42
+ warnings: string[];
43
+ errors: unknown[];
44
+ log: {warn: (text: unknown) => void; error: (error: unknown) => void};
45
+ } {
46
+ const warnings: string[] = [];
47
+ const errors: unknown[] = [];
48
+ return {
49
+ warnings,
50
+ errors,
51
+ log: {
52
+ warn: (text: unknown) => void warnings.push(String(text)),
53
+ error: (error: unknown) => void errors.push(error),
54
+ },
55
+ };
56
+ }
57
+
58
+ test('a browser run is told why it produces no report, once for the run', async t => {
59
+ const dir = await mkdtemp(join(tmpdir(), 'blong-chain-allure-'));
60
+ t.teardown(() => rm(dir, {recursive: true, force: true}));
61
+ const outputDir = join(dir, 'allure-results');
62
+ const {warnings, errors, log} = recordingLog();
63
+ const ran: string[] = [];
64
+ const {default: chainFactory} = await t.mockImport<typeof import('./chain.ts')>(
65
+ './chain.ts',
66
+ {},
67
+ );
68
+
69
+ const chain = await chainFactory(contextAt() as never, log, {
70
+ method: 'test.order.checkpoint',
71
+ allure: {enabled: true, outputDir},
72
+ platform: 'browser',
73
+ });
74
+ const run = chain as unknown as (steps: unknown) => Promise<unknown>;
75
+ await run(
76
+ group('order checkpoint', [
77
+ async function loadsOrder() {
78
+ ran.push('loadsOrder');
79
+ return 'loaded';
80
+ },
81
+ ]),
82
+ );
83
+ // A second group, whose own attempt would be a second opportunity to say the
84
+ // same thing: the answer belongs to the run, so it is said once.
85
+ await run(
86
+ group('payment settle', [
87
+ async function sendsPayment() {
88
+ ran.push('sendsPayment');
89
+ return 'sent';
90
+ },
91
+ ]),
92
+ );
93
+
94
+ t.same(
95
+ ran,
96
+ ['loadsOrder', 'sendsPayment'],
97
+ 'the groups ran: a withheld report is not a withheld test',
98
+ );
99
+ t.equal(warnings.length, 1, 'the reader is told once, not once per group');
100
+ t.match(warnings[0] ?? '', /browser platform cannot write its files/, 'and told the reason');
101
+ t.match(warnings[0] ?? '', /Allure reporting is enabled/, 'as a configuration mistake');
102
+ t.same(errors, [], 'nothing failed: the tests are judged on their tests');
103
+ t.notOk(existsSync(outputDir), 'and no results directory was created');
104
+
105
+ // The end of the run closes nothing, because nothing opened: no session means no
106
+ // import, which on this platform is the one thing that cannot work.
107
+ await (await import('./chain.ts')).endAllureSession();
108
+ });
109
+
110
+ test('a server run opens the session and writes the group result', async t => {
111
+ const dir = await mkdtemp(join(tmpdir(), 'blong-chain-allure-'));
112
+ t.teardown(() => rm(dir, {recursive: true, force: true}));
113
+ const outputDir = join(dir, 'allure-results');
114
+ const {warnings, errors, log} = recordingLog();
115
+ const {default: chainFactory, endAllureSession} = await t.mockImport<
116
+ typeof import('./chain.ts')
117
+ >('./chain.ts', {});
118
+
119
+ const chain = await chainFactory(contextAt() as never, log, {
120
+ method: 'test.order.checkpoint',
121
+ allure: {enabled: true, outputDir},
122
+ platform: 'server',
123
+ });
124
+ await (chain as unknown as (steps: unknown) => Promise<unknown>)(
125
+ group('order checkpoint', [
126
+ async function loadsOrder() {
127
+ return 'loaded';
128
+ },
129
+ ]),
130
+ );
131
+
132
+ t.same(warnings, [], 'a server platform reports nothing and says nothing');
133
+ t.same(errors, []);
134
+ const written = await readdir(outputDir);
135
+ t.ok(
136
+ written.includes('environment.properties'),
137
+ 'the session laid out its directory, as it does for a real run',
138
+ );
139
+ const resultFile = written.find(name => name.endsWith('-result.json'));
140
+ t.ok(resultFile, 'and the group left a result');
141
+ const result = JSON.parse(await readFile(join(outputDir, resultFile as string), 'utf8')) as {
142
+ name?: string;
143
+ labels?: Array<{name: string; value: string}>;
144
+ };
145
+ t.equal(result.name, 'order checkpoint', 'named as the group was run');
146
+ t.ok(
147
+ result.labels?.some(label => label.name === 'parentSuite' && label.value === 'test'),
148
+ 'labelled from the group wire name',
149
+ );
150
+
151
+ // The end writes to stdout, which a tap run parses: its line is captured here
152
+ // rather than left in the middle of the suite's output.
153
+ const lines: string[] = [];
154
+ const original = console.log;
155
+ console.log = (...args: unknown[]) => void lines.push(args.join(' '));
156
+ try {
157
+ await endAllureSession();
158
+ } finally {
159
+ console.log = original;
160
+ }
161
+ t.match(
162
+ lines.join(''),
163
+ /Allure results written to/,
164
+ 'the run ends by saying where the results are',
165
+ );
166
+
167
+ // Nothing is left to close: the second end is a no-op rather than a second report.
168
+ await endAllureSession();
169
+ });
170
+
171
+ test('a failure to start is reported once, and leaves nothing to close', async t => {
172
+ const dir = await mkdtemp(join(tmpdir(), 'blong-chain-allure-'));
173
+ t.teardown(() => rm(dir, {recursive: true, force: true}));
174
+ const {warnings, errors, log} = recordingLog();
175
+ let started = 0;
176
+ let ended = 0;
177
+ const {default: chainFactory, endAllureSession} = await t.mockImport<
178
+ typeof import('./chain.ts')
179
+ >('./chain.ts', {
180
+ // A server platform that cannot import the integration at all — a package
181
+ // missing on the runner, say. The runner cannot prevent this one; it can only
182
+ // say it once and carry on.
183
+ '@feasibleone/blong-allure': {
184
+ allureSessionStart: async () => {
185
+ started += 1;
186
+ throw new Error('blong-allure is not installed');
187
+ },
188
+ allureSessionEnd: async () => {
189
+ ended += 1;
190
+ },
191
+ allureGroupResultWrite: async () => undefined,
192
+ },
193
+ });
194
+
195
+ const chain = await chainFactory(contextAt() as never, log, {
196
+ method: 'test.order.checkpoint',
197
+ allure: {enabled: true, outputDir: join(dir, 'allure-results')},
198
+ platform: 'server',
199
+ });
200
+ const run = chain as unknown as (steps: unknown) => Promise<unknown>;
201
+ const step = async function loadsOrder() {
202
+ return 'loaded';
203
+ };
204
+ await run(group('order checkpoint', [step]));
205
+ await run(group('payment settle', [step]));
206
+
207
+ t.equal(started, 1, 'one attempt, however many groups ask for a report');
208
+ t.equal(errors.length, 1, 'and one report of it, not one per group');
209
+ t.match(String(errors[0]), /blong-allure is not installed/, 'naming what went wrong');
210
+ t.same(warnings, [], 'an unexpected failure is not a platform refusal');
211
+
212
+ // A session that never opened is nothing to close: the end is a no-op, so a run
213
+ // that could not write results does not also try to generate a report from them.
214
+ await endAllureSession();
215
+ t.equal(ended, 0, 'the end of the run closes nothing');
216
+ });