@pikku/core 0.12.52 → 0.12.53

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,18 @@
1
+ ## 0.12.53
2
+
3
+ ### Patch Changes
4
+
5
+ - efb0406: Add in-process V8 precise coverage (`pikku dev --coverage` / `pikku serve --coverage`) with per-scenario attribution.
6
+ - `@pikku/core`: new `V8CoverageService` (node:inspector precise coverage with snapshot + reset), exposed as the optional `coverageService` singleton service.
7
+ - `@pikku/inspector`: function meta now records `bodyStart`/`bodyEnd` body spans (verbose meta only) so coverage can be mapped without a runtime TypeScript dependency.
8
+ - `@pikku/cli`: `--coverage` on `pikku dev` and `pikku serve` starts the collector in-process; `pikku scenario run --coverage` resets/snapshots the server between flows and writes `.pikku/coverage/scenario-coverage.json` with per-scenario function coverage.
9
+ - `@pikku/addon-console`: new exposed `takeLiveCoverage` / `resetLiveCoverage` RPCs; V8 ranges are mapped through inline source maps to original TypeScript lines (offset-based, so esbuild/tsx single-line output keeps full resolution).
10
+
11
+ - fe4f5ca: Add `stub`/`spy`/`isTestRun` core utils with call recording for scenario assertions.
12
+ - `@pikku/core`: `StubTracker` moves here from `@pikku/cucumber` (which re-exports it), gaining `record`/`getCalls`/`reset`. New plain-import utils backed by a process-wide tracker: `stub(name, impl?)` (recording fake), `spy(name, real)` (record + pass through), `isTestRun()` (reads `PIKKU_TEST_RUN`). Nothing is injected into service factories and no new factory types exist — swap services with a plain `isTestRun()` conditional where needed. New scenario DSL steps: `workflow.expectService('email.send', { calledWith })` asserts recorded stub calls via the console RPC, `workflow.expectError(...)` walks error branches.
13
+ - `@pikku/cli`: `pikku dev --test` sets `PIKKU_TEST_RUN` and wraps the dev-provided default services (email) in recording spies; independent of `--coverage`, absent from production `pikku serve`. `pikku scenario run` resets recorded calls per flow.
14
+ - `@pikku/addon-console`: exposed `getStubCalls` / `resetStubs` RPCs next to the coverage snapshot endpoints.
15
+
1
16
  ## 0.12.52
2
17
 
3
18
  ### Patch Changes
@@ -45,3 +45,6 @@ export { NoopAuditService, createInvocationAudit, resolveAuditActorFromWire, res
45
45
  export type { AuditActor, AuditConfig, AuditDurability, AuditEvent, AuditEventBatch, AuditLog, AuditLogWriteInput, AuditOutcome, AuditService, AuditSource, ResolvedAuditConfig, } from './audit-service.js';
46
46
  export { InMemorySessionStore } from './in-memory-session-store.js';
47
47
  export type { MCPMeta, RPCMetaRecord, ServiceMeta, ServicesMetaRecord, MiddlewareDefinitionMeta, MiddlewareInstanceMeta, GroupMeta, MiddlewareGroupsMeta, PermissionDefinitionMeta, PermissionsGroupsMeta, FunctionsMeta, FunctionMeta, MiddlewareMeta, PermissionMeta, AgentsMeta, AgentMeta, EmailsMeta, EmailTemplateMeta, EmailTemplateLocaleMeta, EmailTemplateAssets, } from './meta-service.js';
48
+ export { V8CoverageService, type CoverageService, type CoverageSnapshot, type LineHits, type ScriptCoverage, type FunctionCoverage, type CoverageRange, } from './v8-coverage-service.js';
49
+ export { IstanbulCoverageService } from './istanbul-coverage-service.js';
50
+ export { StubTracker, createStubProxy, getStubTracker, isTestRun, stub, spy, type StubCall, } from './stub-tracker.js';
@@ -21,3 +21,6 @@ export { HttpScenarioActor, createHttpScenarioActors, } from './http-scenario-ac
21
21
  export { TypedCredentialService } from './typed-credential-service.js';
22
22
  export { NoopAuditService, createInvocationAudit, resolveAuditActorFromWire, resolveAuditConfig, } from './audit-service.js';
23
23
  export { InMemorySessionStore } from './in-memory-session-store.js';
24
+ export { V8CoverageService, } from './v8-coverage-service.js';
25
+ export { IstanbulCoverageService } from './istanbul-coverage-service.js';
26
+ export { StubTracker, createStubProxy, getStubTracker, isTestRun, stub, spy, } from './stub-tracker.js';
@@ -0,0 +1,12 @@
1
+ import type { CoverageService, CoverageSnapshot } from './v8-coverage-service.js';
2
+ /**
3
+ * Reads istanbul-instrumented counters from the `__coverage__` global —
4
+ * the coverage backend for runtimes without V8 precise coverage (e.g. Bun,
5
+ * where source files are instrumented at load time by a Bun loader plugin).
6
+ */
7
+ export declare class IstanbulCoverageService implements CoverageService {
8
+ start(): Promise<void>;
9
+ takeCoverage(): Promise<CoverageSnapshot>;
10
+ reset(): Promise<void>;
11
+ stop(): Promise<void>;
12
+ }
@@ -0,0 +1,41 @@
1
+ const getCoverageGlobal = () => globalThis.__coverage__;
2
+ /**
3
+ * Reads istanbul-instrumented counters from the `__coverage__` global —
4
+ * the coverage backend for runtimes without V8 precise coverage (e.g. Bun,
5
+ * where source files are instrumented at load time by a Bun loader plugin).
6
+ */
7
+ export class IstanbulCoverageService {
8
+ async start() { }
9
+ async takeCoverage() {
10
+ const coverage = getCoverageGlobal();
11
+ const lineHits = new Map();
12
+ for (const file of Object.values(coverage ?? {})) {
13
+ let hits = lineHits.get(file.path);
14
+ if (!hits) {
15
+ hits = new Map();
16
+ lineHits.set(file.path, hits);
17
+ }
18
+ // istanbul line semantics: a statement's count belongs to its start
19
+ // line only, so an enclosing multi-line statement never masks an
20
+ // unexecuted inner statement (e.g. a throw inside a taken if).
21
+ for (const [id, location] of Object.entries(file.statementMap)) {
22
+ const count = file.s[id] ?? 0;
23
+ const line = location.start.line;
24
+ hits.set(line, Math.max(hits.get(line) ?? 0, count));
25
+ }
26
+ }
27
+ return { kind: 'line-hits', lineHits };
28
+ }
29
+ async reset() {
30
+ for (const file of Object.values(getCoverageGlobal() ?? {})) {
31
+ for (const id of Object.keys(file.s))
32
+ file.s[id] = 0;
33
+ for (const id of Object.keys(file.f))
34
+ file.f[id] = 0;
35
+ for (const id of Object.keys(file.b)) {
36
+ file.b[id] = file.b[id].map(() => 0);
37
+ }
38
+ }
39
+ }
40
+ async stop() { }
41
+ }
@@ -1,12 +1,5 @@
1
1
  import type { ConverseOptions, ActorFlowVerdict } from '../wirings/actor-flow/actor-flow.types.js';
2
- /**
3
- * A scenario actor: a synthetic user (a normal user row flagged `actor`) that
4
- * workflow steps can run as. Passed to `workflow.do(step, rpc, data, { actor })`
5
- * — the step then goes through the actor's authenticated client over the REAL
6
- * transport (auth middleware, permissions, serialization all exercised),
7
- * never through internal dispatch. Login is lazy: the first `invoke` signs the
8
- * actor in and the session is cached for the actor's lifetime.
9
- */
2
+ /** A synthetic user (a user row flagged `actor`) that workflow steps run as over the real transport */
10
3
  export interface ScenarioActor<TAgentName extends string = string> {
11
4
  /** Stable actor name (the key in pikku.config.json's actor registry). */
12
5
  readonly name: string;
@@ -14,21 +7,10 @@ export interface ScenarioActor<TAgentName extends string = string> {
14
7
  readonly email: string;
15
8
  /** Invoke an exposed RPC as this actor over the real transport. */
16
9
  invoke(rpcName: string, data: unknown): Promise<unknown>;
17
- /**
18
- * Hold a dynamic conversation with a target Pikku AI agent, in THIS actor's
19
- * persona (personality/jobTitle). Drives the target over the real transport
20
- * as the signed-in actor, answers its tool-approval requests in-persona, and
21
- * returns the actor's verdict on whether the task was met. Deterministic
22
- * checks are the caller's job — use `invoke` afterwards. In a typed project
23
- * `agent` is constrained to the generated union of agent names.
24
- */
10
+ /** Converse with a Pikku AI agent in this actor's persona and return its verdict */
25
11
  converse(options: ConverseOptions<TAgentName>): Promise<ActorFlowVerdict>;
26
12
  }
27
- /**
28
- * Display/config metadata for an actor (from pikku.config.json). The email
29
- * identifies the actor's user row; personality/jobTitle exist for the console
30
- * screen and for agent-driven flows (the agent plays the persona).
31
- */
13
+ /** Display/config metadata for an actor (from pikku.config.json) */
32
14
  export interface ScenarioActorConfig {
33
15
  email: string;
34
16
  name?: string;
@@ -0,0 +1,43 @@
1
+ export type StubCall = {
2
+ service: string;
3
+ method: string;
4
+ args: unknown[];
5
+ };
6
+ /**
7
+ * Tracks calls made to stubbed services and enforces strict mode:
8
+ * once a scenario asserts any call on a service, every recorded call on that
9
+ * service must be verified by end of scenario — otherwise the After hook fails.
10
+ * Services never touched by an assertion stay lenient.
11
+ */
12
+ export declare class StubTracker {
13
+ private readonly calls;
14
+ private readonly touched;
15
+ record(service: string, method: string, args: unknown[]): void;
16
+ getCalls(service?: string): StubCall[];
17
+ reset(): void;
18
+ stub<T>(service: string): T;
19
+ assert(service: string, method: string): void;
20
+ assertCall(service: string, method: string, predicate: (args: unknown[]) => boolean, description: string): void;
21
+ assertNoCalls(service: string, method?: string, predicate?: (args: unknown[]) => boolean, description?: string): void;
22
+ verify(): void;
23
+ }
24
+ /**
25
+ * Creates a Proxy suitable for passing as `existingServices` to
26
+ * `createSingletonServices`. Every property returns `tracker.stub(prop)`
27
+ * EXCEPT `schema`, which returns `undefined` so the service factory creates
28
+ * a real schema service. Stubbing the schema makes validation a no-op —
29
+ * required fields pass silently and tests validate nothing.
30
+ */
31
+ export declare function createStubProxy(tracker: StubTracker): Record<string, unknown>;
32
+ /** The process-wide tracker that `stub()`/`spy()` record into — read by the console's getStubCalls/resetStubs RPCs */
33
+ export declare const getStubTracker: () => StubTracker;
34
+ /** True when the server was started by `pikku dev --test` (sets PIKKU_TEST_RUN) */
35
+ export declare const isTestRun: () => boolean;
36
+ /**
37
+ * Creates a recording fake for a service. Methods present on `impl` run and
38
+ * their result is returned; missing methods resolve `undefined`. Every call
39
+ * is recorded so scenarios can assert via workflow.expectService().
40
+ */
41
+ export declare function stub<T = any>(service: string, impl?: Partial<T>): T;
42
+ /** Wraps a real service so every method call is recorded and passed through */
43
+ export declare function spy<T extends object>(service: string, real: T): T;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Tracks calls made to stubbed services and enforces strict mode:
3
+ * once a scenario asserts any call on a service, every recorded call on that
4
+ * service must be verified by end of scenario — otherwise the After hook fails.
5
+ * Services never touched by an assertion stay lenient.
6
+ */
7
+ export class StubTracker {
8
+ calls = new Map();
9
+ touched = new Set();
10
+ record(service, method, args) {
11
+ const list = this.calls.get(service) ?? [];
12
+ list.push({ method, args, verified: false });
13
+ this.calls.set(service, list);
14
+ }
15
+ getCalls(service) {
16
+ const result = [];
17
+ for (const [name, list] of this.calls) {
18
+ if (service && name !== service)
19
+ continue;
20
+ for (const call of list) {
21
+ result.push({ service: name, method: call.method, args: call.args });
22
+ }
23
+ }
24
+ return result;
25
+ }
26
+ reset() {
27
+ this.calls.clear();
28
+ this.touched.clear();
29
+ }
30
+ stub(service) {
31
+ const self = this;
32
+ return new Proxy(Object.create(null), {
33
+ get(_, method) {
34
+ return (...args) => {
35
+ self.record(service, method, args);
36
+ return Promise.resolve();
37
+ };
38
+ },
39
+ });
40
+ }
41
+ assert(service, method) {
42
+ this.touched.add(service);
43
+ const list = this.calls.get(service) ?? [];
44
+ const idx = list.findIndex((c) => c.method === method && !c.verified);
45
+ if (idx === -1) {
46
+ const seen = list.map((c) => c.method).join(', ') || '(none)';
47
+ throw new Error(`Expected "${service}.${method}" to have been called. Recorded: ${seen}`);
48
+ }
49
+ list[idx].verified = true;
50
+ }
51
+ assertCall(service, method, predicate, description) {
52
+ this.touched.add(service);
53
+ const list = this.calls.get(service) ?? [];
54
+ const idx = list.findIndex((c) => c.method === method && !c.verified && predicate(c.args));
55
+ if (idx === -1) {
56
+ const seen = list
57
+ .filter((c) => c.method === method)
58
+ .map((c) => JSON.stringify(c.args[0]))
59
+ .join('\n ') || '(none)';
60
+ throw new Error(`Expected ${description} but found:\n ${seen}`);
61
+ }
62
+ list[idx].verified = true;
63
+ }
64
+ assertNoCalls(service, method, predicate, description) {
65
+ this.touched.add(service);
66
+ const list = this.calls.get(service) ?? [];
67
+ const relevant = (method ? list.filter((c) => c.method === method) : list).filter((c) => !predicate || predicate(c.args));
68
+ if (relevant.length > 0) {
69
+ const calls = relevant
70
+ .map((c) => `${c.method}(${c.args.map((a) => JSON.stringify(a)).join(', ')})`)
71
+ .join('\n ');
72
+ const what = description ?? `"${service}${method ? '.' + method : ''}"`;
73
+ throw new Error(`Expected no ${what} calls but got:\n ${calls}`);
74
+ }
75
+ }
76
+ verify() {
77
+ const errors = [];
78
+ for (const service of this.touched) {
79
+ const unverified = (this.calls.get(service) ?? []).filter((c) => !c.verified);
80
+ for (const c of unverified) {
81
+ const argStr = c.args.map((a) => JSON.stringify(a)).join(', ');
82
+ errors.push(` ${service}.${c.method}(${argStr})`);
83
+ }
84
+ }
85
+ if (errors.length) {
86
+ throw new Error(`Unexpected stub calls — assert them in the scenario or remove the side effect:\n${errors.join('\n')}`);
87
+ }
88
+ }
89
+ }
90
+ /**
91
+ * Creates a Proxy suitable for passing as `existingServices` to
92
+ * `createSingletonServices`. Every property returns `tracker.stub(prop)`
93
+ * EXCEPT `schema`, which returns `undefined` so the service factory creates
94
+ * a real schema service. Stubbing the schema makes validation a no-op —
95
+ * required fields pass silently and tests validate nothing.
96
+ */
97
+ export function createStubProxy(tracker) {
98
+ return new Proxy({}, {
99
+ get(_, prop) {
100
+ if (prop === 'schema')
101
+ return undefined;
102
+ return tracker.stub(prop);
103
+ },
104
+ });
105
+ }
106
+ const defaultStubTracker = new StubTracker();
107
+ /** The process-wide tracker that `stub()`/`spy()` record into — read by the console's getStubCalls/resetStubs RPCs */
108
+ export const getStubTracker = () => defaultStubTracker;
109
+ /** True when the server was started by `pikku dev --test` (sets PIKKU_TEST_RUN) */
110
+ export const isTestRun = () => globalThis
111
+ .process?.env?.PIKKU_TEST_RUN === 'true';
112
+ /**
113
+ * Creates a recording fake for a service. Methods present on `impl` run and
114
+ * their result is returned; missing methods resolve `undefined`. Every call
115
+ * is recorded so scenarios can assert via workflow.expectService().
116
+ */
117
+ export function stub(service, impl) {
118
+ return new Proxy((impl ?? {}), {
119
+ get(target, method) {
120
+ if (typeof method === 'symbol')
121
+ return target[method];
122
+ const real = target[method];
123
+ if (typeof real !== 'function' && real !== undefined)
124
+ return real;
125
+ return (...args) => {
126
+ defaultStubTracker.record(service, method, args);
127
+ return real ? real.apply(target, args) : Promise.resolve(undefined);
128
+ };
129
+ },
130
+ });
131
+ }
132
+ /** Wraps a real service so every method call is recorded and passed through */
133
+ export function spy(service, real) {
134
+ return new Proxy(real, {
135
+ get(target, method) {
136
+ const value = target[method];
137
+ if (typeof method === 'symbol' || typeof value !== 'function') {
138
+ return value;
139
+ }
140
+ return (...args) => {
141
+ defaultStubTracker.record(service, method, args);
142
+ return value.apply(target, args);
143
+ };
144
+ },
145
+ });
146
+ }
@@ -0,0 +1,41 @@
1
+ export interface CoverageRange {
2
+ startOffset: number;
3
+ endOffset: number;
4
+ count: number;
5
+ }
6
+ export interface FunctionCoverage {
7
+ functionName: string;
8
+ isBlockCoverage: boolean;
9
+ ranges: CoverageRange[];
10
+ }
11
+ export interface ScriptCoverage {
12
+ scriptId: string;
13
+ url: string;
14
+ functions: FunctionCoverage[];
15
+ }
16
+ export type LineHits = Map<string, Map<number, number>>;
17
+ export type CoverageSnapshot = {
18
+ kind: 'v8-scripts';
19
+ scripts: ScriptCoverage[];
20
+ getScriptSource: (scriptId: string) => Promise<string>;
21
+ } | {
22
+ kind: 'line-hits';
23
+ lineHits: LineHits;
24
+ };
25
+ export interface CoverageService {
26
+ start(): Promise<void>;
27
+ takeCoverage(): Promise<CoverageSnapshot>;
28
+ reset(): Promise<void>;
29
+ stop(): Promise<void>;
30
+ }
31
+ export declare class V8CoverageService implements CoverageService {
32
+ private session;
33
+ private startPromise;
34
+ private post;
35
+ start(): Promise<void>;
36
+ private doStart;
37
+ takeCoverage(): Promise<CoverageSnapshot>;
38
+ getScriptSource(scriptId: string): Promise<string>;
39
+ reset(): Promise<void>;
40
+ stop(): Promise<void>;
41
+ }
@@ -0,0 +1,63 @@
1
+ // node:inspector is imported lazily inside start() so this module stays
2
+ // loadable on runtimes without it (e.g. Cloudflare Workers).
3
+ export class V8CoverageService {
4
+ session = null;
5
+ startPromise = null;
6
+ post(method, params) {
7
+ const session = this.session;
8
+ if (!session) {
9
+ throw new Error('V8CoverageService not started — call start() first');
10
+ }
11
+ return new Promise((resolve, reject) => {
12
+ session.post(method, params, (err, result) => err ? reject(err) : resolve(result));
13
+ });
14
+ }
15
+ start() {
16
+ this.startPromise ??= this.doStart();
17
+ return this.startPromise;
18
+ }
19
+ async doStart() {
20
+ const inspector = await import('node:inspector');
21
+ this.session = new inspector.Session();
22
+ this.session.connect();
23
+ await this.post('Profiler.enable');
24
+ await this.post('Debugger.enable');
25
+ await this.post('Profiler.startPreciseCoverage', {
26
+ callCount: true,
27
+ detailed: true,
28
+ });
29
+ }
30
+ async takeCoverage() {
31
+ const { result } = await this.post('Profiler.takePreciseCoverage');
32
+ return {
33
+ kind: 'v8-scripts',
34
+ scripts: result.filter((script) => script.url.startsWith('file://')),
35
+ getScriptSource: (scriptId) => this.getScriptSource(scriptId),
36
+ };
37
+ }
38
+ async getScriptSource(scriptId) {
39
+ const { scriptSource } = await this.post('Debugger.getScriptSource', { scriptId });
40
+ return scriptSource;
41
+ }
42
+ async reset() {
43
+ await this.post('Profiler.stopPreciseCoverage');
44
+ await this.post('Profiler.startPreciseCoverage', {
45
+ callCount: true,
46
+ detailed: true,
47
+ });
48
+ }
49
+ async stop() {
50
+ if (!this.session)
51
+ return;
52
+ try {
53
+ await this.post('Profiler.stopPreciseCoverage');
54
+ await this.post('Profiler.disable');
55
+ await this.post('Debugger.disable');
56
+ }
57
+ finally {
58
+ this.session.disconnect();
59
+ this.session = null;
60
+ this.startPromise = null;
61
+ }
62
+ }
63
+ }
@@ -28,6 +28,7 @@ import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js';
28
28
  import type { CredentialService } from '../services/credential-service.js';
29
29
  import type { EmailService } from '../services/email-service.js';
30
30
  import type { MetaService } from '../services/meta-service.js';
31
+ import type { CoverageService } from '../services/v8-coverage-service.js';
31
32
  import type { SessionStore } from '../services/session-store.js';
32
33
  import type { AuditDurability, AuditLog, AuditService } from '../services/audit-service.js';
33
34
  export type PikkuWiringTypes = 'http' | 'scheduler' | 'trigger' | 'channel' | 'rpc' | 'queue' | 'mcp' | 'cli' | 'workflow' | 'agent' | 'gateway';
@@ -110,6 +111,12 @@ export type FunctionMeta = FunctionRuntimeMeta & Partial<{
110
111
  isDirectFunction: boolean;
111
112
  sourceFile: string;
112
113
  exportedName: string;
114
+ /** File containing the handler body when it differs from sourceFile (imported handlers) */
115
+ bodySourceFile?: string;
116
+ /** 1-indexed first line of the handler body (verbose meta; coverage mapping) */
117
+ bodyStart: number;
118
+ /** 1-indexed last line of the handler body (verbose meta; coverage mapping) */
119
+ bodyEnd: number;
113
120
  } & CommonWireMeta>;
114
121
  export type FunctionsRuntimeMeta = Record<string, FunctionRuntimeMeta>;
115
122
  export type FunctionsMeta = Record<string, FunctionMeta>;
@@ -233,6 +240,8 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
233
240
  emailService?: EmailService;
234
241
  /** Meta service for reading .pikku metadata files (filesystem on Node, R2/KV on CF) */
235
242
  metaService?: MetaService;
243
+ /** V8 precise-coverage collector (`pikku dev --coverage` only) */
244
+ coverageService?: CoverageService;
236
245
  /** Audit service for durable or staged audit event capture */
237
246
  audit?: AuditService;
238
247
  /**
@@ -32,6 +32,18 @@ export interface WorkflowExpectEventuallyOptions extends WorkflowStepOptions {
32
32
  /** Poll interval (e.g. '1s'). Default '1s'. */
33
33
  interval?: string | number;
34
34
  }
35
+ /** Options for workflow.expectError() */
36
+ export interface WorkflowExpectErrorOptions extends WorkflowStepOptions {
37
+ /** Assert the error message matches (string = substring match). */
38
+ matches?: string | RegExp;
39
+ }
40
+ /** Options for workflow.expectService() */
41
+ export interface WorkflowExpectServiceOptions extends WorkflowStepOptions {
42
+ /** Assert a recorded call's first argument deep-equals this value. */
43
+ calledWith?: unknown;
44
+ /** Assert the exact number of matching calls. Default: at least one. */
45
+ times?: number;
46
+ }
35
47
  /**
36
48
  * Type signature for workflow.do() RPC form - used by inspector
37
49
  */
@@ -344,6 +356,10 @@ export interface PikkuWorkflowWire {
344
356
  * `options.as` is set) until `predicate` passes or `options.within` elapses.
345
357
  */
346
358
  expectEventually: <TOutput = any, TInput = any>(stepName: string, rpcName: string, data: TInput, predicate: (output: TOutput) => boolean, options?: WorkflowExpectEventuallyOptions) => Promise<TOutput>;
359
+ /** Error-path step (scenarios): succeeds only when the RPC throws; returns the message */
360
+ expectError: <TInput = any>(stepName: string, rpcName: string, data: TInput, options?: WorkflowExpectErrorOptions) => Promise<string>;
361
+ /** Stub-assertion step (scenarios): asserts `service.method` was called on the target server */
362
+ expectService: (stepName: string, serviceMethod: string, options?: WorkflowExpectServiceOptions) => Promise<void>;
347
363
  /** Sleep for a duration */
348
364
  sleep: WorkflowWireSleep;
349
365
  /** Suspend workflow until explicitly resumed */
@@ -368,12 +368,6 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
368
368
  protected scheduleSleep(runId: string, stepId: string, duration: number | string): Promise<boolean>;
369
369
  /** Build HTTP scenario actors for a run started without them; undefined when SCENARIO_ACTOR_SECRET or the API URL is missing */
370
370
  private resolveScenarioActors;
371
- /**
372
- * Start a new workflow run
373
- * Automatically detects workflow type (DSL or graph) from meta and executes accordingly
374
- * @param options.inline - If true, execute workflow directly without queue service
375
- * @param options.startNode - Starting node ID for graph workflows (from wire config)
376
- */
377
371
  /**
378
372
  * Start a new workflow run
379
373
  * Automatically detects workflow type (DSL or graph) from meta and executes accordingly
@@ -631,12 +631,6 @@ export class PikkuWorkflowService {
631
631
  rpcPath,
632
632
  });
633
633
  }
634
- /**
635
- * Start a new workflow run
636
- * Automatically detects workflow type (DSL or graph) from meta and executes accordingly
637
- * @param options.inline - If true, execute workflow directly without queue service
638
- * @param options.startNode - Starting node ID for graph workflows (from wire config)
639
- */
640
634
  /**
641
635
  * Start a new workflow run
642
636
  * Automatically detects workflow type (DSL or graph) from meta and executes accordingly
@@ -1427,6 +1421,62 @@ export class PikkuWorkflowService {
1427
1421
  }
1428
1422
  }, options);
1429
1423
  },
1424
+ expectError: async (stepName, rpcName, data, options) => {
1425
+ this.verifyStepName(stepName);
1426
+ const resolvedRpcName = addonNamespace && !rpcName.includes(':')
1427
+ ? `${addonNamespace}:${rpcName}`
1428
+ : rpcName;
1429
+ return await this.inlineStep(runId, stepName, async () => {
1430
+ let result;
1431
+ try {
1432
+ result = options?.actor
1433
+ ? await options.actor.invoke(resolvedRpcName, data)
1434
+ : await rpcService.rpcWithWire(resolvedRpcName, data, {});
1435
+ }
1436
+ catch (e) {
1437
+ const message = e?.message ?? String(e);
1438
+ if (options?.matches) {
1439
+ const matched = typeof options.matches === 'string'
1440
+ ? message.includes(options.matches)
1441
+ : options.matches.test(message);
1442
+ if (!matched) {
1443
+ throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`);
1444
+ }
1445
+ }
1446
+ return message;
1447
+ }
1448
+ throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`);
1449
+ }, options);
1450
+ },
1451
+ expectService: async (stepName, serviceMethod, options) => {
1452
+ this.verifyStepName(stepName);
1453
+ const [service, method] = serviceMethod.split('.');
1454
+ if (!service || !method) {
1455
+ throw new Error(`[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`);
1456
+ }
1457
+ await this.inlineStep(runId, stepName, async () => {
1458
+ const rpcName = 'console:getStubCalls';
1459
+ const calls = options?.actor
1460
+ ? await options.actor.invoke(rpcName, { service })
1461
+ : await rpcService.rpcWithWire(rpcName, { service }, {});
1462
+ const matching = (calls ?? []).filter((c) => c.service === service &&
1463
+ c.method === method &&
1464
+ (options?.calledWith === undefined ||
1465
+ JSON.stringify(c.args?.[0]) ===
1466
+ JSON.stringify(options.calledWith)));
1467
+ const expected = options?.times;
1468
+ const ok = expected === undefined
1469
+ ? matching.length > 0
1470
+ : matching.length === expected;
1471
+ if (!ok) {
1472
+ const seen = (calls ?? [])
1473
+ .map((c) => `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`)
1474
+ .join('\n ') || '(none)';
1475
+ throw new Error(`[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
1476
+ `${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`);
1477
+ }
1478
+ }, options);
1479
+ },
1430
1480
  // Implement workflow.sleep()
1431
1481
  sleep: async (stepName, duration) => {
1432
1482
  this.verifyStepName(stepName);
@@ -1,7 +1,7 @@
1
1
  import type { SerializedError, CommonWireMeta } from '../../types/core.types.js';
2
2
  import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
3
3
  export type { WorkflowService } from '../../services/workflow-service.js';
4
- export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './dsl/workflow-dsl.types.js';
4
+ export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowExpectErrorOptions, WorkflowExpectServiceOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './dsl/workflow-dsl.types.js';
5
5
  import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
6
6
  export interface WorkflowRunWire {
7
7
  type: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.52",
3
+ "version": "0.12.53",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -154,3 +154,22 @@ export type {
154
154
  EmailTemplateLocaleMeta,
155
155
  EmailTemplateAssets,
156
156
  } from './meta-service.js'
157
+ export {
158
+ V8CoverageService,
159
+ type CoverageService,
160
+ type CoverageSnapshot,
161
+ type LineHits,
162
+ type ScriptCoverage,
163
+ type FunctionCoverage,
164
+ type CoverageRange,
165
+ } from './v8-coverage-service.js'
166
+ export { IstanbulCoverageService } from './istanbul-coverage-service.js'
167
+ export {
168
+ StubTracker,
169
+ createStubProxy,
170
+ getStubTracker,
171
+ isTestRun,
172
+ stub,
173
+ spy,
174
+ type StubCall,
175
+ } from './stub-tracker.js'