@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.
- package/CHANGELOG.md +37 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +1 -0
- package/dist/services/istanbul-coverage-service.d.ts +12 -0
- package/dist/services/istanbul-coverage-service.js +41 -0
- package/dist/services/scenario-actors-service.d.ts +3 -21
- package/dist/services/stub-tracker.d.ts +43 -0
- package/dist/services/stub-tracker.js +146 -0
- package/dist/services/v8-coverage-service.d.ts +79 -0
- package/dist/services/v8-coverage-service.js +63 -0
- package/dist/types/core.types.d.ts +9 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +16 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +0 -6
- package/dist/wirings/workflow/pikku-workflow-service.js +56 -6
- package/dist/wirings/workflow/workflow.types.d.ts +1 -1
- package/package.json +3 -1
- package/src/services/index.ts +21 -0
- package/src/services/istanbul-coverage-service.test.ts +66 -0
- package/src/services/istanbul-coverage-service.ts +65 -0
- package/src/services/scenario-actors-service.ts +3 -21
- package/src/services/stub-tracker.test.ts +104 -0
- package/src/services/stub-tracker.ts +185 -0
- package/src/services/v8-coverage-service.test.ts +69 -0
- package/src/services/v8-coverage-service.ts +165 -0
- package/src/types/core.types.ts +9 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +29 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +100 -6
- package/src/wirings/workflow/workflow.types.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.54",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
"./errors": "./dist/errors/index.js",
|
|
48
48
|
"./services": "./dist/services/index.js",
|
|
49
49
|
"./services/local-meta": "./dist/services/meta-service.js",
|
|
50
|
+
"./services/v8-coverage": "./dist/services/v8-coverage-service.js",
|
|
51
|
+
"./services/istanbul-coverage": "./dist/services/istanbul-coverage-service.js",
|
|
50
52
|
"./services/local-content": "./dist/services/local-content.js",
|
|
51
53
|
"./services/temporary-file-service": "./dist/services/temporary-file-service.js",
|
|
52
54
|
"./services/gopass-secrets": "./dist/services/gopass-secrets.js",
|
package/src/services/index.ts
CHANGED
|
@@ -154,3 +154,24 @@ export type {
|
|
|
154
154
|
EmailTemplateLocaleMeta,
|
|
155
155
|
EmailTemplateAssets,
|
|
156
156
|
} from './meta-service.js'
|
|
157
|
+
export type {
|
|
158
|
+
CoverageService,
|
|
159
|
+
CoverageSnapshot,
|
|
160
|
+
LineHits,
|
|
161
|
+
ScriptCoverage,
|
|
162
|
+
FunctionCoverage,
|
|
163
|
+
CoverageRange,
|
|
164
|
+
CoverageStatus,
|
|
165
|
+
FunctionCoverageEntry,
|
|
166
|
+
FunctionCoverageReport,
|
|
167
|
+
CoverageFunctionMeta,
|
|
168
|
+
} from './v8-coverage-service.js'
|
|
169
|
+
export {
|
|
170
|
+
StubTracker,
|
|
171
|
+
createStubProxy,
|
|
172
|
+
getStubTracker,
|
|
173
|
+
isTestRun,
|
|
174
|
+
stub,
|
|
175
|
+
spy,
|
|
176
|
+
type StubCall,
|
|
177
|
+
} from './stub-tracker.js'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, test, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { IstanbulCoverageService } from './istanbul-coverage-service.js'
|
|
4
|
+
|
|
5
|
+
const FILE = '/proj/src/things.function.ts'
|
|
6
|
+
|
|
7
|
+
const seedCoverage = () => {
|
|
8
|
+
;(globalThis as any).__coverage__ = {
|
|
9
|
+
[FILE]: {
|
|
10
|
+
path: FILE,
|
|
11
|
+
statementMap: {
|
|
12
|
+
'0': { start: { line: 2, column: 2 }, end: { line: 2, column: 20 } },
|
|
13
|
+
'1': { start: { line: 3, column: 4 }, end: { line: 3, column: 30 } },
|
|
14
|
+
'2': { start: { line: 5, column: 2 }, end: { line: 6, column: 18 } },
|
|
15
|
+
},
|
|
16
|
+
s: { '0': 3, '1': 0, '2': 3 },
|
|
17
|
+
branchMap: {},
|
|
18
|
+
b: {},
|
|
19
|
+
fnMap: {},
|
|
20
|
+
f: {},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
delete (globalThis as any).__coverage__
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('IstanbulCoverageService', () => {
|
|
30
|
+
test('takeCoverage converts __coverage__ statement counts into line hits', async () => {
|
|
31
|
+
seedCoverage()
|
|
32
|
+
const service = new IstanbulCoverageService()
|
|
33
|
+
await service.start()
|
|
34
|
+
const snapshot = await service.takeCoverage()
|
|
35
|
+
assert.equal(snapshot.kind, 'line-hits')
|
|
36
|
+
if (snapshot.kind !== 'line-hits') return
|
|
37
|
+
const hits = snapshot.lineHits.get(FILE)
|
|
38
|
+
assert.ok(hits, 'file should have line hits')
|
|
39
|
+
assert.equal(hits.get(2), 3)
|
|
40
|
+
assert.equal(hits.get(3), 0)
|
|
41
|
+
assert.equal(hits.get(5), 3)
|
|
42
|
+
assert.equal(
|
|
43
|
+
hits.get(6),
|
|
44
|
+
undefined,
|
|
45
|
+
'counts attach to statement start lines only'
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('reset zeroes counters so per-scenario attribution is possible', async () => {
|
|
50
|
+
seedCoverage()
|
|
51
|
+
const service = new IstanbulCoverageService()
|
|
52
|
+
await service.reset()
|
|
53
|
+
const snapshot = await service.takeCoverage()
|
|
54
|
+
if (snapshot.kind !== 'line-hits') return assert.fail('expected line-hits')
|
|
55
|
+
const hits = snapshot.lineHits.get(FILE)
|
|
56
|
+
assert.equal(hits?.get(2), 0)
|
|
57
|
+
assert.equal(hits?.get(5), 0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('takeCoverage with no __coverage__ global returns an empty snapshot', async () => {
|
|
61
|
+
const service = new IstanbulCoverageService()
|
|
62
|
+
const snapshot = await service.takeCoverage()
|
|
63
|
+
if (snapshot.kind !== 'line-hits') return assert.fail('expected line-hits')
|
|
64
|
+
assert.equal(snapshot.lineHits.size, 0)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CoverageService,
|
|
3
|
+
CoverageSnapshot,
|
|
4
|
+
LineHits,
|
|
5
|
+
} from './v8-coverage-service.js'
|
|
6
|
+
|
|
7
|
+
interface IstanbulStatementLocation {
|
|
8
|
+
start: { line: number }
|
|
9
|
+
end: { line: number }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface IstanbulFileCoverage {
|
|
13
|
+
path: string
|
|
14
|
+
statementMap: Record<string, IstanbulStatementLocation>
|
|
15
|
+
s: Record<string, number>
|
|
16
|
+
b: Record<string, number[]>
|
|
17
|
+
f: Record<string, number>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type IstanbulCoverageGlobal = Record<string, IstanbulFileCoverage>
|
|
21
|
+
|
|
22
|
+
const getCoverageGlobal = (): IstanbulCoverageGlobal | undefined =>
|
|
23
|
+
(globalThis as { __coverage__?: IstanbulCoverageGlobal }).__coverage__
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reads istanbul-instrumented counters from the `__coverage__` global —
|
|
27
|
+
* the coverage backend for runtimes without V8 precise coverage (e.g. Bun,
|
|
28
|
+
* where source files are instrumented at load time by a Bun loader plugin).
|
|
29
|
+
*/
|
|
30
|
+
export class IstanbulCoverageService implements CoverageService {
|
|
31
|
+
async start(): Promise<void> {}
|
|
32
|
+
|
|
33
|
+
async takeCoverage(): Promise<CoverageSnapshot> {
|
|
34
|
+
const coverage = getCoverageGlobal()
|
|
35
|
+
const lineHits: LineHits = new Map()
|
|
36
|
+
for (const file of Object.values(coverage ?? {})) {
|
|
37
|
+
let hits = lineHits.get(file.path)
|
|
38
|
+
if (!hits) {
|
|
39
|
+
hits = new Map()
|
|
40
|
+
lineHits.set(file.path, hits)
|
|
41
|
+
}
|
|
42
|
+
// istanbul line semantics: a statement's count belongs to its start
|
|
43
|
+
// line only, so an enclosing multi-line statement never masks an
|
|
44
|
+
// unexecuted inner statement (e.g. a throw inside a taken if).
|
|
45
|
+
for (const [id, location] of Object.entries(file.statementMap)) {
|
|
46
|
+
const count = file.s[id] ?? 0
|
|
47
|
+
const line = location.start.line
|
|
48
|
+
hits.set(line, Math.max(hits.get(line) ?? 0, count))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { kind: 'line-hits', lineHits }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async reset(): Promise<void> {
|
|
55
|
+
for (const file of Object.values(getCoverageGlobal() ?? {})) {
|
|
56
|
+
for (const id of Object.keys(file.s)) file.s[id] = 0
|
|
57
|
+
for (const id of Object.keys(file.f)) file.f[id] = 0
|
|
58
|
+
for (const id of Object.keys(file.b)) {
|
|
59
|
+
file.b[id] = file.b[id]!.map(() => 0)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async stop(): Promise<void> {}
|
|
65
|
+
}
|
|
@@ -3,14 +3,7 @@ import type {
|
|
|
3
3
|
ActorFlowVerdict,
|
|
4
4
|
} from '../wirings/actor-flow/actor-flow.types.js'
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* A scenario actor: a synthetic user (a normal user row flagged `actor`) that
|
|
8
|
-
* workflow steps can run as. Passed to `workflow.do(step, rpc, data, { actor })`
|
|
9
|
-
* — the step then goes through the actor's authenticated client over the REAL
|
|
10
|
-
* transport (auth middleware, permissions, serialization all exercised),
|
|
11
|
-
* never through internal dispatch. Login is lazy: the first `invoke` signs the
|
|
12
|
-
* actor in and the session is cached for the actor's lifetime.
|
|
13
|
-
*/
|
|
6
|
+
/** A synthetic user (a user row flagged `actor`) that workflow steps run as over the real transport */
|
|
14
7
|
export interface ScenarioActor<TAgentName extends string = string> {
|
|
15
8
|
/** Stable actor name (the key in pikku.config.json's actor registry). */
|
|
16
9
|
readonly name: string
|
|
@@ -18,22 +11,11 @@ export interface ScenarioActor<TAgentName extends string = string> {
|
|
|
18
11
|
readonly email: string
|
|
19
12
|
/** Invoke an exposed RPC as this actor over the real transport. */
|
|
20
13
|
invoke(rpcName: string, data: unknown): Promise<unknown>
|
|
21
|
-
/**
|
|
22
|
-
* Hold a dynamic conversation with a target Pikku AI agent, in THIS actor's
|
|
23
|
-
* persona (personality/jobTitle). Drives the target over the real transport
|
|
24
|
-
* as the signed-in actor, answers its tool-approval requests in-persona, and
|
|
25
|
-
* returns the actor's verdict on whether the task was met. Deterministic
|
|
26
|
-
* checks are the caller's job — use `invoke` afterwards. In a typed project
|
|
27
|
-
* `agent` is constrained to the generated union of agent names.
|
|
28
|
-
*/
|
|
14
|
+
/** Converse with a Pikku AI agent in this actor's persona and return its verdict */
|
|
29
15
|
converse(options: ConverseOptions<TAgentName>): Promise<ActorFlowVerdict>
|
|
30
16
|
}
|
|
31
17
|
|
|
32
|
-
/**
|
|
33
|
-
* Display/config metadata for an actor (from pikku.config.json). The email
|
|
34
|
-
* identifies the actor's user row; personality/jobTitle exist for the console
|
|
35
|
-
* screen and for agent-driven flows (the agent plays the persona).
|
|
36
|
-
*/
|
|
18
|
+
/** Display/config metadata for an actor (from pikku.config.json) */
|
|
37
19
|
export interface ScenarioActorConfig {
|
|
38
20
|
email: string
|
|
39
21
|
name?: string
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, test, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {
|
|
4
|
+
StubTracker,
|
|
5
|
+
stub,
|
|
6
|
+
spy,
|
|
7
|
+
isTestRun,
|
|
8
|
+
getStubTracker,
|
|
9
|
+
} from './stub-tracker.js'
|
|
10
|
+
|
|
11
|
+
describe('StubTracker', () => {
|
|
12
|
+
test('records calls and exposes them via getCalls', () => {
|
|
13
|
+
const tracker = new StubTracker()
|
|
14
|
+
tracker.record('email', 'send', [{ to: 'a@b.c' }])
|
|
15
|
+
tracker.record('payments', 'charge', [{ amount: 5 }])
|
|
16
|
+
|
|
17
|
+
assert.equal(tracker.getCalls().length, 2)
|
|
18
|
+
const emailCalls = tracker.getCalls('email')
|
|
19
|
+
assert.equal(emailCalls.length, 1)
|
|
20
|
+
assert.deepEqual(emailCalls[0], {
|
|
21
|
+
service: 'email',
|
|
22
|
+
method: 'send',
|
|
23
|
+
args: [{ to: 'a@b.c' }],
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('reset clears recorded calls and strict-mode touches', () => {
|
|
28
|
+
const tracker = new StubTracker()
|
|
29
|
+
tracker.record('email', 'send', [])
|
|
30
|
+
tracker.reset()
|
|
31
|
+
assert.equal(tracker.getCalls().length, 0)
|
|
32
|
+
tracker.verify()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('legacy stub() proxy records through the same tracker', async () => {
|
|
36
|
+
const tracker = new StubTracker()
|
|
37
|
+
const email = tracker.stub<{ send: (msg: unknown) => Promise<void> }>(
|
|
38
|
+
'email'
|
|
39
|
+
)
|
|
40
|
+
await email.send({ to: 'x@y.z' })
|
|
41
|
+
assert.deepEqual(tracker.getCalls('email')[0]?.args, [{ to: 'x@y.z' }])
|
|
42
|
+
tracker.assert('email', 'send')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('stub/spy core utils', () => {
|
|
47
|
+
beforeEach(() => getStubTracker().reset())
|
|
48
|
+
|
|
49
|
+
test('stub() records into the default tracker and uses the implementation', async () => {
|
|
50
|
+
const email = stub<{ send: (msg: unknown) => Promise<{ ok: boolean }> }>(
|
|
51
|
+
'email',
|
|
52
|
+
{ send: async () => ({ ok: true }) }
|
|
53
|
+
)
|
|
54
|
+
const result = await email.send({ to: 'a@b.c' })
|
|
55
|
+
assert.deepEqual(result, { ok: true })
|
|
56
|
+
assert.deepEqual(getStubTracker().getCalls('email')[0], {
|
|
57
|
+
service: 'email',
|
|
58
|
+
method: 'send',
|
|
59
|
+
args: [{ to: 'a@b.c' }],
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('stub() without an implementation resolves undefined', async () => {
|
|
64
|
+
const payments = stub<{ charge: (x: unknown) => Promise<unknown> }>(
|
|
65
|
+
'payments'
|
|
66
|
+
)
|
|
67
|
+
assert.equal(await payments.charge({ amount: 5 }), undefined)
|
|
68
|
+
assert.equal(getStubTracker().getCalls('payments').length, 1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('spy() records calls and passes through to the real service', async () => {
|
|
72
|
+
let realCalled = 0
|
|
73
|
+
const real = {
|
|
74
|
+
config: { retries: 3 },
|
|
75
|
+
send: async (msg: { to: string }) => {
|
|
76
|
+
realCalled++
|
|
77
|
+
return { delivered: msg.to }
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
const email = spy('email', real)
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(await email.send({ to: 'a@b.c' }), {
|
|
83
|
+
delivered: 'a@b.c',
|
|
84
|
+
})
|
|
85
|
+
assert.equal(realCalled, 1)
|
|
86
|
+
assert.equal(email.config.retries, 3)
|
|
87
|
+
assert.equal(getStubTracker().getCalls('email').length, 1)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('isTestRun', () => {
|
|
92
|
+
const original = process.env.PIKKU_TEST_RUN
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
if (original === undefined) delete process.env.PIKKU_TEST_RUN
|
|
95
|
+
else process.env.PIKKU_TEST_RUN = original
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('reflects the PIKKU_TEST_RUN environment variable', () => {
|
|
99
|
+
delete process.env.PIKKU_TEST_RUN
|
|
100
|
+
assert.equal(isTestRun(), false)
|
|
101
|
+
process.env.PIKKU_TEST_RUN = 'true'
|
|
102
|
+
assert.equal(isTestRun(), true)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
export type StubCall = { service: string; method: string; args: unknown[] }
|
|
2
|
+
|
|
3
|
+
type Call = { method: string; args: unknown[]; verified: boolean }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tracks calls made to stubbed services and enforces strict mode:
|
|
7
|
+
* once a scenario asserts any call on a service, every recorded call on that
|
|
8
|
+
* service must be verified by end of scenario — otherwise the After hook fails.
|
|
9
|
+
* Services never touched by an assertion stay lenient.
|
|
10
|
+
*/
|
|
11
|
+
export class StubTracker {
|
|
12
|
+
private readonly calls = new Map<string, Call[]>()
|
|
13
|
+
private readonly touched = new Set<string>()
|
|
14
|
+
|
|
15
|
+
record(service: string, method: string, args: unknown[]): void {
|
|
16
|
+
const list = this.calls.get(service) ?? []
|
|
17
|
+
list.push({ method, args, verified: false })
|
|
18
|
+
this.calls.set(service, list)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getCalls(service?: string): StubCall[] {
|
|
22
|
+
const result: StubCall[] = []
|
|
23
|
+
for (const [name, list] of this.calls) {
|
|
24
|
+
if (service && name !== service) continue
|
|
25
|
+
for (const call of list) {
|
|
26
|
+
result.push({ service: name, method: call.method, args: call.args })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return result
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
reset(): void {
|
|
33
|
+
this.calls.clear()
|
|
34
|
+
this.touched.clear()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
stub<T>(service: string): T {
|
|
38
|
+
const self = this
|
|
39
|
+
return new Proxy(Object.create(null) as object, {
|
|
40
|
+
get(_, method: string) {
|
|
41
|
+
return (...args: unknown[]) => {
|
|
42
|
+
self.record(service, method, args)
|
|
43
|
+
return Promise.resolve()
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
}) as unknown as T
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
assert(service: string, method: string): void {
|
|
50
|
+
this.touched.add(service)
|
|
51
|
+
const list = this.calls.get(service) ?? []
|
|
52
|
+
const idx = list.findIndex((c) => c.method === method && !c.verified)
|
|
53
|
+
if (idx === -1) {
|
|
54
|
+
const seen = list.map((c) => c.method).join(', ') || '(none)'
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Expected "${service}.${method}" to have been called. Recorded: ${seen}`
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
list[idx]!.verified = true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
assertCall(
|
|
63
|
+
service: string,
|
|
64
|
+
method: string,
|
|
65
|
+
predicate: (args: unknown[]) => boolean,
|
|
66
|
+
description: string
|
|
67
|
+
): void {
|
|
68
|
+
this.touched.add(service)
|
|
69
|
+
const list = this.calls.get(service) ?? []
|
|
70
|
+
const idx = list.findIndex(
|
|
71
|
+
(c) => c.method === method && !c.verified && predicate(c.args)
|
|
72
|
+
)
|
|
73
|
+
if (idx === -1) {
|
|
74
|
+
const seen =
|
|
75
|
+
list
|
|
76
|
+
.filter((c) => c.method === method)
|
|
77
|
+
.map((c) => JSON.stringify(c.args[0]))
|
|
78
|
+
.join('\n ') || '(none)'
|
|
79
|
+
throw new Error(`Expected ${description} but found:\n ${seen}`)
|
|
80
|
+
}
|
|
81
|
+
list[idx]!.verified = true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
assertNoCalls(
|
|
85
|
+
service: string,
|
|
86
|
+
method?: string,
|
|
87
|
+
predicate?: (args: unknown[]) => boolean,
|
|
88
|
+
description?: string
|
|
89
|
+
): void {
|
|
90
|
+
this.touched.add(service)
|
|
91
|
+
const list = this.calls.get(service) ?? []
|
|
92
|
+
const relevant = (
|
|
93
|
+
method ? list.filter((c) => c.method === method) : list
|
|
94
|
+
).filter((c) => !predicate || predicate(c.args))
|
|
95
|
+
if (relevant.length > 0) {
|
|
96
|
+
const calls = relevant
|
|
97
|
+
.map(
|
|
98
|
+
(c) =>
|
|
99
|
+
`${c.method}(${c.args.map((a) => JSON.stringify(a)).join(', ')})`
|
|
100
|
+
)
|
|
101
|
+
.join('\n ')
|
|
102
|
+
const what = description ?? `"${service}${method ? '.' + method : ''}"`
|
|
103
|
+
throw new Error(`Expected no ${what} calls but got:\n ${calls}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
verify(): void {
|
|
108
|
+
const errors: string[] = []
|
|
109
|
+
for (const service of this.touched) {
|
|
110
|
+
const unverified = (this.calls.get(service) ?? []).filter(
|
|
111
|
+
(c) => !c.verified
|
|
112
|
+
)
|
|
113
|
+
for (const c of unverified) {
|
|
114
|
+
const argStr = c.args.map((a) => JSON.stringify(a)).join(', ')
|
|
115
|
+
errors.push(` ${service}.${c.method}(${argStr})`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (errors.length) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Unexpected stub calls — assert them in the scenario or remove the side effect:\n${errors.join('\n')}`
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Creates a Proxy suitable for passing as `existingServices` to
|
|
128
|
+
* `createSingletonServices`. Every property returns `tracker.stub(prop)`
|
|
129
|
+
* EXCEPT `schema`, which returns `undefined` so the service factory creates
|
|
130
|
+
* a real schema service. Stubbing the schema makes validation a no-op —
|
|
131
|
+
* required fields pass silently and tests validate nothing.
|
|
132
|
+
*/
|
|
133
|
+
export function createStubProxy(tracker: StubTracker): Record<string, unknown> {
|
|
134
|
+
return new Proxy({} as Record<string, unknown>, {
|
|
135
|
+
get(_, prop: string) {
|
|
136
|
+
if (prop === 'schema') return undefined
|
|
137
|
+
return tracker.stub(prop)
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const defaultStubTracker = new StubTracker()
|
|
143
|
+
|
|
144
|
+
/** The process-wide tracker that `stub()`/`spy()` record into — read by the console's getStubCalls/resetStubs RPCs */
|
|
145
|
+
export const getStubTracker = (): StubTracker => defaultStubTracker
|
|
146
|
+
|
|
147
|
+
/** True when the server was started by `pikku dev --test` (sets PIKKU_TEST_RUN) */
|
|
148
|
+
export const isTestRun = (): boolean =>
|
|
149
|
+
(globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
150
|
+
.process?.env?.PIKKU_TEST_RUN === 'true'
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Creates a recording fake for a service. Methods present on `impl` run and
|
|
154
|
+
* their result is returned; missing methods resolve `undefined`. Every call
|
|
155
|
+
* is recorded so scenarios can assert via workflow.expectService().
|
|
156
|
+
*/
|
|
157
|
+
export function stub<T = any>(service: string, impl?: Partial<T>): T {
|
|
158
|
+
return new Proxy((impl ?? {}) as object, {
|
|
159
|
+
get(target: any, method: string | symbol) {
|
|
160
|
+
if (typeof method === 'symbol') return target[method]
|
|
161
|
+
const real = target[method]
|
|
162
|
+
if (typeof real !== 'function' && real !== undefined) return real
|
|
163
|
+
return (...args: unknown[]) => {
|
|
164
|
+
defaultStubTracker.record(service, method, args)
|
|
165
|
+
return real ? real.apply(target, args) : Promise.resolve(undefined)
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
}) as T
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Wraps a real service so every method call is recorded and passed through */
|
|
172
|
+
export function spy<T extends object>(service: string, real: T): T {
|
|
173
|
+
return new Proxy(real, {
|
|
174
|
+
get(target: any, method: string | symbol) {
|
|
175
|
+
const value = target[method]
|
|
176
|
+
if (typeof method === 'symbol' || typeof value !== 'function') {
|
|
177
|
+
return value
|
|
178
|
+
}
|
|
179
|
+
return (...args: unknown[]) => {
|
|
180
|
+
defaultStubTracker.record(service, method, args)
|
|
181
|
+
return value.apply(target, args)
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
}) as T
|
|
185
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, test, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { V8CoverageService } from './v8-coverage-service.js'
|
|
4
|
+
|
|
5
|
+
const service = new V8CoverageService()
|
|
6
|
+
|
|
7
|
+
function coveredProbe(n: number): number {
|
|
8
|
+
return n * 2
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('V8CoverageService', () => {
|
|
12
|
+
after(async () => {
|
|
13
|
+
await service.stop()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('start + takeCoverage reports call counts for functions invoked after start', async () => {
|
|
17
|
+
await service.start()
|
|
18
|
+
coveredProbe(21)
|
|
19
|
+
|
|
20
|
+
const snapshot = await service.takeCoverage()
|
|
21
|
+
assert.equal(snapshot.kind, 'v8-scripts')
|
|
22
|
+
if (snapshot.kind !== 'v8-scripts') return
|
|
23
|
+
const own = snapshot.scripts.find((s) =>
|
|
24
|
+
s.url.includes('v8-coverage-service.test')
|
|
25
|
+
)
|
|
26
|
+
assert.ok(own, 'own test script should appear in precise coverage')
|
|
27
|
+
const probe = own.functions.find((f) =>
|
|
28
|
+
f.functionName.includes('coveredProbe')
|
|
29
|
+
)
|
|
30
|
+
assert.ok(probe, 'coveredProbe should be tracked')
|
|
31
|
+
assert.ok(
|
|
32
|
+
probe.ranges[0].count >= 1,
|
|
33
|
+
`coveredProbe should have been counted, got ${probe.ranges[0].count}`
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('reset clears call counts so attribution per run is possible', async () => {
|
|
38
|
+
coveredProbe(1)
|
|
39
|
+
await service.reset()
|
|
40
|
+
const snapshot = await service.takeCoverage()
|
|
41
|
+
if (snapshot.kind !== 'v8-scripts') return assert.fail('expected scripts')
|
|
42
|
+
const own = snapshot.scripts.find((s) =>
|
|
43
|
+
s.url.includes('v8-coverage-service.test')
|
|
44
|
+
)
|
|
45
|
+
const probe = own?.functions.find((f) =>
|
|
46
|
+
f.functionName.includes('coveredProbe')
|
|
47
|
+
)
|
|
48
|
+
const count = probe?.ranges[0].count ?? 0
|
|
49
|
+
assert.equal(
|
|
50
|
+
count,
|
|
51
|
+
0,
|
|
52
|
+
`after reset coveredProbe count should be 0, got ${count}`
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('getScriptSource returns the executed source for mapping', async () => {
|
|
57
|
+
const snapshot = await service.takeCoverage()
|
|
58
|
+
if (snapshot.kind !== 'v8-scripts') return assert.fail('expected scripts')
|
|
59
|
+
const own = snapshot.scripts.find((s) =>
|
|
60
|
+
s.url.includes('v8-coverage-service.test')
|
|
61
|
+
)
|
|
62
|
+
assert.ok(own)
|
|
63
|
+
const source = await snapshot.getScriptSource(own.scriptId)
|
|
64
|
+
assert.ok(
|
|
65
|
+
source.includes('coveredProbe'),
|
|
66
|
+
'script source should contain the probe function'
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
})
|