@sanoma-ai/workflows 0.9.0 → 0.9.1
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/README.md +2 -2
- package/dist/apply.js +8 -8
- package/dist/apply.js.map +1 -1
- package/dist/config.d.ts +5 -3
- package/dist/config.js +10 -5
- package/dist/config.js.map +1 -1
- package/dist/cucumber.d.ts +4 -2
- package/dist/cucumber.js +8 -3
- package/dist/cucumber.js.map +1 -1
- package/dist/resource.d.ts +17 -0
- package/dist/resource.js +38 -0
- package/dist/resource.js.map +1 -1
- package/dist/sandbox.js +2 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +7 -0
- package/dist/scenario.js +33 -3
- package/dist/scenario.js.map +1 -1
- package/dist/source.d.ts +6 -0
- package/dist/source.js +13 -0
- package/dist/source.js.map +1 -1
- package/dist/steps.d.ts +3 -1
- package/dist/steps.js.map +1 -1
- package/package.json +2 -2
- package/src/apply.ts +16 -8
- package/src/config.ts +10 -5
- package/src/cucumber.ts +16 -3
- package/src/resource.ts +43 -0
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +45 -3
- package/src/source.ts +14 -0
- package/src/steps.ts +3 -1
package/src/cucumber.ts
CHANGED
|
@@ -173,7 +173,8 @@ const literal = (path: string) => path.replaceAll(/[*?[\]()+@]/g, "[$&]").replac
|
|
|
173
173
|
* examples row at that line. `makeWorld` makes each scenario's World, given the report so far
|
|
174
174
|
* (every file and pickle, read before the first scenario starts). A dry run matches the steps
|
|
175
175
|
* and runs none. A step's error that is a bug in code (`isBug`) is rethrown once the run is
|
|
176
|
-
* over, rather than reported as the step's failure.
|
|
176
|
+
* over, rather than reported as the step's failure. `timeoutMs` is how long a step may take
|
|
177
|
+
* (Cucumber's 5 s by default).
|
|
177
178
|
*/
|
|
178
179
|
export async function runFeatures(
|
|
179
180
|
library: ISupportCodeLibrary,
|
|
@@ -184,7 +185,15 @@ export async function runFeatures(
|
|
|
184
185
|
line,
|
|
185
186
|
dryRun = false,
|
|
186
187
|
isBug,
|
|
187
|
-
|
|
188
|
+
timeoutMs,
|
|
189
|
+
}: {
|
|
190
|
+
cwd: string;
|
|
191
|
+
files: string[];
|
|
192
|
+
line?: number;
|
|
193
|
+
dryRun?: boolean;
|
|
194
|
+
isBug: (err: unknown) => boolean;
|
|
195
|
+
timeoutMs?: number;
|
|
196
|
+
},
|
|
188
197
|
): Promise<Report> {
|
|
189
198
|
const report: Report = { query: new Query(), texts: new Map(), parseErrors: [] };
|
|
190
199
|
let bug: unknown;
|
|
@@ -200,7 +209,11 @@ export async function runFeatures(
|
|
|
200
209
|
}
|
|
201
210
|
const configuration: IRunOptions = {
|
|
202
211
|
sources: { defaultDialect: "en", paths: files.map(literal), names: [], tagExpression: "", order: "defined" },
|
|
203
|
-
support: {
|
|
212
|
+
support: {
|
|
213
|
+
...library,
|
|
214
|
+
World,
|
|
215
|
+
...(timeoutMs !== undefined && { defaultTimeout: timeoutMs }),
|
|
216
|
+
} as ISupportCodeLibrary,
|
|
204
217
|
runtime: {
|
|
205
218
|
dryRun,
|
|
206
219
|
failFast: false,
|
package/src/resource.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { checkDatabase, type DatabaseSpec } from "./database.ts";
|
|
3
|
+
import { isRunControlError } from "./errors.ts";
|
|
3
4
|
import { checkHost, type HostSpec } from "./host.ts";
|
|
4
5
|
import { type Connector, type OpSpec, RESOURCE, VENDOR } from "./op.ts";
|
|
5
6
|
import { isSanomaSecret, isSecret, type Secret } from "./secret.ts";
|
|
7
|
+
import { errorMessage } from "./shared.ts";
|
|
6
8
|
import { checkWakeup, type WakeupSpec } from "./wakeup.ts";
|
|
7
9
|
|
|
8
10
|
/** Which fields of a resource are flagged, by dotted path (`pages.html_url`; a list's items have no index). */
|
|
@@ -123,6 +125,47 @@ export interface AfterCreate {
|
|
|
123
125
|
until?: (output: any) => boolean;
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
/** How long apply waits between calls of an `afterCreate` operation with `until`, and how many calls it makes. */
|
|
129
|
+
export const AFTER_CREATE_SECONDS = 5;
|
|
130
|
+
export const AFTER_CREATE_CALLS = 120;
|
|
131
|
+
/** How long an `afterCreate` operation may be polled, in all, in milliseconds. */
|
|
132
|
+
export const AFTER_CREATE_MS = AFTER_CREATE_SECONDS * AFTER_CREATE_CALLS * 1000;
|
|
133
|
+
|
|
134
|
+
/** Why an `afterCreate` operation with `until` did not pass: the time it was given, and its last call's error if that failed. */
|
|
135
|
+
export const afterCreateStuck = (op: string, failed: unknown): string =>
|
|
136
|
+
`${op} did not pass in ${AFTER_CREATE_MS / 60_000} minutes` +
|
|
137
|
+
(failed === undefined ? "" : `; its last call failed: ${errorMessage(failed)}`);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Calls one `afterCreate` operation for a new object as apply does: with `until`, again every
|
|
141
|
+
* few seconds until its output passes, a call that fails meanwhile being one that did not pass;
|
|
142
|
+
* without, once, a failure thrown. Returns why it did not pass, if it did not. Apply inlines the
|
|
143
|
+
* same loop in its `run`, which may not close over its `ctx`; an operation's own test calls this.
|
|
144
|
+
*/
|
|
145
|
+
export async function callAfterCreate(
|
|
146
|
+
hook: AfterCreate,
|
|
147
|
+
made: { id: string; export: string },
|
|
148
|
+
call: (op: string, input: unknown) => Promise<unknown>,
|
|
149
|
+
sleep: (seconds: number) => Promise<void>,
|
|
150
|
+
): Promise<string | undefined> {
|
|
151
|
+
const given = hook.input(made);
|
|
152
|
+
let output: unknown;
|
|
153
|
+
let failed: unknown;
|
|
154
|
+
for (let n = 0; n < AFTER_CREATE_CALLS && (n === 0 || (hook.until && !hook.until(output))); n++) {
|
|
155
|
+
if (n > 0) await sleep(AFTER_CREATE_SECONDS);
|
|
156
|
+
try {
|
|
157
|
+
output = await call(hook.op, given);
|
|
158
|
+
failed = undefined;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (isRunControlError(err) || !hook.until) throw err;
|
|
161
|
+
output = undefined;
|
|
162
|
+
failed = err;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (hook.until && !hook.until(output)) return afterCreateStuck(hook.op, failed);
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
126
169
|
/**
|
|
127
170
|
* Where a declared resource keeps its brand, its type's `<vendor>.<type>`: a symbol, so a field
|
|
128
171
|
* holding a resource is told from data; a registered one (`Symbol.for`), so a copy of this
|
package/src/sandbox.ts
CHANGED
|
@@ -109,7 +109,11 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
|
109
109
|
});
|
|
110
110
|
}
|
|
111
111
|
const fakes = new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
|
|
112
|
-
|
|
112
|
+
// The built-ins first, so a connector's fake that implements one of their operations wins.
|
|
113
|
+
const drivers = indexDrivers(
|
|
114
|
+
[...sandboxDrivers(state.ops), ...Array.from(fakes.values(), (f) => f.driver)],
|
|
115
|
+
state.ops,
|
|
116
|
+
);
|
|
113
117
|
const uses = opIdsOf(state.workflows.get(run.workflow)!);
|
|
114
118
|
const seeding = scenario.given.flatMap((g) => ("op" in g ? [g.op] : []));
|
|
115
119
|
const missing = [...new Set([...uses, ...seeding])].filter((id) => !drivers.has(id));
|
package/src/scenario.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type { LedgerRecord } from "./ledger.ts";
|
|
|
34
34
|
import type { Driver, Op } from "./op.ts";
|
|
35
35
|
import { type OutlineNode, outlineWorkflow } from "./outline.ts";
|
|
36
36
|
import { isDirectory, kindDirs, SCENARIOS_DIR } from "./paths.ts";
|
|
37
|
-
import { isObject } from "./resource.ts";
|
|
37
|
+
import { AFTER_CREATE_MS, callAfterCreate, isObject } from "./resource.ts";
|
|
38
38
|
import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
|
|
39
39
|
import { memorySecrets } from "./secret.ts";
|
|
40
40
|
import {
|
|
@@ -903,11 +903,19 @@ function viewOf(records: readonly LedgerRecord[]): RunView {
|
|
|
903
903
|
return { records, calls, seeded, made: (op) => [...seeded(op), ...calls(op)] };
|
|
904
904
|
}
|
|
905
905
|
|
|
906
|
+
/** How long a step of an operation's own test may take live: a `Given` may seed an object as apply makes it, its `afterCreate` polled. */
|
|
907
|
+
export const FEATURE_STEP_MS = AFTER_CREATE_MS + 60_000;
|
|
908
|
+
|
|
906
909
|
/** Where an operation's own test makes its calls: an operation's driver, live, replayed or a fake's, and the fake. */
|
|
907
910
|
export interface FeatureTarget {
|
|
908
911
|
driver: Driver;
|
|
909
912
|
/** The fake whose driver it is: a scenario's faults need one. */
|
|
910
913
|
fake?: Fake<any, any>;
|
|
914
|
+
/**
|
|
915
|
+
* The driver calls the vendor: a seed's `afterCreate` waits between its calls as apply's do,
|
|
916
|
+
* and a step may take `FEATURE_STEP_MS`. Replayed or on a fake, the calls answer at once.
|
|
917
|
+
*/
|
|
918
|
+
live?: boolean;
|
|
911
919
|
}
|
|
912
920
|
|
|
913
921
|
/** Why no step, or two, match a step's text. */
|
|
@@ -1030,12 +1038,13 @@ function checkWorld(built: Built, workflow: string, view: RunView, verdicts: Map
|
|
|
1030
1038
|
* A World for an operation's own test: `Given` steps seed through the target, `When` steps call
|
|
1031
1039
|
* its operations directly (no worker, no policy), and `Then` steps fail when they do not hold.
|
|
1032
1040
|
*/
|
|
1033
|
-
function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
1041
|
+
function testWorld(built: Built, { driver, fake, live }: FeatureTarget) {
|
|
1034
1042
|
return (report: Report): SanomaWorld => {
|
|
1035
1043
|
// What the steps named (`as`), and the calls made, as a run's ledger would hold them.
|
|
1036
1044
|
const values = new Map<string, unknown>();
|
|
1037
1045
|
const records: LedgerRecord[] = [];
|
|
1038
1046
|
let runId = "";
|
|
1047
|
+
let calls = 0;
|
|
1039
1048
|
// The test's own secrets, in memory: a driver resolves `secret()` markers against them.
|
|
1040
1049
|
const secrets = memorySecrets();
|
|
1041
1050
|
/** A record of the test, as a run's ledger would hold it. */
|
|
@@ -1051,7 +1060,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
1051
1060
|
const input = op.input.parse(resolve(given, values));
|
|
1052
1061
|
const fn = driver.ops[`${op.resource}.${op.name}`];
|
|
1053
1062
|
if (!fn) throw new Error(`the driver under test does not implement ${id}`);
|
|
1054
|
-
|
|
1063
|
+
// A key of its own per call: a fake answers a repeated key with its remembered reply.
|
|
1064
|
+
const context = { idempotencyKey: `${runId}:${++calls}`, runId, opId: id, attempt: 1, secrets };
|
|
1055
1065
|
return { op, input, output: () => fn(input, context) };
|
|
1056
1066
|
};
|
|
1057
1067
|
return {
|
|
@@ -1076,6 +1086,37 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
1076
1086
|
const output = await made.output();
|
|
1077
1087
|
if (item.as !== undefined) values.set(item.as, output);
|
|
1078
1088
|
seeds.push({ op: item.op, input: made.input, output });
|
|
1089
|
+
// A resource type's create seeds the object as apply makes it: the type's `afterCreate`
|
|
1090
|
+
// calls follow, each polled as apply polls it, the seed's name (else the type's) its export.
|
|
1091
|
+
const type =
|
|
1092
|
+
made.op.name === "create" ? built.scope.types.get(`${made.op.vendor}.${made.op.resource}`) : undefined;
|
|
1093
|
+
const id = (output as { id?: unknown } | undefined)?.id;
|
|
1094
|
+
if (type?.afterCreate?.length && typeof id !== "string") {
|
|
1095
|
+
throw new Error(`"${text}": ${item.op} returned no id for its afterCreate`);
|
|
1096
|
+
}
|
|
1097
|
+
for (const hook of type?.afterCreate ?? []) {
|
|
1098
|
+
// One the target cannot call fails at once: polled, its error would read as not passing yet.
|
|
1099
|
+
const polled = built.scope.ops.get(hook.op);
|
|
1100
|
+
if (!polled || !driver.ops[`${polled.resource}.${polled.name}`]) {
|
|
1101
|
+
throw new Error(
|
|
1102
|
+
`"${text}": the driver under test does not implement ${hook.op}, an afterCreate of ${item.op}`,
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
const stuck = await callAfterCreate(
|
|
1106
|
+
hook,
|
|
1107
|
+
{ id: id as string, export: item.as ?? made.op.resource },
|
|
1108
|
+
async (op, input) => {
|
|
1109
|
+
const after = call(op, input);
|
|
1110
|
+
const out = await after.output();
|
|
1111
|
+
seeds.push({ op, input: after.input, output: out });
|
|
1112
|
+
return out;
|
|
1113
|
+
},
|
|
1114
|
+
async (seconds) => {
|
|
1115
|
+
if (live) await delay(seconds * 1000);
|
|
1116
|
+
},
|
|
1117
|
+
);
|
|
1118
|
+
if (stuck !== undefined) throw new Error(`"${text}": ${stuck}`);
|
|
1119
|
+
}
|
|
1079
1120
|
}
|
|
1080
1121
|
if (seeds.length) record({ type: "scenario.seeded", scenario: "", seeds });
|
|
1081
1122
|
return;
|
|
@@ -1442,6 +1483,7 @@ export async function testFeature(scope: Scope, file: string, line: number, targ
|
|
|
1442
1483
|
files: [name],
|
|
1443
1484
|
line,
|
|
1444
1485
|
isBug,
|
|
1486
|
+
timeoutMs: target.live ? FEATURE_STEP_MS : undefined,
|
|
1445
1487
|
});
|
|
1446
1488
|
if (report.parseErrors.length) throw new Error(report.parseErrors.map(parseErrorOf).join("\n"));
|
|
1447
1489
|
const results = stepResults(report);
|
package/src/source.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import type { PermissionsGrid } from "./grid.ts";
|
|
@@ -155,6 +156,19 @@ export const sourceDriver = defineDriver(sourceOps, {
|
|
|
155
156
|
|
|
156
157
|
const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
157
158
|
|
|
159
|
+
/**
|
|
160
|
+
* The fake of `sanoma.source.resolve`, which a sandbox run calls (`sandboxDrivers`): no git and no
|
|
161
|
+
* network, a full commit id is itself as the driver has it, and any other ref the SHA-1 of
|
|
162
|
+
* `<url>#<ref>`, so a scenario's revisions are the same on every run.
|
|
163
|
+
*/
|
|
164
|
+
export const fakeSourceDriver = defineDriver(sourceOps, {
|
|
165
|
+
source: {
|
|
166
|
+
resolve: async ({ url, ref }) => ({
|
|
167
|
+
revision: COMMIT.test(ref) ? ref : createHash("sha1").update(`${url}#${ref}`).digest("hex"),
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
158
172
|
/** The commit `ref` (a branch, a tag, `HEAD`, a full ref or a commit id) names in the repository at `url`. */
|
|
159
173
|
export async function resolveRevision(url: string, ref: string): Promise<string> {
|
|
160
174
|
if (COMMIT.test(ref)) return ref;
|
package/src/steps.ts
CHANGED
|
@@ -14,7 +14,9 @@ type AnyOp<I = any, O = any> = Op<string, string, string, I, O>;
|
|
|
14
14
|
* What a `Given` step sets up before the run: a call through an operation of the run's fake,
|
|
15
15
|
* with the fields of its input to set (the rest is made up), or a fault to inject into its next
|
|
16
16
|
* call. `as` names a call's output, so a later step's table of an operation's own test can use
|
|
17
|
-
* its fields: `$draft.id`.
|
|
17
|
+
* its fields: `$draft.id`. In an operation's own test, a resource type's `create` seeds the object
|
|
18
|
+
* as apply makes it: the type's `afterCreate` calls follow, each polled as apply polls it, the
|
|
19
|
+
* seed's name (else the type's) as the export.
|
|
18
20
|
*/
|
|
19
21
|
export type Seed =
|
|
20
22
|
| { op: AnyOp; input: Record<string, unknown>; as?: string }
|