@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/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
- }: { cwd: string; files: string[]; line?: number; dryRun?: boolean; isBug: (err: unknown) => boolean },
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: { ...library, World } as ISupportCodeLibrary,
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
- const drivers = indexDrivers([...Array.from(fakes.values(), (f) => f.driver), ...sandboxDrivers], state.ops);
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
- const context = { idempotencyKey: `${runId}:${records.length}`, runId, opId: id, attempt: 1, secrets };
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 }