@pikku/core 0.12.52 → 0.12.54

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.
@@ -0,0 +1,165 @@
1
+ // node:inspector is imported lazily inside start() so this module stays
2
+ // loadable on runtimes without it (e.g. Cloudflare Workers).
3
+
4
+ export interface CoverageRange {
5
+ startOffset: number
6
+ endOffset: number
7
+ count: number
8
+ }
9
+
10
+ export interface FunctionCoverage {
11
+ functionName: string
12
+ isBlockCoverage: boolean
13
+ ranges: CoverageRange[]
14
+ }
15
+
16
+ export interface ScriptCoverage {
17
+ scriptId: string
18
+ url: string
19
+ functions: FunctionCoverage[]
20
+ }
21
+
22
+ export type LineHits = Map<string, Map<number, number>>
23
+
24
+ export type CoverageSnapshot =
25
+ | {
26
+ kind: 'v8-scripts'
27
+ scripts: ScriptCoverage[]
28
+ getScriptSource: (scriptId: string) => Promise<string>
29
+ }
30
+ | { kind: 'line-hits'; lineHits: LineHits }
31
+
32
+ export type CoverageStatus = 'covered' | 'partial' | 'uncovered' | 'unknown'
33
+
34
+ export interface FunctionCoverageEntry {
35
+ name: string
36
+ sourceFile: string
37
+ exposed: boolean
38
+ description: string | null
39
+ coveredLines: number
40
+ totalLines: number
41
+ missedLines: number[]
42
+ ratio: number
43
+ status: CoverageStatus
44
+ }
45
+
46
+ export interface FunctionCoverageReport {
47
+ generatedAt: string
48
+ summary: {
49
+ total: number
50
+ covered: number
51
+ partial: number
52
+ uncovered: number
53
+ unknown: number
54
+ overallRatio: number
55
+ }
56
+ functions: FunctionCoverageEntry[]
57
+ }
58
+
59
+ export interface CoverageFunctionMeta {
60
+ name: string
61
+ sourceFile: string
62
+ bodySourceFile?: string
63
+ exportedName?: string
64
+ expose?: boolean
65
+ description?: string | null
66
+ bodyStart?: number
67
+ bodyEnd?: number
68
+ }
69
+
70
+ export interface CoverageService {
71
+ start(): Promise<void>
72
+ takeCoverage(): Promise<CoverageSnapshot>
73
+ /** Maps the current snapshot onto function body spans. Provided by the
74
+ * process that booted the coverage backend (the pikku CLI) — absent when
75
+ * the runtime only exposes raw snapshots. */
76
+ takeReport?(
77
+ functionsMeta: Record<string, CoverageFunctionMeta>
78
+ ): Promise<FunctionCoverageReport>
79
+ reset(): Promise<void>
80
+ stop(): Promise<void>
81
+ }
82
+
83
+ type InspectorSession = {
84
+ connect(): void
85
+ disconnect(): void
86
+ post(
87
+ method: string,
88
+ params?: unknown,
89
+ callback?: (err: Error | null, result?: any) => void
90
+ ): void
91
+ }
92
+
93
+ export class V8CoverageService implements CoverageService {
94
+ private session: InspectorSession | null = null
95
+ private startPromise: Promise<void> | null = null
96
+
97
+ private post<T>(method: string, params?: unknown): Promise<T> {
98
+ const session = this.session
99
+ if (!session) {
100
+ throw new Error('V8CoverageService not started — call start() first')
101
+ }
102
+ return new Promise<T>((resolve, reject) => {
103
+ session.post(method, params, (err, result) =>
104
+ err ? reject(err) : resolve(result as T)
105
+ )
106
+ })
107
+ }
108
+
109
+ start(): Promise<void> {
110
+ this.startPromise ??= this.doStart()
111
+ return this.startPromise
112
+ }
113
+
114
+ private async doStart(): Promise<void> {
115
+ const inspector = await import('node:inspector')
116
+ this.session = new inspector.Session() as unknown as InspectorSession
117
+ this.session.connect()
118
+ await this.post('Profiler.enable')
119
+ await this.post('Debugger.enable')
120
+ await this.post('Profiler.startPreciseCoverage', {
121
+ callCount: true,
122
+ detailed: true,
123
+ })
124
+ }
125
+
126
+ async takeCoverage(): Promise<CoverageSnapshot> {
127
+ const { result } = await this.post<{ result: ScriptCoverage[] }>(
128
+ 'Profiler.takePreciseCoverage'
129
+ )
130
+ return {
131
+ kind: 'v8-scripts',
132
+ scripts: result.filter((script) => script.url.startsWith('file://')),
133
+ getScriptSource: (scriptId) => this.getScriptSource(scriptId),
134
+ }
135
+ }
136
+
137
+ async getScriptSource(scriptId: string): Promise<string> {
138
+ const { scriptSource } = await this.post<{ scriptSource: string }>(
139
+ 'Debugger.getScriptSource',
140
+ { scriptId }
141
+ )
142
+ return scriptSource
143
+ }
144
+
145
+ async reset(): Promise<void> {
146
+ await this.post('Profiler.stopPreciseCoverage')
147
+ await this.post('Profiler.startPreciseCoverage', {
148
+ callCount: true,
149
+ detailed: true,
150
+ })
151
+ }
152
+
153
+ async stop(): Promise<void> {
154
+ if (!this.session) return
155
+ try {
156
+ await this.post('Profiler.stopPreciseCoverage')
157
+ await this.post('Profiler.disable')
158
+ await this.post('Debugger.disable')
159
+ } finally {
160
+ this.session.disconnect()
161
+ this.session = null
162
+ this.startPromise = null
163
+ }
164
+ }
165
+ }
@@ -38,6 +38,7 @@ import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js'
38
38
  import type { CredentialService } from '../services/credential-service.js'
39
39
  import type { EmailService } from '../services/email-service.js'
40
40
  import type { MetaService } from '../services/meta-service.js'
41
+ import type { CoverageService } from '../services/v8-coverage-service.js'
41
42
  import type { SessionStore } from '../services/session-store.js'
42
43
  import type {
43
44
  AuditDurability,
@@ -150,6 +151,12 @@ export type FunctionMeta = FunctionRuntimeMeta &
150
151
  isDirectFunction: boolean
151
152
  sourceFile: string
152
153
  exportedName: string
154
+ /** File containing the handler body when it differs from sourceFile (imported handlers) */
155
+ bodySourceFile?: string
156
+ /** 1-indexed first line of the handler body (verbose meta; coverage mapping) */
157
+ bodyStart: number
158
+ /** 1-indexed last line of the handler body (verbose meta; coverage mapping) */
159
+ bodyEnd: number
153
160
  } & CommonWireMeta
154
161
  >
155
162
 
@@ -293,6 +300,8 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
293
300
  emailService?: EmailService
294
301
  /** Meta service for reading .pikku metadata files (filesystem on Node, R2/KV on CF) */
295
302
  metaService?: MetaService
303
+ /** V8 precise-coverage collector (`pikku dev --coverage` only) */
304
+ coverageService?: CoverageService
296
305
  /** Audit service for durable or staged audit event capture */
297
306
  audit?: AuditService
298
307
  /**
@@ -37,6 +37,20 @@ export interface WorkflowExpectEventuallyOptions extends WorkflowStepOptions {
37
37
  interval?: string | number
38
38
  }
39
39
 
40
+ /** Options for workflow.expectError() */
41
+ export interface WorkflowExpectErrorOptions extends WorkflowStepOptions {
42
+ /** Assert the error message matches (string = substring match). */
43
+ matches?: string | RegExp
44
+ }
45
+
46
+ /** Options for workflow.expectService() */
47
+ export interface WorkflowExpectServiceOptions extends WorkflowStepOptions {
48
+ /** Assert a recorded call's first argument deep-equals this value. */
49
+ calledWith?: unknown
50
+ /** Assert the exact number of matching calls. Default: at least one. */
51
+ times?: number
52
+ }
53
+
40
54
  /**
41
55
  * Type signature for workflow.do() RPC form - used by inspector
42
56
  */
@@ -381,6 +395,21 @@ export interface PikkuWorkflowWire {
381
395
  options?: WorkflowExpectEventuallyOptions
382
396
  ) => Promise<TOutput>
383
397
 
398
+ /** Error-path step (scenarios): succeeds only when the RPC throws; returns the message */
399
+ expectError: <TInput = any>(
400
+ stepName: string,
401
+ rpcName: string,
402
+ data: TInput,
403
+ options?: WorkflowExpectErrorOptions
404
+ ) => Promise<string>
405
+
406
+ /** Stub-assertion step (scenarios): asserts `service.method` was called on the target server */
407
+ expectService: (
408
+ stepName: string,
409
+ serviceMethod: string,
410
+ options?: WorkflowExpectServiceOptions
411
+ ) => Promise<void>
412
+
384
413
  /** Sleep for a duration */
385
414
  sleep: WorkflowWireSleep
386
415
 
@@ -60,6 +60,8 @@ import type {
60
60
  WorkflowServiceConfig,
61
61
  WorkflowStepOptions,
62
62
  WorkflowExpectEventuallyOptions,
63
+ WorkflowExpectErrorOptions,
64
+ WorkflowExpectServiceOptions,
63
65
  } from './workflow.types.js'
64
66
  import {
65
67
  continueGraph,
@@ -1099,12 +1101,6 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1099
1101
  })
1100
1102
  }
1101
1103
 
1102
- /**
1103
- * Start a new workflow run
1104
- * Automatically detects workflow type (DSL or graph) from meta and executes accordingly
1105
- * @param options.inline - If true, execute workflow directly without queue service
1106
- * @param options.startNode - Starting node ID for graph workflows (from wire config)
1107
- */
1108
1104
  /**
1109
1105
  * Start a new workflow run
1110
1106
  * Automatically detects workflow type (DSL or graph) from meta and executes accordingly
@@ -2229,6 +2225,104 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2229
2225
  )
2230
2226
  },
2231
2227
 
2228
+ expectError: async (
2229
+ stepName: string,
2230
+ rpcName: string,
2231
+ data: any,
2232
+ options?: WorkflowExpectErrorOptions
2233
+ ) => {
2234
+ this.verifyStepName(stepName)
2235
+ const resolvedRpcName =
2236
+ addonNamespace && !rpcName.includes(':')
2237
+ ? `${addonNamespace}:${rpcName}`
2238
+ : rpcName
2239
+ return await this.inlineStep(
2240
+ runId,
2241
+ stepName,
2242
+ async () => {
2243
+ let result: any
2244
+ try {
2245
+ result = options?.actor
2246
+ ? await options.actor.invoke(resolvedRpcName, data)
2247
+ : await rpcService.rpcWithWire(resolvedRpcName, data, {})
2248
+ } catch (e: any) {
2249
+ const message = e?.message ?? String(e)
2250
+ if (options?.matches) {
2251
+ const matched =
2252
+ typeof options.matches === 'string'
2253
+ ? message.includes(options.matches)
2254
+ : options.matches.test(message)
2255
+ if (!matched) {
2256
+ throw new Error(
2257
+ `[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`
2258
+ )
2259
+ }
2260
+ }
2261
+ return message
2262
+ }
2263
+ throw new Error(
2264
+ `[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`
2265
+ )
2266
+ },
2267
+ options
2268
+ )
2269
+ },
2270
+
2271
+ expectService: async (
2272
+ stepName: string,
2273
+ serviceMethod: string,
2274
+ options?: WorkflowExpectServiceOptions
2275
+ ) => {
2276
+ this.verifyStepName(stepName)
2277
+ const [service, method] = serviceMethod.split('.')
2278
+ if (!service || !method) {
2279
+ throw new Error(
2280
+ `[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`
2281
+ )
2282
+ }
2283
+ await this.inlineStep(
2284
+ runId,
2285
+ stepName,
2286
+ async () => {
2287
+ const rpcName = 'pikkuScenarioGetStubCalls'
2288
+ const calls: Array<{
2289
+ service: string
2290
+ method: string
2291
+ args: unknown[]
2292
+ }> = options?.actor
2293
+ ? await options.actor.invoke(rpcName, { service })
2294
+ : await rpcService.rpcWithWire(rpcName, { service }, {})
2295
+ const matching = (calls ?? []).filter(
2296
+ (c) =>
2297
+ c.service === service &&
2298
+ c.method === method &&
2299
+ (options?.calledWith === undefined ||
2300
+ JSON.stringify(c.args?.[0]) ===
2301
+ JSON.stringify(options.calledWith))
2302
+ )
2303
+ const expected = options?.times
2304
+ const ok =
2305
+ expected === undefined
2306
+ ? matching.length > 0
2307
+ : matching.length === expected
2308
+ if (!ok) {
2309
+ const seen =
2310
+ (calls ?? [])
2311
+ .map(
2312
+ (c) =>
2313
+ `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`
2314
+ )
2315
+ .join('\n ') || '(none)'
2316
+ throw new Error(
2317
+ `[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
2318
+ `${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`
2319
+ )
2320
+ }
2321
+ },
2322
+ options
2323
+ )
2324
+ },
2325
+
2232
2326
  // Implement workflow.sleep()
2233
2327
  sleep: async (stepName: string, duration: string | number) => {
2234
2328
  this.verifyStepName(stepName)
@@ -8,6 +8,8 @@ export type { WorkflowService } from '../../services/workflow-service.js'
8
8
  export type {
9
9
  WorkflowStepOptions,
10
10
  WorkflowExpectEventuallyOptions,
11
+ WorkflowExpectErrorOptions,
12
+ WorkflowExpectServiceOptions,
11
13
  WorkflowWireDoRPC,
12
14
  WorkflowWireDoInline,
13
15
  WorkflowWireSleep,