@pikku/core 0.12.69 → 0.12.70
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 +334 -0
- package/README.md +34 -2
- package/dist/function/functions.types.d.ts +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- package/dist/pikku-state.js +1 -0
- package/dist/services/http-scenario-actors.d.ts +12 -4
- package/dist/services/http-scenario-actors.js +47 -45
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +1 -0
- package/dist/services/meta-service.d.ts +5 -1
- package/dist/services/meta-service.js +44 -18
- package/dist/services/scenario-actors-service.d.ts +108 -2
- package/dist/services/scenario-actors-service.js +40 -1
- package/dist/types/core.types.d.ts +21 -3
- package/dist/types/state.types.d.ts +3 -1
- package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
- package/dist/wirings/actor-flow/index.d.ts +1 -1
- package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
- package/dist/wirings/actor-flow/run-conversation.js +27 -27
- package/dist/wirings/cli/command-parser.js +11 -1
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
- package/dist/wirings/workflow/feature.d.ts +28 -0
- package/dist/wirings/workflow/feature.js +57 -0
- package/dist/wirings/workflow/index.d.ts +13 -2
- package/dist/wirings/workflow/index.js +15 -0
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
- package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +103 -10
- package/dist/wirings/workflow/pikku-workflow-service.js +80 -135
- package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
- package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
- package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
- package/dist/wirings/workflow/scenario-poll.js +25 -0
- package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
- package/dist/wirings/workflow/scenario-prose.js +45 -0
- package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
- package/dist/wirings/workflow/scenario-step-guards.js +29 -0
- package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
- package/dist/wirings/workflow/scenario-step.types.js +1 -0
- package/dist/wirings/workflow/workflow.types.d.ts +81 -2
- package/package.json +3 -1
- package/src/function/functions.types.ts +32 -0
- package/src/index.ts +1 -0
- package/src/internal.ts +5 -1
- package/src/pikku-state.ts +1 -0
- package/src/services/http-scenario-actors.test.ts +85 -1
- package/src/services/http-scenario-actors.ts +65 -51
- package/src/services/index.ts +5 -0
- package/src/services/meta-service.test.ts +79 -0
- package/src/services/meta-service.ts +61 -26
- package/src/services/scenario-actors-service.ts +157 -2
- package/src/types/core.types.ts +27 -2
- package/src/types/state.types.ts +3 -0
- package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
- package/src/wirings/actor-flow/index.ts +1 -1
- package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
- package/src/wirings/actor-flow/run-conversation.ts +36 -41
- package/src/wirings/cli/command-parser.test.ts +60 -0
- package/src/wirings/cli/command-parser.ts +12 -1
- package/src/wirings/rpc/rpc-runner.test.ts +28 -5
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
- package/src/wirings/workflow/feature.test.ts +131 -0
- package/src/wirings/workflow/feature.ts +78 -0
- package/src/wirings/workflow/index.ts +73 -0
- package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +196 -208
- package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
- package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
- package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
- package/src/wirings/workflow/scenario-poll.test.ts +66 -0
- package/src/wirings/workflow/scenario-poll.ts +36 -0
- package/src/wirings/workflow/scenario-prose.test.ts +152 -0
- package/src/wirings/workflow/scenario-prose.ts +79 -0
- package/src/wirings/workflow/scenario-service.test.ts +155 -0
- package/src/wirings/workflow/scenario-step-guards.ts +43 -0
- package/src/wirings/workflow/scenario-step.test.ts +441 -8
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- package/src/wirings/workflow/workflow.types.ts +98 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { ScenarioActor } from '../../services/scenario-actors-service.js';
|
|
2
|
+
/**
|
|
3
|
+
* Scenario steps: named, typed units of scenario behaviour.
|
|
4
|
+
*
|
|
5
|
+
* A step's body is an ordinary pikku function, so it may drive a browser, call
|
|
6
|
+
* an RPC as its actor, or run a workflow. `given`/`when`/`then` are sugar over
|
|
7
|
+
* `step` — they only change the prose the reporter renders.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Which Gherkin-style keyword the reporter prefixes this step with. `step`
|
|
11
|
+
* renders no prefix at all.
|
|
12
|
+
*/
|
|
13
|
+
export type ScenarioStepPhase = 'step' | 'given' | 'when' | 'then';
|
|
14
|
+
/**
|
|
15
|
+
* Options accepted by `scenario.step/given/when/then`.
|
|
16
|
+
*
|
|
17
|
+
* Note the retry default differs from an ordinary workflow step: retrying a
|
|
18
|
+
* failed assertion is the wrong behaviour for a test primitive, so steps
|
|
19
|
+
* default to no retries.
|
|
20
|
+
*/
|
|
21
|
+
export interface ScenarioStepOptions {
|
|
22
|
+
/** The actor this step runs as. Required for steps declaring `browser: true`. */
|
|
23
|
+
actor?: unknown;
|
|
24
|
+
/** Overrides the step's own `description` for this call site only. */
|
|
25
|
+
description?: string;
|
|
26
|
+
/** Defaults to 0 for steps — a failed assertion must not be retried. */
|
|
27
|
+
retries?: number;
|
|
28
|
+
retryDelay?: number | string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The environment a scenario run targets, as declared in pikku.config.json
|
|
32
|
+
* under `scenarios.environments`.
|
|
33
|
+
*/
|
|
34
|
+
export interface ScenarioEnvironment {
|
|
35
|
+
/** Base API URL of the target app, INCLUDING the HTTP prefix. */
|
|
36
|
+
apiUrl: string;
|
|
37
|
+
/** Base URL of the app's UI, for environments with browser steps. */
|
|
38
|
+
appUrl?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The `scenarioStep` wire, present on every scenario step invocation.
|
|
42
|
+
*
|
|
43
|
+
* `TActor` is the project's own actor type, so a step reaches only the RPCs its
|
|
44
|
+
* actors can actually call. It defaults to the open `ScenarioActor` for a
|
|
45
|
+
* project that declares no registry.
|
|
46
|
+
*/
|
|
47
|
+
export interface PikkuScenarioStepWire<TActor = ScenarioActor> {
|
|
48
|
+
/** Registered step name (also its pikkuFuncId) */
|
|
49
|
+
name: string;
|
|
50
|
+
/** Durable key within the run; may carry an `#ordinal` suffix when repeated */
|
|
51
|
+
stepName: string;
|
|
52
|
+
runId: string;
|
|
53
|
+
phase: ScenarioStepPhase;
|
|
54
|
+
/**
|
|
55
|
+
* The actor this step runs as, when one was given. Call RPCs through it
|
|
56
|
+
* (`actor.invoke(...)`) so they run against the target environment as that
|
|
57
|
+
* persona.
|
|
58
|
+
*/
|
|
59
|
+
actor?: TActor;
|
|
60
|
+
/**
|
|
61
|
+
* The environment this run targets. A step runs in the CLI process, where
|
|
62
|
+
* there is no `variables` service — this is how a raw-HTTP step learns the
|
|
63
|
+
* target's URL without reaching for `process.env`.
|
|
64
|
+
*/
|
|
65
|
+
env?: ScenarioEnvironment;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* How a browser step names an element.
|
|
69
|
+
*
|
|
70
|
+
* A `data-testid` on its own is rarely enough to name exactly one: `where`
|
|
71
|
+
* matches the element's own data attributes (so a step asserts a status
|
|
72
|
+
* without reading translated copy back to the app), `prefix` matches a family
|
|
73
|
+
* of ids, `containing` picks the match holding a piece of text, and `within`
|
|
74
|
+
* scopes the lookup to one row or section.
|
|
75
|
+
*
|
|
76
|
+
* Declared here so a step's input type is structural; the driver
|
|
77
|
+
* (`@pikku/playwright`) is what resolves it against a real page.
|
|
78
|
+
*/
|
|
79
|
+
export interface TestIdSelector {
|
|
80
|
+
testId: string;
|
|
81
|
+
/** Match every test id beginning with `testId`, e.g. every `flow-card-*`. */
|
|
82
|
+
prefix?: boolean;
|
|
83
|
+
/** Data attributes the element must also carry, e.g. `{ 'data-open': 'true' }`. */
|
|
84
|
+
where?: Record<string, string>;
|
|
85
|
+
/** Narrow to the one match holding this text. */
|
|
86
|
+
containing?: string;
|
|
87
|
+
/** Scope the lookup to one enclosing element, e.g. the row for one user. */
|
|
88
|
+
within?: TestIdSelector;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Structural browser handle, present only when the runner provisioned a
|
|
92
|
+
* browser for this step (`browser: true` on the step config).
|
|
93
|
+
*
|
|
94
|
+
* `@pikku/core` deliberately never imports playwright — it must stay
|
|
95
|
+
* dependency-free for edge runtimes. `@pikku/playwright` augments this
|
|
96
|
+
* interface via `declare module`, so `wire.browser.page` is a fully typed
|
|
97
|
+
* Playwright `Page` in a project that installs it.
|
|
98
|
+
*/
|
|
99
|
+
export interface PikkuBrowserWire {
|
|
100
|
+
/** The actor whose browser context this is */
|
|
101
|
+
readonly actor: string;
|
|
102
|
+
goto(url: string): Promise<void>;
|
|
103
|
+
screenshot(name?: string): Promise<Uint8Array>;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* What one actor's window looked like at the moment a scenario failed.
|
|
107
|
+
*
|
|
108
|
+
* A browser step fails with a selector timeout that says nothing about *why*
|
|
109
|
+
* the page never rendered. The answer is almost always in the page's own
|
|
110
|
+
* errors, which the driver has been collecting all along.
|
|
111
|
+
*/
|
|
112
|
+
export interface ScenarioBrowserFailure {
|
|
113
|
+
/** The actor whose window this is. */
|
|
114
|
+
actor: string;
|
|
115
|
+
/** Where the window was pointed, when the driver can still report it. */
|
|
116
|
+
url?: string;
|
|
117
|
+
/** Path the screenshot was written to; absent when none could be taken. */
|
|
118
|
+
screenshot?: string;
|
|
119
|
+
consoleErrors: string[];
|
|
120
|
+
pageErrors: string[];
|
|
121
|
+
failedRequests: string[];
|
|
122
|
+
apiErrors: string[];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Supplied by `@pikku/playwright` (or any other driver) and consumed by the
|
|
126
|
+
* scenario runner. Declared here so the CLI depends only on core.
|
|
127
|
+
*
|
|
128
|
+
* `reset` and `captureFailure` are optional so a driver written against an
|
|
129
|
+
* earlier version keeps compiling; the runner treats a driver without them as
|
|
130
|
+
* one that simply offers no isolation and no diagnostics.
|
|
131
|
+
*/
|
|
132
|
+
export interface ScenarioBrowserProvider {
|
|
133
|
+
/** Resolve — creating on first use — the browser session for an actor. */
|
|
134
|
+
sessionFor(actorName: string): Promise<PikkuBrowserWire>;
|
|
135
|
+
/**
|
|
136
|
+
* Discard every actor's per-scenario state — cookies, storage, open pages —
|
|
137
|
+
* while keeping the browser itself. Called between scenarios, so one
|
|
138
|
+
* scenario cannot leave the next signed in as somebody else.
|
|
139
|
+
*/
|
|
140
|
+
reset?(): Promise<void>;
|
|
141
|
+
/**
|
|
142
|
+
* Snapshot every open window for a failed scenario. `label` identifies the
|
|
143
|
+
* scenario in artifact filenames. Never throws: a failure to capture must
|
|
144
|
+
* not replace the failure being captured.
|
|
145
|
+
*/
|
|
146
|
+
captureFailure?(label: string): Promise<ScenarioBrowserFailure[]>;
|
|
147
|
+
close(): Promise<void>;
|
|
148
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { SerializedError, CommonWireMeta } from '../../types/core.types.js';
|
|
2
|
-
import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
|
|
2
|
+
import type { CorePikkuFunctionConfig, CorePikkuFunctionHook } from '../../function/functions.types.js';
|
|
3
3
|
import type { GroupConcurrencyConfig } from '../queue/queue.types.js';
|
|
4
4
|
export type { WorkflowService } from '../../services/workflow-service.js';
|
|
5
|
-
export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowExpectErrorOptions, WorkflowExpectServiceOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, WorkflowWireApproval, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './dsl/workflow-dsl.types.js';
|
|
5
|
+
export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowExpectErrorOptions, WorkflowExpectServiceOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, WorkflowWireApproval, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, ScenarioStepInvocation, ScenarioStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './dsl/workflow-dsl.types.js';
|
|
6
|
+
export type { ScenarioStepPhase, ScenarioStepOptions, PikkuScenarioStepWire, PikkuBrowserWire, ScenarioBrowserProvider, } from './scenario-step.types.js';
|
|
6
7
|
import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
|
|
7
8
|
export interface WorkflowRunWire {
|
|
8
9
|
type: string;
|
|
@@ -235,6 +236,77 @@ export type CoreWorkflow<PikkuFunctionConfig extends CorePikkuFunctionConfig<any
|
|
|
235
236
|
/** Tags for organization and filtering */
|
|
236
237
|
tags?: string[];
|
|
237
238
|
};
|
|
239
|
+
/**
|
|
240
|
+
* A scenario as a feature references it: either the scenario itself, or the
|
|
241
|
+
* scenario paired with the input to run it with. The paired form is gherkin's
|
|
242
|
+
* `Examples:` — the same scenario run once per row, written as an ordinary
|
|
243
|
+
* loop rather than a table.
|
|
244
|
+
*/
|
|
245
|
+
export type CoreFeatureScenario = CorePikkuFunctionConfig<any, any, any> | {
|
|
246
|
+
scenario: CorePikkuFunctionConfig<any, any, any>;
|
|
247
|
+
data: unknown;
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* A feature: an ordered group of scenarios, mirroring gherkin's Feature ↔
|
|
251
|
+
* Scenario structure. Scenarios are referenced by imported identifier, so a
|
|
252
|
+
* renamed or deleted scenario is a compile error rather than a silent skip.
|
|
253
|
+
*
|
|
254
|
+
* Hooks run **once around the whole group** (`before → a → b → c → after`),
|
|
255
|
+
* not per scenario — per-scenario setup is the scenario's own `before`. That
|
|
256
|
+
* is the one thing a feature deliberately cannot express: gherkin's
|
|
257
|
+
* `Background:` runs per scenario.
|
|
258
|
+
*/
|
|
259
|
+
export type CoreFeature = {
|
|
260
|
+
/** Human-readable name. The export identifier is the id. */
|
|
261
|
+
name: string;
|
|
262
|
+
description?: string;
|
|
263
|
+
tags?: string[];
|
|
264
|
+
/** Readonly because `pikkuFeature`'s `const` generic infers a readonly tuple. */
|
|
265
|
+
scenarios: readonly CoreFeatureScenario[];
|
|
266
|
+
before?: CorePikkuFunctionHook;
|
|
267
|
+
after?: CorePikkuFunctionHook;
|
|
268
|
+
};
|
|
269
|
+
/** One entry of a feature's scenario list, as extracted from the source. */
|
|
270
|
+
export type FeatureMetaEntry = {
|
|
271
|
+
/** The scenario's declared export name — its key in `WorkflowsMeta`. */
|
|
272
|
+
scenario: string;
|
|
273
|
+
/** The input this entry runs the scenario with — gherkin's `Examples:`. */
|
|
274
|
+
data?: unknown;
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* A feature as the console reads it: the document structure around a group of
|
|
278
|
+
* scenarios. Generated to `scenarios/features.gen.json` and read off disk, so
|
|
279
|
+
* nothing app-facing has to import the scenario bootstrap to describe one.
|
|
280
|
+
*/
|
|
281
|
+
export type FeatureMeta = {
|
|
282
|
+
/** The export identifier. */
|
|
283
|
+
id: string;
|
|
284
|
+
name: string;
|
|
285
|
+
description?: string;
|
|
286
|
+
tags: string[];
|
|
287
|
+
/** In declared order — a feature's reading order is its declaration order. */
|
|
288
|
+
entries: FeatureMetaEntry[];
|
|
289
|
+
/**
|
|
290
|
+
* Entries that could not be read statically (a spread, a `.map()`). Their
|
|
291
|
+
* membership is only known once the scenario bootstrap has been evaluated,
|
|
292
|
+
* so a non-zero count means this listing is partial.
|
|
293
|
+
*/
|
|
294
|
+
unresolvedEntries: number;
|
|
295
|
+
/** Hooks are runtime-only; only their presence is knowable from meta. */
|
|
296
|
+
hasBefore: boolean;
|
|
297
|
+
hasAfter: boolean;
|
|
298
|
+
};
|
|
299
|
+
export type FeaturesMeta = Record<string, FeatureMeta>;
|
|
300
|
+
/** One planned scenario run, resolved from a feature's scenario list. */
|
|
301
|
+
export type FeaturePlanEntry = {
|
|
302
|
+
featureId: string;
|
|
303
|
+
featureName: string;
|
|
304
|
+
scenarioName: string;
|
|
305
|
+
/** The input this entry runs the scenario with, if the feature supplied one. */
|
|
306
|
+
data?: unknown;
|
|
307
|
+
/** The scenario's own tags unioned with the containing feature's. */
|
|
308
|
+
tags: string[];
|
|
309
|
+
};
|
|
238
310
|
/**
|
|
239
311
|
* Workflow client interface
|
|
240
312
|
*/
|
|
@@ -274,6 +346,13 @@ export type WorkflowsMeta = Record<string, CommonWireMeta & {
|
|
|
274
346
|
expose?: boolean;
|
|
275
347
|
/** True for pikkuScenario workflows (complex + actor steps). */
|
|
276
348
|
scenario?: boolean;
|
|
349
|
+
/**
|
|
350
|
+
* Why a scenario is held out of a default run. Stating the reason in code
|
|
351
|
+
* keeps the quarantine next to the scenario it applies to, rather than in
|
|
352
|
+
* a CI invocation nobody reads. Naming the scenario with `--flows` runs it
|
|
353
|
+
* regardless.
|
|
354
|
+
*/
|
|
355
|
+
skip?: string;
|
|
277
356
|
/** Actor names a scenario declares (personas it runs steps as). */
|
|
278
357
|
actors?: string[];
|
|
279
358
|
}>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.70",
|
|
4
|
+
"description": "The Pikku runtime — functions, wirings, services, middleware and types",
|
|
4
5
|
"author": "yasser.fadl@gmail.com",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"module": "dist/index.js",
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"./function": "./dist/function/index.js",
|
|
23
24
|
"./channel": "./dist/wirings/channel/index.js",
|
|
24
25
|
"./workflow": "./dist/wirings/workflow/index.js",
|
|
26
|
+
"./scenario": "./dist/wirings/workflow/pikku-scenario-service.js",
|
|
25
27
|
"./workflow/timeline": "./dist/wirings/workflow/run-timeline.js",
|
|
26
28
|
"./workflow/types": "./dist/wirings/workflow/workflow.types.js",
|
|
27
29
|
"./actor-flow": "./dist/wirings/actor-flow/index.js",
|
|
@@ -248,6 +248,17 @@ export type CorePermissionGroup<PikkuPermission = CorePikkuPermission<any>> =
|
|
|
248
248
|
| Record<string, PikkuPermission | PikkuPermission[]>
|
|
249
249
|
| undefined
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* A lifecycle hook: the same call signature as the function it hangs off, but
|
|
253
|
+
* its return value is discarded. A hook is setup/teardown, not a step — it has
|
|
254
|
+
* no id, no meta and no schema, so it is never recorded and never replayed.
|
|
255
|
+
*/
|
|
256
|
+
export type CorePikkuFunctionHook<Services = any, Data = any, Wire = any> = (
|
|
257
|
+
services: Services,
|
|
258
|
+
data: Data,
|
|
259
|
+
wire: Wire
|
|
260
|
+
) => Promise<void> | void
|
|
261
|
+
|
|
251
262
|
export type CorePikkuFunctionConfig<
|
|
252
263
|
PikkuFunction extends
|
|
253
264
|
| CorePikkuFunction<any, any, any, any, any>
|
|
@@ -282,6 +293,8 @@ export type CorePikkuFunctionConfig<
|
|
|
282
293
|
workflowRetries?: number
|
|
283
294
|
/** Timeout for this function when used as a workflow step (e.g. '30s', '5m'). */
|
|
284
295
|
workflowTimeout?: string
|
|
296
|
+
/** Scenario steps only: this step drives a browser, so the runner must provision one before calling it. */
|
|
297
|
+
browser?: boolean
|
|
285
298
|
audit?:
|
|
286
299
|
| boolean
|
|
287
300
|
| {
|
|
@@ -289,6 +302,25 @@ export type CorePikkuFunctionConfig<
|
|
|
289
302
|
}
|
|
290
303
|
approvalDescription?: any
|
|
291
304
|
func: PikkuFunction
|
|
305
|
+
/**
|
|
306
|
+
* Scenarios only: runs before the scenario body, with the scenario's own
|
|
307
|
+
* signature. Throwing skips the body and fails the run, but `after` still
|
|
308
|
+
* runs.
|
|
309
|
+
*/
|
|
310
|
+
before?: CorePikkuFunctionHook
|
|
311
|
+
/**
|
|
312
|
+
* Scenarios only: always runs after the scenario body, in a `finally`.
|
|
313
|
+
* Throwing fails a run that would otherwise have passed; on an
|
|
314
|
+
* already-failed run it attaches as the `cause` and never replaces the
|
|
315
|
+
* original error.
|
|
316
|
+
*/
|
|
317
|
+
after?: CorePikkuFunctionHook
|
|
318
|
+
/**
|
|
319
|
+
* Scenarios only: why this scenario is held out of a default run. It is
|
|
320
|
+
* reported as skipped rather than quietly omitted, and naming it directly
|
|
321
|
+
* with `--flows` runs it anyway.
|
|
322
|
+
*/
|
|
323
|
+
skip?: string
|
|
292
324
|
auth?: boolean
|
|
293
325
|
/**
|
|
294
326
|
* Scopes the session must hold to run this function. All of them are
|
package/src/index.ts
CHANGED
package/src/internal.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
pikkuState,
|
|
3
|
+
resetPikkuState,
|
|
4
|
+
getAllPackageStates,
|
|
5
|
+
} from './pikku-state.js'
|
|
2
6
|
export { httpRouter } from './wirings/http/routers/http-router.js'
|
|
3
7
|
export type {
|
|
4
8
|
CreateSingletonServices,
|
package/src/pikku-state.ts
CHANGED
|
@@ -38,11 +38,27 @@ const startTarget = async () => {
|
|
|
38
38
|
res.writeHead(401).end()
|
|
39
39
|
return
|
|
40
40
|
}
|
|
41
|
+
const rpcName = req.url.slice('/api/rpc/'.length)
|
|
42
|
+
if (rpcName === 'html-error') {
|
|
43
|
+
res
|
|
44
|
+
.writeHead(500, { 'content-type': 'text/html' })
|
|
45
|
+
.end('<html><body>Gateway blew up</body></html>')
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (rpcName === 'forbidden') {
|
|
49
|
+
res
|
|
50
|
+
.writeHead(403, { 'content-type': 'application/json' })
|
|
51
|
+
.end(
|
|
52
|
+
JSON.stringify({ message: 'MissingScopeError', scope: 'admin' })
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
41
56
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
42
57
|
JSON.stringify({
|
|
43
|
-
rpcName
|
|
58
|
+
rpcName,
|
|
44
59
|
echoed: body.data,
|
|
45
60
|
cookie,
|
|
61
|
+
userHeader: req.headers['x-user-id'] ?? null,
|
|
46
62
|
})
|
|
47
63
|
)
|
|
48
64
|
return
|
|
@@ -115,6 +131,74 @@ describe('HttpScenarioActor', async () => {
|
|
|
115
131
|
assert.match(result.cookie, new RegExp(`session=s${loginsBefore + 1}`))
|
|
116
132
|
})
|
|
117
133
|
|
|
134
|
+
test('invokeRaw returns the status and body instead of throwing', async () => {
|
|
135
|
+
const actors = makeActors()
|
|
136
|
+
|
|
137
|
+
// A refusal is the expected outcome of a permissions scenario, so the
|
|
138
|
+
// status and the payload that names the missing scope both have to survive.
|
|
139
|
+
const res = await actors.customer!.invokeRaw('forbidden', {})
|
|
140
|
+
|
|
141
|
+
assert.equal(res.status, 403)
|
|
142
|
+
assert.equal(res.ok, false)
|
|
143
|
+
assert.deepEqual(res.body, {
|
|
144
|
+
message: 'MissingScopeError',
|
|
145
|
+
scope: 'admin',
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('invokeRaw reports a success the same way', async () => {
|
|
150
|
+
const actors = makeActors()
|
|
151
|
+
const res = await actors.manager!.invokeRaw('listTodos', { page: 2 })
|
|
152
|
+
|
|
153
|
+
assert.equal(res.status, 200)
|
|
154
|
+
assert.equal(res.ok, true)
|
|
155
|
+
assert.deepEqual((res.body as any).echoed, { page: 2 })
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('invokeRaw carries the response text so a step can search it', async () => {
|
|
159
|
+
const actors = makeActors()
|
|
160
|
+
const res = await actors.customer!.invokeRaw('forbidden', {})
|
|
161
|
+
|
|
162
|
+
assert.match(res.serialized, /MissingScopeError/)
|
|
163
|
+
assert.match(res.serialized, /admin/)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('invokeRaw keeps a non-JSON error body instead of failing to parse it', async () => {
|
|
167
|
+
const actors = makeActors()
|
|
168
|
+
const res = await actors.manager!.invokeRaw('html-error', {})
|
|
169
|
+
|
|
170
|
+
assert.equal(res.status, 500)
|
|
171
|
+
assert.equal(res.ok, false)
|
|
172
|
+
assert.equal(res.body, '<html><body>Gateway blew up</body></html>')
|
|
173
|
+
assert.match(res.serialized, /Gateway blew up/)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('invokeRaw reports an empty body as an empty string', async () => {
|
|
177
|
+
const actors = makeActors()
|
|
178
|
+
const res = await actors.manager!.invokeRaw('listTodos', {})
|
|
179
|
+
|
|
180
|
+
assert.equal(typeof res.serialized, 'string')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('invokeRaw passes extra headers through', async () => {
|
|
184
|
+
const actors = makeActors()
|
|
185
|
+
const res = await actors.manager!.invokeRaw(
|
|
186
|
+
'whoAmI',
|
|
187
|
+
{},
|
|
188
|
+
{ headers: { 'x-user-id': 'impersonated-1' } }
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
assert.equal((res.body as any).userHeader, 'impersonated-1')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('invoke still throws on a refusal, naming the status and body', async () => {
|
|
195
|
+
const actors = makeActors()
|
|
196
|
+
await assert.rejects(
|
|
197
|
+
actors.customer!.invoke('forbidden', {}),
|
|
198
|
+
/'forbidden' as 'customer' returned 403.*MissingScopeError/
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
118
202
|
test('a wrong impersonation secret surfaces status and body', async () => {
|
|
119
203
|
const actors = makeActors('wrong-secret')
|
|
120
204
|
await assert.rejects(
|
|
@@ -2,13 +2,20 @@ import type {
|
|
|
2
2
|
ScenarioActor,
|
|
3
3
|
ScenarioActorConfig,
|
|
4
4
|
ScenarioActors,
|
|
5
|
+
ScenarioInvokeOptions,
|
|
6
|
+
ScenarioHttpResponse,
|
|
5
7
|
} from './scenario-actors-service.js'
|
|
8
|
+
import { readScenarioHttpResponse } from './scenario-actors-service.js'
|
|
6
9
|
import type {
|
|
7
10
|
ConverseOptions,
|
|
8
11
|
ActorFlowVerdict,
|
|
9
12
|
TargetAgentReply,
|
|
10
13
|
} from '../wirings/actor-flow/actor-flow.types.js'
|
|
11
14
|
import { runConversation } from '../wirings/actor-flow/run-conversation.js'
|
|
15
|
+
import {
|
|
16
|
+
createCookieJar,
|
|
17
|
+
type ScenarioCookieJar,
|
|
18
|
+
} from '../wirings/workflow/scenario-cookie-jar.js'
|
|
12
19
|
import { getSingletonServices } from '../pikku-state.js'
|
|
13
20
|
import { AIProviderNotConfiguredError } from '../errors/errors.js'
|
|
14
21
|
|
|
@@ -48,15 +55,21 @@ export interface HttpScenarioActorsConfig {
|
|
|
48
55
|
* outlive a session).
|
|
49
56
|
*/
|
|
50
57
|
export class HttpScenarioActor implements ScenarioActor {
|
|
51
|
-
private
|
|
52
|
-
|
|
58
|
+
private jar: ScenarioCookieJar
|
|
59
|
+
/**
|
|
60
|
+
* Whether `login()` has succeeded since the last time the session was
|
|
61
|
+
* dropped. The jar cannot answer this — a target may set a cookie before
|
|
62
|
+
* anyone signs in, and it would then look like a session that was never
|
|
63
|
+
* established.
|
|
64
|
+
*/
|
|
65
|
+
private signedIn = false
|
|
53
66
|
|
|
54
67
|
constructor(
|
|
55
68
|
readonly name: string,
|
|
56
69
|
private actorConfig: ScenarioActorConfig,
|
|
57
70
|
private config: HttpScenarioActorsConfig
|
|
58
71
|
) {
|
|
59
|
-
this.
|
|
72
|
+
this.jar = createCookieJar(config.apiUrl)
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
get email(): string {
|
|
@@ -64,17 +77,31 @@ export class HttpScenarioActor implements ScenarioActor {
|
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
async invoke(rpcName: string, data: unknown): Promise<unknown> {
|
|
67
|
-
const
|
|
68
|
-
|
|
80
|
+
const res = await this.invokeRaw(rpcName, data)
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`[scenario] '${rpcName}' as '${this.name}' returned ${res.status}: ${res.serialized.slice(0, 300)}`
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
return res.body
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async invokeRaw(
|
|
90
|
+
rpcName: string,
|
|
91
|
+
data: unknown,
|
|
92
|
+
options?: ScenarioInvokeOptions
|
|
93
|
+
): Promise<ScenarioHttpResponse> {
|
|
94
|
+
if (!this.signedIn) {
|
|
95
|
+
await this.login()
|
|
96
|
+
}
|
|
97
|
+
let res = await this.postRpc(rpcName, data, options?.headers)
|
|
69
98
|
if (res.status === 401) {
|
|
70
99
|
// Session expired mid-run — re-login once and retry.
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
await this.postRpc(rpcName, data, await this.login())
|
|
75
|
-
)
|
|
100
|
+
this.signOut()
|
|
101
|
+
await this.login()
|
|
102
|
+
res = await this.postRpc(rpcName, data, options?.headers)
|
|
76
103
|
}
|
|
77
|
-
return
|
|
104
|
+
return readScenarioHttpResponse(res)
|
|
78
105
|
}
|
|
79
106
|
|
|
80
107
|
async converse(options: ConverseOptions): Promise<ActorFlowVerdict> {
|
|
@@ -92,8 +119,8 @@ export class HttpScenarioActor implements ScenarioActor {
|
|
|
92
119
|
const resourceId = `actor:${this.name}`
|
|
93
120
|
|
|
94
121
|
return runConversation({
|
|
95
|
-
|
|
96
|
-
|
|
122
|
+
actor: this.actorConfig,
|
|
123
|
+
actorName: this.actorConfig.name ?? this.name,
|
|
97
124
|
agentName: options.agent,
|
|
98
125
|
task: options.task,
|
|
99
126
|
evaluate: options.evaluate,
|
|
@@ -148,21 +175,18 @@ export class HttpScenarioActor implements ScenarioActor {
|
|
|
148
175
|
private async postAgent(subPath: string, body: unknown): Promise<unknown> {
|
|
149
176
|
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
150
177
|
const url = `${this.config.apiUrl}${rpcPath}/${subPath}`
|
|
151
|
-
const send = (
|
|
152
|
-
fetch(url, {
|
|
178
|
+
const send = () =>
|
|
179
|
+
this.jar.fetch(url, {
|
|
153
180
|
method: 'POST',
|
|
154
|
-
headers: {
|
|
155
|
-
'content-type': 'application/json',
|
|
156
|
-
origin: this.origin,
|
|
157
|
-
...(cookie ? { cookie } : {}),
|
|
158
|
-
},
|
|
181
|
+
headers: { 'content-type': 'application/json' },
|
|
159
182
|
body: JSON.stringify(body),
|
|
160
183
|
})
|
|
161
184
|
|
|
162
|
-
let res = await send(
|
|
185
|
+
let res = await send()
|
|
163
186
|
if (res.status === 401) {
|
|
164
|
-
this.
|
|
165
|
-
|
|
187
|
+
this.signOut()
|
|
188
|
+
await this.login()
|
|
189
|
+
res = await send()
|
|
166
190
|
}
|
|
167
191
|
if (!res.ok) {
|
|
168
192
|
const text = (await res.text().catch(() => '')).slice(0, 300)
|
|
@@ -175,36 +199,30 @@ export class HttpScenarioActor implements ScenarioActor {
|
|
|
175
199
|
return text ? JSON.parse(text) : undefined
|
|
176
200
|
}
|
|
177
201
|
|
|
178
|
-
private async postRpc(
|
|
202
|
+
private async postRpc(
|
|
203
|
+
rpcName: string,
|
|
204
|
+
data: unknown,
|
|
205
|
+
extraHeaders?: Record<string, string>
|
|
206
|
+
) {
|
|
179
207
|
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
180
|
-
return fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
|
|
208
|
+
return this.jar.fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
|
|
181
209
|
method: 'POST',
|
|
182
|
-
headers: {
|
|
183
|
-
'content-type': 'application/json',
|
|
184
|
-
origin: this.origin,
|
|
185
|
-
cookie,
|
|
186
|
-
},
|
|
210
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
187
211
|
body: JSON.stringify({ data }),
|
|
188
212
|
})
|
|
189
213
|
}
|
|
190
214
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
`[scenario] '${rpcName}' as '${this.name}' returned ${res.status}: ${body}`
|
|
196
|
-
)
|
|
197
|
-
}
|
|
198
|
-
if (res.status === 204) return undefined
|
|
199
|
-
const text = await res.text()
|
|
200
|
-
return text ? JSON.parse(text) : undefined
|
|
215
|
+
/** Drop the session, so the next call signs in again before it goes out. */
|
|
216
|
+
private signOut(): void {
|
|
217
|
+
this.jar.clear()
|
|
218
|
+
this.signedIn = false
|
|
201
219
|
}
|
|
202
220
|
|
|
203
|
-
private async login(): Promise<
|
|
221
|
+
private async login(): Promise<void> {
|
|
204
222
|
const signInPath = this.config.signInPath ?? '/auth/sign-in/actor'
|
|
205
|
-
const res = await fetch(`${this.config.apiUrl}${signInPath}`, {
|
|
223
|
+
const res = await this.jar.fetch(`${this.config.apiUrl}${signInPath}`, {
|
|
206
224
|
method: 'POST',
|
|
207
|
-
headers: { 'content-type': 'application/json'
|
|
225
|
+
headers: { 'content-type': 'application/json' },
|
|
208
226
|
body: JSON.stringify({
|
|
209
227
|
email: this.actorConfig.email,
|
|
210
228
|
name: this.actorConfig.name ?? this.name,
|
|
@@ -217,18 +235,14 @@ export class HttpScenarioActor implements ScenarioActor {
|
|
|
217
235
|
`[scenario] actor sign-in failed for '${this.name}' (${res.status}): ${body}`
|
|
218
236
|
)
|
|
219
237
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
.filter(Boolean)
|
|
224
|
-
.join('; ')
|
|
225
|
-
if (!cookie) {
|
|
238
|
+
// What proves a session was established is this response setting a cookie,
|
|
239
|
+
// not the jar being non-empty — the target may have set one earlier.
|
|
240
|
+
if (res.headers.getSetCookie().length === 0) {
|
|
226
241
|
throw new Error(
|
|
227
242
|
`[scenario] actor sign-in for '${this.name}' returned no session cookie`
|
|
228
243
|
)
|
|
229
244
|
}
|
|
230
|
-
this.
|
|
231
|
-
return cookie
|
|
245
|
+
this.signedIn = true
|
|
232
246
|
}
|
|
233
247
|
}
|
|
234
248
|
|
package/src/services/index.ts
CHANGED
|
@@ -43,8 +43,13 @@ export type {
|
|
|
43
43
|
export type {
|
|
44
44
|
ScenarioActor,
|
|
45
45
|
ScenarioActorConfig,
|
|
46
|
+
ScenarioActorOf,
|
|
46
47
|
ScenarioActors,
|
|
48
|
+
ScenarioInvokeOptions,
|
|
49
|
+
ScenarioRpcMap,
|
|
50
|
+
ScenarioHttpResponse,
|
|
47
51
|
} from './scenario-actors-service.js'
|
|
52
|
+
export { readScenarioHttpResponse } from './scenario-actors-service.js'
|
|
48
53
|
export {
|
|
49
54
|
HttpScenarioActor,
|
|
50
55
|
createHttpScenarioActors,
|