@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/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(steps.name, runSteps(steps, log) as (t: unknown) => void | Promise<void>);
331
+ context.test(
332
+ steps.name,
333
+ runSteps(steps, log, options) as (t: unknown) => void | Promise<void>,
334
+ );
99
335
  };
@@ -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 {IMeta} from '@feasibleone/blong/types';
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.decisions,
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
- t.equal(debug.checkpoints?.length, 1, 'recorded where points are');
60
- t.equal(debug.checkpoints?.[0]?.name, 'total-calculated');
61
- t.same(debug.checkpoints?.[0]?.data, {total: 200});
62
- t.type(debug.checkpoints?.[0]?.timestamp, 'number', 'and stamped when it happened');
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.decisions?.[0]?.chosen, 'none', 'and it is recorded as `none` rather than lost');
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.decisions,
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 {CheckpointFn, DecideFn, IMeta} from '@feasibleone/blong/types';
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
- * Records a checkpoint in the $meta.checkpoints array, and announces it as a progress
6
- * point (PRD R26).
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
- (this.checkpoints ??= []).push({
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 the rationale on the invocation that took it (PRD R11/R26).
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 throw propagates before the push, because a decision that never
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
- const result = vocabulary.decide<T>(discriminator, values, wrapped);
52
- (this.decisions ??= []).push({
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: chosen ?? 'none',
92
+ chosen: '',
56
93
  values,
57
- });
58
- return result;
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
- checkpoints?: unknown[];
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.checkpoints?.length) {
58
- ($meta.checkpoints ??= []).push(...body.checkpoints);
57
+ if ($meta && body.progress?.length) {
58
+ ($meta.progress ??= []).push(...(body.progress as IProgressEntry[]));
59
59
  }
60
60
  return body.result;
61
61
  } else {