@pikku/core 0.12.117 → 0.12.118

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,3 +1,44 @@
1
+ ## 0.12.118
2
+
3
+ ### Patch Changes
4
+
5
+ - b4a895e: A scenario step row now carries where it fell inside each actor's recording, and a scenario result now says which feature it came from by id as well as by title.
6
+
7
+ The video offset is recorded rather than estimated. Documentation built out of a run has to turn a step sentence into an exact moment — a chapter marker, or a still pulled with `ffmpeg -ss` — and the only number available until now was the scenario clock summed out of the ladder. That clock is not the video's: recording starts when the actor's browser context opens, which is somewhere after step one, and every RPC step before or between the browser ones burns scenario time while the file sits still. The two drift apart by however much of the scenario happened off camera, and a still forty seconds out is a picture of the wrong screen with nothing to say it is wrong.
8
+
9
+ So `ScenarioStepRow.video` is stamped at the moment the step runs, from the driver's own clock: `ScenarioBrowserProvider.videoStartedAt(actor)` reports when that actor's context was opened with `recordVideo`, and the runner subtracts. It is a list of `{ actor, offsetMs }` rather than one number, because a video belongs to an actor and not to the scenario — one step touching two windows falls at a different moment in each, and an offset that does not name its file cannot be seeked to. A run without video, a step with no actor, and a step that never ran all carry nothing, which is what keeps the console's existing estimate as the fallback for runs recorded before this.
10
+
11
+ `ScenarioResult.featureId` is the other half of the same problem. `feature` is a title written for people to read and rewritten whenever the wording improves, so nothing downstream could key off it; the id `addFeature` registered the feature under does not move. The runner threads it from the plan, which read it off the registry, instead of deriving it from the label. `scenarioName` already carried the registration id and keeps it.
12
+
13
+ - b4a895e: `pikku scenario guide` writes the user guide a scenario suite already contains. A feature reads as a page and a scenario as a section, and a run leaves screenshots behind with the captions their author took them under — the command joins that to the editorial prose a project checks in under `docs/` and writes markdown. It renders no HTML, ships no components, resolves no asset URLs and calls no model: an image is an ordinary relative `![caption](path)`, and whoever consumes the markdown rewrites the paths.
14
+
15
+ A page cites a feature by leaving the marker pair where the block belongs:
16
+
17
+ ```markdown
18
+ ---
19
+ title: Deployments
20
+ ---
21
+
22
+ A deployment is one tracked shipment of your app.
23
+
24
+ <!-- pikku:guide feature=deploymentsFeature -->
25
+ <!-- /pikku:guide -->
26
+
27
+ ## Does my app go down during a deploy?
28
+ ```
29
+
30
+ That one line does both halves of the job. It says _where_ the block goes, which a frontmatter list cannot express, and it is what the coverage gate counts to decide _whether_ a feature is documented at all. The mapping stays many-to-many and falls out of the union of every marker in the tree. A rebuild rewrites exactly the regions between the markers, so every sentence a human wrote around them survives.
31
+
32
+ **The steps are evidence, not content.** A generated block is the scenario's title, the description its author wrote, and the shots it filed — never a numbered Given/When/Then ladder, which is a test report and not something anybody arrives at a documentation page wanting. The sentences are still what the guide is kept honest against: `docs/.guide.lock` records a hash of each feature's step sentences and artifact ids, deliberately not of the image bytes. Restyling a UI changes every screenshot and no sentence; inserting or renaming a step changes what the prose was describing, and the page is reported stale. The lock is generated and checked in, so no hash is ever typed or merged by hand — and a tree whose lock is untracked reports every page as current forever.
33
+
34
+ Every registered feature has to be cited by some page, and a feature that is pure plumbing says so rather than being written about — `pikkuFeature({ document: false })`, threaded through the inspector and `FeatureMeta`. An uncited feature fails the command by name; `--allow-undocumented` downgrades that one failure to a report. A page citing a feature id that is not registered stays an error either way.
35
+
36
+ A guide is only written out of a run that can stand behind it. A run that failed or was killed halfway is refused, because a page is a claim that the product does what it says. So is a narrowed one: `pikku scenario run --flows`/`--features`/`--tags` leaves out scenarios the suite has, and a guide built from it would describe those flows as though they do not exist. `ScenarioRunRecord.selection` records the filters a run was selected with, since nothing in the results afterwards can tell a suite of forty from forty that were asked for.
37
+
38
+ Results are joined to features by `featureId`, falling back to the display name only for records written before that field existed — a title is rewritten freely and two features may share one.
39
+
40
+ Emission is deterministic — identical inputs give byte-identical output, and no timestamp goes in that did not come from the run record. Frontmatter the compiler does not own (`slug`, `draft`, `sidebar_position`, anything else a docs site reads) passes through untouched, and a source written with CRLF line endings is read as having frontmatter.
41
+
1
42
  ## 0.12.117
2
43
 
3
44
  ### Patch Changes
@@ -3,11 +3,12 @@ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-servi
3
3
  import type { RunLifecycleContext, WorkflowRunEngine, WorkflowRunExtension } from './workflow-run-engine.types.js';
4
4
  import type { PikkuRawWire } from '../../types/core.types.js';
5
5
  import type { ScenarioPersonas } from '../../services/personas-service.js';
6
+ import type { ScenarioStepVideoOffset } from './scenario-run.types.js';
6
7
  import type { ScenarioBrowserProvider, ScenarioEnvironment, ScenarioSurface } from './scenario-step.types.js';
7
8
  import type { PikkuWorkflowWire, WorkflowQueueOptions } from './workflow.types.js';
8
9
  export { addFeature, resolveFeatureScenarios } from './feature.js';
9
10
  export type { CoreFeature, CoreFeatureScenario, FeatureMeta, FeaturesMeta, PikkuBrowserWire, PikkuScenarioWire, ScenarioBrowserFailure, ScenarioBrowserProvider, ScenarioEnvironment, ScenarioStepKind, ScenarioStepMeta, ScenarioStepOptions, ScenarioScreenshotOptions, ScenarioStepPhase, ScenarioSurface, TestIdSelector, } from './scenario.types.js';
10
- export type { ScenarioArtifact, ScenarioFailureDetail, ScenarioResult, ScenarioRunRecord, ScenarioRunReport, ScenarioRunStatus, ScenarioRunStore, ScenarioRunSummary, ScenarioStepRow, } from './scenario-run.types.js';
11
+ export type { ScenarioArtifact, ScenarioFailureDetail, ScenarioResult, ScenarioRunRecord, ScenarioRunReport, ScenarioRunSelection, ScenarioRunStatus, ScenarioRunStore, ScenarioRunSummary, ScenarioStepRow, ScenarioStepVideoOffset, } from './scenario-run.types.js';
11
12
  export { SCENARIO_SURFACES } from './scenario-step.types.js';
12
13
  export { resolveScenarioSurfaces } from './scenario-surface.js';
13
14
  export { pollUntil, type PollOptions } from './scenario-poll.js';
@@ -161,6 +162,7 @@ export declare class PikkuScenarioService implements WorkflowRunExtension {
161
162
  private readonly engine;
162
163
  private runActors;
163
164
  private runContexts;
165
+ private runVideoOffsets;
164
166
  private scenarioBrowserProvider?;
165
167
  private scenarioEnvironment?;
166
168
  private runSurface;
@@ -193,6 +195,23 @@ export declare class PikkuScenarioService implements WorkflowRunExtension {
193
195
  */
194
196
  setScenarioEnvironment(env: ScenarioEnvironment | undefined): void;
195
197
  getScenarioEnvironment(): ScenarioEnvironment | undefined;
198
+ /**
199
+ * Where each of a run's browser steps fell in its actor's video, keyed by the
200
+ * durable step name, handed over and forgotten in one call.
201
+ *
202
+ * Taken rather than read because the runner joins these onto the step rows
203
+ * after the run has finished — which is past the point anything else would
204
+ * clear them, and the only moment they are still wanted.
205
+ */
206
+ takeStepVideoOffsets(runId: string): Map<string, ScenarioStepVideoOffset[]>;
207
+ /**
208
+ * Stamp where a step began inside one actor's recording.
209
+ *
210
+ * First write per actor wins: a `then` step runs once per witness, and the
211
+ * moment the reader wants is when the sentence started, not when its last
212
+ * witness got around to the browser.
213
+ */
214
+ private recordVideoOffset;
196
215
  attachRunContext(runId: string, workflowMeta: any, options?: {
197
216
  actors?: ScenarioPersonas;
198
217
  }): Promise<void>;
@@ -243,6 +243,10 @@ export class PikkuScenarioService {
243
243
  // so the body and its hooks share one object rather than reading it back off
244
244
  // the persisted wire.
245
245
  runContexts = new Map();
246
+ // Where each browser step landed in its actor's recording, per run. Held
247
+ // apart from the run context because the runner reads it once the run has
248
+ // ended, which is exactly when `detachRunContext` has cleared that.
249
+ runVideoOffsets = new Map();
246
250
  scenarioBrowserProvider;
247
251
  scenarioEnvironment;
248
252
  runSurface = 'default';
@@ -292,6 +296,39 @@ export class PikkuScenarioService {
292
296
  getScenarioEnvironment() {
293
297
  return this.scenarioEnvironment;
294
298
  }
299
+ /**
300
+ * Where each of a run's browser steps fell in its actor's video, keyed by the
301
+ * durable step name, handed over and forgotten in one call.
302
+ *
303
+ * Taken rather than read because the runner joins these onto the step rows
304
+ * after the run has finished — which is past the point anything else would
305
+ * clear them, and the only moment they are still wanted.
306
+ */
307
+ takeStepVideoOffsets(runId) {
308
+ const offsets = this.runVideoOffsets.get(runId);
309
+ this.runVideoOffsets.delete(runId);
310
+ return offsets ?? new Map();
311
+ }
312
+ /**
313
+ * Stamp where a step began inside one actor's recording.
314
+ *
315
+ * First write per actor wins: a `then` step runs once per witness, and the
316
+ * moment the reader wants is when the sentence started, not when its last
317
+ * witness got around to the browser.
318
+ */
319
+ recordVideoOffset(runId, stepName, actor, offsetMs) {
320
+ let byStep = this.runVideoOffsets.get(runId);
321
+ if (!byStep) {
322
+ byStep = new Map();
323
+ this.runVideoOffsets.set(runId, byStep);
324
+ }
325
+ const offsets = byStep.get(stepName) ?? [];
326
+ if (offsets.some((offset) => offset.actor === actor)) {
327
+ return;
328
+ }
329
+ offsets.push({ actor, offsetMs });
330
+ byStep.set(stepName, offsets);
331
+ }
295
332
  async attachRunContext(runId, workflowMeta, options) {
296
333
  const actors = options?.actors ??
297
334
  (workflowMeta.source === 'scenario'
@@ -641,6 +678,10 @@ export class PikkuScenarioService {
641
678
  // The dispatch guard above already refused an actor-less call, and a
642
679
  // browser binding always requires one.
643
680
  wire.browser = await this.scenarioBrowserProvider.sessionFor(actor.name);
681
+ const videoStartedAt = this.scenarioBrowserProvider.videoStartedAt?.(actor.name);
682
+ if (videoStartedAt !== undefined) {
683
+ this.recordVideoOffset(runId, stepName, actor.name, Math.max(0, Date.now() - videoStartedAt));
684
+ }
644
685
  }
645
686
  return await runPikkuFunc('workflow', workflowName, resolvedStepFunc, {
646
687
  singletonServices: getSingletonServices(),
@@ -41,6 +41,21 @@ export interface ScenarioArtifact {
41
41
  /** Fit to show outside the run: a marketing card, a docs page, a gallery. */
42
42
  showcase?: boolean;
43
43
  }
44
+ /**
45
+ * Where a step falls inside one actor's recording.
46
+ *
47
+ * Measured from the moment that actor's browser context opened — which is when
48
+ * Playwright starts the file — rather than from the start of the scenario, so
49
+ * it addresses the video's own clock. The two differ by however long the
50
+ * scenario spent before that window existed, plus every non-browser step since,
51
+ * which is why the offset is recorded at the moment the step runs instead of
52
+ * being summed back out of the ladder afterwards.
53
+ */
54
+ export interface ScenarioStepVideoOffset {
55
+ /** Whose recording this offset is into: one actor, one video file. */
56
+ actor: string;
57
+ offsetMs: number;
58
+ }
44
59
  /** One step of a run, already joined to the prose that declared it. */
45
60
  export interface ScenarioStepRow {
46
61
  sentence: string;
@@ -54,6 +69,16 @@ export interface ScenarioStepRow {
54
69
  status: string;
55
70
  durationMs?: number;
56
71
  error?: string;
72
+ /**
73
+ * Where this step lands in each actor's video, for the actors whose window
74
+ * was being recorded when it ran. Absent for a step with no actor, a run
75
+ * without video, and a step that never ran at all.
76
+ *
77
+ * A list rather than one number because a video belongs to an actor, not to
78
+ * the scenario: a step touching two windows falls at a different moment in
79
+ * each, and an offset that does not name its file cannot be seeked to.
80
+ */
81
+ video?: ScenarioStepVideoOffset[];
57
82
  }
58
83
  /** Everything known about why one scenario failed. */
59
84
  export interface ScenarioFailureDetail {
@@ -76,10 +101,22 @@ export interface ScenarioResult {
76
101
  error?: string;
77
102
  steps?: ScenarioStepRow[];
78
103
  failure?: ScenarioFailureDetail;
79
- /** The scenario registration this ran, which the label alone does not give. */
104
+ /**
105
+ * The registered scenario this ran, by the id it is registered under — the
106
+ * same id `FeatureMetaEntry.scenario` references. The label alone does not
107
+ * give it, and unlike the label it is not rewritten when the prose is.
108
+ */
80
109
  scenarioName?: string;
81
- /** The feature that grouped it, when one did. */
110
+ /** The feature that grouped it — its display name, which is freely renamed. */
82
111
  feature?: string;
112
+ /**
113
+ * The registered feature that grouped it, by id.
114
+ *
115
+ * `feature` is a title someone writes for people to read, so nothing that
116
+ * outlives a run can key off it. This is what `addFeature` registered the
117
+ * feature under, and it is what survives the title being rewritten.
118
+ */
119
+ featureId?: string;
83
120
  tags?: string[];
84
121
  /** Images and footage this scenario produced, filed under the run. */
85
122
  artifacts?: ScenarioArtifact[];
@@ -103,6 +140,21 @@ export interface ScenarioRunReport {
103
140
  hookFailures: string[];
104
141
  }
105
142
  export type ScenarioRunStatus = 'running' | 'passed' | 'failed';
143
+ /**
144
+ * The filters a run was selected with, recorded when it was narrowed at all.
145
+ *
146
+ * A narrowed run is a partial record of the suite: scenarios a feature owns can
147
+ * be missing from it, and whole features can be absent, with nothing in the
148
+ * results to say so. Anything that reads a run as evidence of what the suite
149
+ * does — rather than of what happened that afternoon — has to be able to tell
150
+ * the two apart, and it cannot be inferred from the results afterwards.
151
+ */
152
+ export interface ScenarioRunSelection {
153
+ flows?: string[];
154
+ features?: string[];
155
+ tags?: string[];
156
+ excludeTags?: string[];
157
+ }
106
158
  /**
107
159
  * A whole run, as it is stored and read back.
108
160
  *
@@ -116,6 +168,8 @@ export interface ScenarioRunRecord extends ScenarioRunReport {
116
168
  status: ScenarioRunStatus;
117
169
  /** The surface the run targeted: `default`, `browser`, … */
118
170
  surface: string;
171
+ /** Absent on a run of the whole suite; see {@link ScenarioRunSelection}. */
172
+ selection?: ScenarioRunSelection;
119
173
  startedAt: string;
120
174
  finishedAt?: string;
121
175
  }
@@ -235,6 +235,15 @@ export interface ScenarioBrowserProvider {
235
235
  * scenario's reset, long after the outcome that decides whether to keep them.
236
236
  */
237
237
  endScenario?(outcome: 'passed' | 'failed'): void;
238
+ /**
239
+ * When this actor's recording started, as epoch milliseconds.
240
+ *
241
+ * The seam that keeps the video clock out of `@pikku/core`: a driver knows
242
+ * when it opened the context it passed `recordVideo` to, and the runner turns
243
+ * that into a per-step offset. Absent for an actor with no window open, and
244
+ * for a run recording nothing — both of which leave the step's offset off.
245
+ */
246
+ videoStartedAt?(actorName: string): number | undefined;
238
247
  /**
239
248
  * Snapshot every open window for a failed scenario. `label` identifies the
240
249
  * scenario in artifact filenames. Never throws: a failure to capture must
@@ -9,6 +9,14 @@ export type CoreFeature = {
9
9
  name: string;
10
10
  description?: string;
11
11
  tags?: string[];
12
+ /**
13
+ * Whether this feature is guide material. Defaults to true: a feature is a
14
+ * page of the user guide unless it says otherwise, so a feature nobody has
15
+ * written about is a gap `pikku scenario guide` reports rather than a page
16
+ * silently missing. Pure plumbing — a wire, a validation layer, a bearer
17
+ * auth handshake — sets it false and stops being a coverage problem.
18
+ */
19
+ document?: boolean;
12
20
  scenarios: readonly CoreFeatureScenario[];
13
21
  before?: CorePikkuFunctionHook;
14
22
  after?: CorePikkuFunctionHook;
@@ -22,6 +30,8 @@ export type FeatureMeta = {
22
30
  name: string;
23
31
  description?: string;
24
32
  tags: string[];
33
+ /** Present only when the feature opted out; absent means documented. */
34
+ document?: boolean;
25
35
  entries: FeatureMetaEntry[];
26
36
  unresolvedEntries: number;
27
37
  hasBefore: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.117",
3
+ "version": "0.12.118",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",