@ripplo/testing 0.6.1 → 0.7.0

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.
Files changed (44) hide show
  1. package/DSL.md +355 -0
  2. package/LICENSE.md +1 -0
  3. package/README.md +47 -273
  4. package/dist/engine-BT7hUouB.d.ts +1095 -0
  5. package/dist/express.d.ts +7 -9
  6. package/dist/express.js +422 -48
  7. package/dist/index.d.ts +122 -59
  8. package/dist/index.js +1630 -1126
  9. package/package.json +31 -113
  10. package/dist/actions.d.ts +0 -260
  11. package/dist/actions.js +0 -177
  12. package/dist/assert.d.ts +0 -188
  13. package/dist/assert.js +0 -111
  14. package/dist/builder-SsgqYqSC.d.ts +0 -156
  15. package/dist/chunk-2YLI7VD4.js +0 -65
  16. package/dist/chunk-4MGIQFAJ.js +0 -16
  17. package/dist/chunk-DCJBLS2U.js +0 -26
  18. package/dist/chunk-MGATMMCZ.js +0 -16
  19. package/dist/chunk-XO36IU66.js +0 -88
  20. package/dist/chunk-YFOTJIVF.js +0 -134
  21. package/dist/chunk-YQAEOH5W.js +0 -111
  22. package/dist/compiler.d.ts +0 -32
  23. package/dist/compiler.js +0 -8
  24. package/dist/control.d.ts +0 -45
  25. package/dist/control.js +0 -17
  26. package/dist/elysia.d.ts +0 -78
  27. package/dist/elysia.js +0 -114
  28. package/dist/engine-BOqzK_go.d.ts +0 -61
  29. package/dist/fastify.d.ts +0 -14
  30. package/dist/fastify.js +0 -79
  31. package/dist/hono.d.ts +0 -19
  32. package/dist/hono.js +0 -89
  33. package/dist/koa.d.ts +0 -14
  34. package/dist/koa.js +0 -135
  35. package/dist/locators.d.ts +0 -40
  36. package/dist/locators.js +0 -11
  37. package/dist/lockfile.d.ts +0 -722
  38. package/dist/lockfile.js +0 -707
  39. package/dist/nestjs.d.ts +0 -17
  40. package/dist/nestjs.js +0 -139
  41. package/dist/nextjs.d.ts +0 -14
  42. package/dist/nextjs.js +0 -137
  43. package/dist/step-De52hTLd.d.ts +0 -19
  44. package/dist/types-BzZrl65Z.d.ts +0 -115
package/dist/index.js CHANGED
@@ -1,1279 +1,1783 @@
1
- import {
2
- DEFAULT_IGNORE_PATHS,
3
- DEFAULT_WATCH_PATHS,
4
- brandTestValue,
5
- isPrimitive,
6
- makeObserverHandle,
7
- readObserverBudget,
8
- readObserverDescription,
9
- readObserverName,
10
- readPreconditionDepMapping,
11
- readPreconditionDependsOn,
12
- readPreconditionDescription,
13
- readPreconditionName
14
- } from "./chunk-YQAEOH5W.js";
15
- import {
16
- compile
17
- } from "./chunk-YFOTJIVF.js";
18
- import "./chunk-MGATMMCZ.js";
19
- import {
20
- readAdapterWebhookSecret,
21
- serializeCookie,
22
- toBatchRunResults,
23
- toTeardownResults,
24
- verifyWebhookSignature
25
- } from "./chunk-XO36IU66.js";
26
- import "./chunk-4MGIQFAJ.js";
27
-
28
- // src/chainable.ts
29
- var BuilderChainError = class _BuilderChainError extends Error {
30
- missingMethod;
31
- validNext;
32
- constructor(missingMethod, validNext) {
33
- const valid = validNext.length === 0 ? "<chain is terminal>" : validNext.map((m) => `.${m}`).join(", ");
34
- super(`\`.${missingMethod}\` is not a valid step in this builder chain. Valid next: ${valid}.`);
35
- this.name = "BuilderChainError";
36
- this.missingMethod = missingMethod;
37
- this.validNext = validNext;
38
- if (typeof Error.captureStackTrace === "function") {
39
- Error.captureStackTrace(this, _BuilderChainError);
40
- }
1
+ // src/bindings.ts
2
+ function fieldBinding(descriptor, field2) {
3
+ return { __bind: { descriptor, field: field2, kind: "field" }, ref: "" };
4
+ }
5
+ function paramBinding(token) {
6
+ return { __bind: { kind: "param", token }, ref: "" };
7
+ }
8
+ function entityHandle(descriptor, fieldNames) {
9
+ const fields = Object.fromEntries(
10
+ fieldNames.map((field2) => [field2, fieldBinding(descriptor, field2)])
11
+ );
12
+ return { ...fields, __entity: descriptor };
13
+ }
14
+ function isBinding(value2) {
15
+ return typeof value2 === "object" && value2 != null && "__bind" in value2;
16
+ }
17
+ function toSetMap(props) {
18
+ return Object.fromEntries(
19
+ Object.entries(props).map(([key2, value2]) => [key2, toSetValue(value2)])
20
+ );
21
+ }
22
+ function toSetValue(value2) {
23
+ if (value2 === null) {
24
+ return null;
41
25
  }
42
- };
43
- var PASSTHROUGH_PROPS = /* @__PURE__ */ new Set([
44
- "then",
45
- "catch",
46
- "finally",
47
- "toJSON",
48
- "toString",
49
- "valueOf",
50
- "constructor",
51
- "nodeType"
52
- ]);
53
- function chainable(target) {
54
- return new Proxy(target, {
55
- get(t, prop, receiver) {
56
- if (Reflect.has(t, prop)) {
57
- return Reflect.get(t, prop, receiver);
58
- }
59
- if (typeof prop === "symbol") {
60
- return void 0;
61
- }
62
- if (PASSTHROUGH_PROPS.has(prop)) {
63
- return void 0;
64
- }
65
- const validNext = Object.keys(t).filter((k) => typeof Reflect.get(t, k) === "function");
66
- throw new BuilderChainError(prop, validNext);
67
- }
26
+ return isBinding(value2) ? value2 : asPrimitive(value2);
27
+ }
28
+ function isChanged(value2) {
29
+ return typeof value2 === "object" && value2 != null && "kind" in value2 && value2.kind === "changed";
30
+ }
31
+ function toUpdateMap(props) {
32
+ return Object.fromEntries(
33
+ Object.entries(props).map(([key2, value2]) => [
34
+ key2,
35
+ isChanged(value2) ? value2 : toSetValue(value2)
36
+ ])
37
+ );
38
+ }
39
+ function stringValueFromTemplate(strings, values) {
40
+ const segments = strings.flatMap((str, i) => {
41
+ const value2 = values[i];
42
+ const refs = value2 === void 0 ? [] : [value2];
43
+ return [str, ...refs];
68
44
  });
45
+ return { template: segments.filter((seg) => typeof seg !== "string" || seg.length > 0) };
46
+ }
47
+ function asPrimitive(value2) {
48
+ if (typeof value2 === "string" || typeof value2 === "number" || typeof value2 === "boolean") {
49
+ return value2;
50
+ }
51
+ throw new Error("setup set value must be a entity-sourced binding or a literal primitive");
69
52
  }
70
53
 
71
- // src/observer.ts
72
- function createPassOutcome() {
73
- return { kind: "pass" };
54
+ // src/predicates.ts
55
+ function isCapturePredicate(input) {
56
+ return "ref" in input;
74
57
  }
75
- function createRetryOutcome(reason) {
76
- return { kind: "retry", reason };
58
+ function captureOf(predicate) {
59
+ if (predicate.predicate.kind !== "state") {
60
+ throw new Error("internal: a capture predicate must carry a state assertion");
61
+ }
62
+ return { assertion: predicate.predicate.assertion, descriptor: predicate.ref.__entity };
77
63
  }
78
- function createFailOutcome(reason) {
79
- return { kind: "fail", reason };
64
+ function leaf(predicate) {
65
+ return { predicate, wait: (budget) => leaf(withWait(predicate, budget)) };
80
66
  }
81
- function buildReady(state) {
82
- return chainable({
83
- contract: () => makeObserverHandle(state)
84
- });
67
+ function condLeaf(predicate) {
68
+ return {
69
+ __condition: true,
70
+ predicate,
71
+ wait: (budget) => condLeaf(withWaitCondition(predicate, budget))
72
+ };
85
73
  }
86
- function buildNeedsInput(state) {
87
- return chainable({
88
- input: () => buildReady(state)
89
- });
74
+ function isConditionInput(input) {
75
+ return "__condition" in input;
90
76
  }
91
- function buildObserver(name) {
92
- return chainable({
93
- description: (description) => chainable({
94
- budget: (budget) => buildNeedsInput({ budget, description, name })
95
- })
96
- });
77
+ function withWait(predicate, budget) {
78
+ if (predicate.kind === "not" || predicate.kind === "and" || predicate.kind === "count" || predicate.kind === "when") {
79
+ return predicate;
80
+ }
81
+ return { ...predicate, wait: budget };
82
+ }
83
+ function withWaitCondition(condition, budget) {
84
+ return condition.kind === "singleton" ? { ...condition, wait: budget } : condition;
85
+ }
86
+ function toPredicate(input) {
87
+ return "predicate" in input ? input.predicate : input;
88
+ }
89
+ function visible(locator) {
90
+ return leaf({ kind: "visible", locator, wait: void 0 });
91
+ }
92
+ function disabled(locator) {
93
+ return leaf({ kind: "disabled", locator, wait: void 0 });
94
+ }
95
+ function enabled(locator) {
96
+ return leaf({ kind: "enabled", locator, wait: void 0 });
97
+ }
98
+ function focused(locator) {
99
+ return leaf({ kind: "focused", locator, wait: void 0 });
100
+ }
101
+ function value(locator, binding) {
102
+ return leaf({ kind: "value", locator, value: binding, wait: void 0 });
103
+ }
104
+ function text(locator, binding) {
105
+ return leaf({ kind: "text", locator, value: binding, wait: void 0 });
106
+ }
107
+ function not(input) {
108
+ if (isConditionInput(input)) {
109
+ return condLeaf({ kind: "not", predicate: input.predicate });
110
+ }
111
+ return leaf({ kind: "not", predicate: toPredicate(input) });
112
+ }
113
+ function and(...conditions) {
114
+ return condLeaf({ kind: "and", predicates: conditions.map((c) => c.predicate) });
115
+ }
116
+ function count(entity2) {
117
+ return {
118
+ is: (n) => condLeaf({ entity: entity2.name, kind: "count", value: n })
119
+ };
120
+ }
121
+ function when(...clauses) {
122
+ const last = clauses.at(-1);
123
+ const fallback = last !== void 0 && !isClause(last) ? toPredicate(last) : void 0;
124
+ const rows = clauses.filter((clause) => isClause(clause));
125
+ const chain = rows.reduceRight(
126
+ (otherwise, [condition, consequence]) => ({
127
+ condition: condition.predicate,
128
+ consequence: toPredicate(consequence),
129
+ kind: "when",
130
+ otherwise
131
+ }),
132
+ fallback
133
+ );
134
+ if (chain == null) {
135
+ throw new Error("when() needs at least one [condition, consequence] clause");
136
+ }
137
+ return leaf(chain);
138
+ }
139
+ function isClause(clause) {
140
+ return Array.isArray(clause);
97
141
  }
98
142
 
99
- // src/builder.ts
100
- function precondition(name) {
101
- return buildPreconditionStart(name);
143
+ // src/util.ts
144
+ function mapValues(record, fn) {
145
+ return Object.fromEntries(
146
+ Object.entries(record).map(([entryKey, value2]) => [entryKey, fn(value2, entryKey)])
147
+ );
102
148
  }
103
- function observer(name) {
104
- return buildObserver(name);
149
+ function sameSetValue(a, b) {
150
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
151
+ return a === b;
152
+ }
153
+ if ("ref" in a || "ref" in b) {
154
+ return "ref" in a && "ref" in b && a.ref === b.ref;
155
+ }
156
+ return sameTemplate(a, b);
105
157
  }
106
- function test(id, options) {
107
- validateTestId(id);
108
- const sourcePath = captureTestSourcePath();
109
- return buildTestName({ id, sourcePath, uiOnly: options?.uiOnly });
158
+ function sameTemplate(a, b) {
159
+ return a.template.length === b.template.length && a.template.every((segment, index) => sameSegment(segment, b.template[index]));
110
160
  }
111
- var TESTS_ANCHOR_PATTERN = /[/\\]\.ripplo[/\\]tests[/\\]([^):]+?)(?::\d+:\d+\)?)?$/;
112
- function captureTestSourcePath() {
113
- const stack = new Error("capture").stack;
114
- if (stack == null) {
115
- return void 0;
161
+ function sameSegment(a, b) {
162
+ if (typeof a === "string" || typeof b === "string" || b === void 0) {
163
+ return a === b;
116
164
  }
117
- const match = stack.split("\n").map((line) => TESTS_ANCHOR_PATTERN.exec(line)).find((m) => m != null);
118
- const captured = match?.[1];
119
- if (captured == null) {
120
- return void 0;
165
+ return a.ref === b.ref;
166
+ }
167
+
168
+ // src/entity.ts
169
+ function stringSpace(generator, constraints) {
170
+ return {
171
+ __t: void 0,
172
+ constraints: constraints == null ? void 0 : { kind: "string", ...constraints },
173
+ generator,
174
+ primitive: "string",
175
+ values: void 0
176
+ };
177
+ }
178
+ function primitiveTypeOf(value2) {
179
+ if (typeof value2 === "number") {
180
+ return "number";
181
+ }
182
+ if (typeof value2 === "boolean") {
183
+ return "boolean";
121
184
  }
122
- return captured.replaceAll("\\", "/");
185
+ return "string";
186
+ }
187
+ var v = {
188
+ boolean: () => ({
189
+ __t: void 0,
190
+ constraints: void 0,
191
+ generator: "lorem.word",
192
+ primitive: "boolean",
193
+ values: void 0
194
+ }),
195
+ companyName: (constraints) => stringSpace("company.name", constraints),
196
+ datetime: (options) => ({
197
+ __t: void 0,
198
+ constraints: {
199
+ kind: "datetime",
200
+ maxOffsetDays: options.offsetDays.max,
201
+ minOffsetDays: options.offsetDays.min
202
+ },
203
+ generator: "date.iso",
204
+ primitive: "string",
205
+ values: void 0
206
+ }),
207
+ email: (constraints) => stringSpace("internet.email", constraints),
208
+ fullName: (constraints) => stringSpace("person.fullName", constraints),
209
+ id: (constraints) => stringSpace("lorem.slug", constraints),
210
+ number: (constraints) => ({
211
+ __t: void 0,
212
+ constraints: { kind: "number", ...constraints },
213
+ generator: "lorem.word",
214
+ primitive: "number",
215
+ values: void 0
216
+ }),
217
+ oneOf: (values) => ({
218
+ __t: void 0,
219
+ constraints: void 0,
220
+ generator: "lorem.word",
221
+ primitive: primitiveTypeOf(values[0]),
222
+ values
223
+ }),
224
+ slug: (constraints) => stringSpace("lorem.slug", constraints),
225
+ url: (constraints) => stringSpace("internet.url", constraints),
226
+ word: (constraints) => stringSpace("lorem.word", constraints)
227
+ };
228
+ function propBuilder(args) {
229
+ return {
230
+ __o: void 0,
231
+ __t: void 0,
232
+ spec: args.spec,
233
+ valueSpace: args.valueSpace
234
+ };
123
235
  }
124
- function createRipplo(registries) {
125
- const { observers, preconditions, tests } = registries;
126
- validateUniqueNames(preconditions, observers, tests);
127
- const preconditionDefs = Object.values(preconditions).map((p) => stubPreconditionDef(p));
128
- const observerDefs = Object.values(observers).map((o) => stubObserverDef(o));
129
- const testDefs = [...tests];
236
+ function field(options) {
237
+ return propBuilder({
238
+ spec: {
239
+ consistency: options.consistency ?? "strict",
240
+ optional: options.optional ?? false,
241
+ stable: options.stable ?? true,
242
+ type: options.value.primitive
243
+ },
244
+ valueSpace: options.value
245
+ });
246
+ }
247
+ function id() {
130
248
  return {
131
- observers,
132
- preconditions,
133
- tests: testDefs,
134
- getObservers: () => observerDefs,
135
- getPreconditions: () => preconditionDefs,
136
- getTests: () => testDefs,
137
- getUnimplemented: () => ({
138
- observers: observerDefs.filter((o) => !o.implemented).map((o) => o.name),
139
- preconditions: preconditionDefs.filter((p) => !p.implemented).map((p) => p.name),
140
- tests: testDefs.filter((t) => !t.implemented).map((t) => t.id)
249
+ idKind: "surrogate",
250
+ prop: propBuilder({
251
+ spec: { consistency: "strict", optional: false, stable: true, type: "string" },
252
+ valueSpace: void 0
141
253
  })
142
254
  };
143
255
  }
144
- function stubPreconditionDef(p) {
145
- const name = readPreconditionName(p);
256
+ function key(options) {
146
257
  return {
147
- dependsOn: readPreconditionDependsOn(p),
148
- depMapping: readPreconditionDepMapping(p),
149
- description: readPreconditionDescription(p),
150
- implemented: false,
151
- name,
152
- returns: [],
153
- teardown: void 0,
154
- setup: () => Promise.resolve([])
258
+ idKind: "natural",
259
+ prop: propBuilder({
260
+ spec: {
261
+ consistency: "strict",
262
+ optional: false,
263
+ stable: true,
264
+ type: options.value.primitive
265
+ },
266
+ valueSpace: options.value
267
+ })
155
268
  };
156
269
  }
157
- function stubObserverDef(o) {
158
- const name = readObserverName(o);
270
+ function within(selection, sourceField) {
159
271
  return {
160
- budget: readObserverBudget(o),
161
- description: readObserverDescription(o),
162
- implemented: false,
163
- name,
164
- run: () => Promise.resolve(createFailOutcome(`observer "${name}" not implemented`))
272
+ __t: void 0,
273
+ field: String(sourceField),
274
+ kind: "within",
275
+ selection: { entity: selection.entity, where: selection.where }
165
276
  };
166
277
  }
167
- function validateUniqueNames(preconditions, observers, tests) {
168
- const pNames = /* @__PURE__ */ new Set();
169
- Object.values(preconditions).forEach((p) => {
170
- const name = readPreconditionName(p);
171
- if (pNames.has(name)) {
172
- throw new Error(`Duplicate precondition name: "${name}"`);
173
- }
174
- pNames.add(name);
175
- });
176
- const oNames = /* @__PURE__ */ new Set();
177
- Object.values(observers).forEach((o) => {
178
- const name = readObserverName(o);
179
- if (oNames.has(name)) {
180
- throw new Error(`Duplicate observer name: "${name}"`);
181
- }
182
- oNames.add(name);
183
- });
184
- const tIds = /* @__PURE__ */ new Set();
185
- tests.forEach((t) => {
186
- if (tIds.has(t.id)) {
187
- throw new Error(`Duplicate test id: "${t.id}"`);
188
- }
189
- tIds.add(t.id);
190
- });
278
+ function changed() {
279
+ return { kind: "changed" };
191
280
  }
192
- var TEST_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
193
- function validateTestId(id) {
194
- if (!TEST_ID_PATTERN.test(id)) {
195
- throw new Error(
196
- `Invalid test id "${id}". Must be lowercase alphanumeric with hyphens (e.g. "my-test-id").`
197
- );
281
+ function isWithin(value2) {
282
+ return value2 != null && typeof value2 === "object" && "kind" in value2;
283
+ }
284
+ function keyMap(where) {
285
+ return Object.fromEntries(
286
+ Object.entries(where).map(([col, value2]) => [col, keyValue(value2)])
287
+ );
288
+ }
289
+ function keyValue(value2) {
290
+ if (isWithin(value2)) {
291
+ return { field: value2.field, kind: "within", selection: value2.selection };
198
292
  }
293
+ return toSetValue(value2);
294
+ }
295
+ function entity(name, definition) {
296
+ const identityProps = mapValues(definition.identity, (builder) => builder.prop);
297
+ const allBuilders = { ...identityProps, ...definition.fields };
298
+ const fieldKeys = Object.keys(allBuilders);
299
+ const schema = {
300
+ description: definition.description,
301
+ identity: Object.keys(definition.identity),
302
+ identityKind: identityKindOf(definition.identity),
303
+ name,
304
+ props: mapValues(allBuilders, (builder, fieldName) => specWithSpace(name, fieldName, builder)),
305
+ source: definition.source
306
+ };
307
+ const handle = {
308
+ field: mapValues(allBuilders, (builder, fieldName) => ({
309
+ __t: void 0,
310
+ entity: name,
311
+ field: fieldName,
312
+ primitive: builder.spec.type,
313
+ valueSpaceName: `${name}.${fieldName}`
314
+ })),
315
+ name,
316
+ schema,
317
+ source: definition.source,
318
+ valueSpaces: collectValueSpaces(name, allBuilders),
319
+ created: (props) => captureLeaf({
320
+ assertion: { as: "", kind: "created", props: toSetMap(props) },
321
+ entity: name,
322
+ fieldKeys,
323
+ key: {}
324
+ }),
325
+ deleted: (where) => leaf({
326
+ assertion: { kind: "deleted" },
327
+ entity: name,
328
+ key: keyMap(where),
329
+ kind: "state",
330
+ wait: void 0
331
+ }),
332
+ maybe: (where) => makeEntity("maybe", name, fieldKeys, where),
333
+ none: (where) => ({
334
+ __entity: { entity: name, kind: "none", where: toSetMap(where) }
335
+ }),
336
+ of: (props) => makeEntity("of", name, fieldKeys, props),
337
+ only: (props) => makeEntity("only", name, fieldKeys, props),
338
+ updated: (where, props) => captureLeaf({
339
+ assertion: { as: "", kind: "updated", props: toUpdateMap(props) },
340
+ entity: name,
341
+ fieldKeys,
342
+ key: keyMap(where)
343
+ }),
344
+ where: (criteria) => ({
345
+ __s: void 0,
346
+ entity: name,
347
+ where: keyMap(criteria)
348
+ })
349
+ };
350
+ return handle;
199
351
  }
200
- function makePrecondition(params) {
201
- return params;
352
+ function makeEntity(kind, entityName, fieldKeys, props) {
353
+ const descriptor = { entity: entityName, kind, props: toSetMap(props) };
354
+ return entityHandle(descriptor, fieldKeys);
202
355
  }
203
- function buildDepMapping(deps) {
204
- return Object.entries(deps).map(([key, dep]) => [key, readPreconditionName(dep)]);
356
+ function matchPropsOf(props) {
357
+ return Object.fromEntries(
358
+ Object.entries(props).filter((entry) => !isChanged(entry[1]))
359
+ );
205
360
  }
206
- function buildPreconditionStart(name) {
207
- let description = "";
208
- const self = chainable({
209
- contract: () => makePrecondition({
210
- dependsOn: [],
211
- depMapping: [],
212
- description,
213
- name
214
- }),
215
- description(text) {
216
- description = text;
217
- return self;
361
+ function captureLeaf(params) {
362
+ const descriptor = {
363
+ entity: params.entity,
364
+ kind: params.assertion.kind,
365
+ props: matchPropsOf(params.assertion.props)
366
+ };
367
+ const ref = entityHandle(descriptor, params.fieldKeys);
368
+ const build = (wait2) => ({
369
+ predicate: {
370
+ assertion: params.assertion,
371
+ entity: params.entity,
372
+ key: params.key,
373
+ kind: "state",
374
+ wait: wait2
218
375
  },
219
- requires(deps) {
220
- return buildPreconditionWithDeps({ deps, name, getDescription: () => description });
221
- }
376
+ ref,
377
+ wait: (budget) => build(budget)
222
378
  });
223
- return self;
379
+ return build(void 0);
224
380
  }
225
- function buildPreconditionWithDeps({
226
- deps,
227
- getDescription,
228
- name
229
- }) {
230
- const description = getDescription();
231
- const depMapping = buildDepMapping(deps);
232
- const dependsOn = depMapping.map(([, depName]) => depName);
233
- return chainable({
234
- contract: () => makePrecondition({
235
- dependsOn,
236
- depMapping,
237
- description,
238
- name
239
- })
240
- });
381
+ function specWithSpace(stateName, fieldName, builder) {
382
+ if (builder.valueSpace == null) {
383
+ return builder.spec;
384
+ }
385
+ return { ...builder.spec, valueSpace: `${stateName}.${fieldName}` };
386
+ }
387
+ function identityKindOf(fields) {
388
+ return Object.values(fields).every((f) => f.idKind === "surrogate") ? "surrogate" : "natural";
389
+ }
390
+ function collectValueSpaces(stateName, builders) {
391
+ return Object.entries(builders).map(([fieldName, builder]) => ({ field: fieldName, partial: builder.valueSpace })).filter(
392
+ (entry) => entry.partial != null
393
+ ).map((entry) => ({
394
+ constraints: entry.partial.constraints,
395
+ generator: entry.partial.generator,
396
+ name: `${stateName}.${entry.field}`,
397
+ type: entry.partial.primitive,
398
+ values: entry.partial.values == null ? void 0 : [...entry.partial.values]
399
+ }));
241
400
  }
242
- function buildTestName({ id, sourcePath, uiOnly }) {
243
- return chainable({
244
- name: (displayName) => buildTestRequires({ id, name: displayName, sourcePath, uiOnly })
245
- });
401
+
402
+ // src/locators.ts
403
+ function role(roleName, name, ...bindings) {
404
+ return { by: "role", name: name == null ? void 0 : nameValue(name, bindings), role: roleName };
246
405
  }
247
- function castOutcome(value) {
248
- return value;
406
+ function inside(scope, target) {
407
+ return { by: "inside", scope, target };
249
408
  }
250
- function buildTestRequires({
251
- id,
252
- name,
253
- sourcePath,
254
- uiOnly
255
- }) {
256
- let description = "";
257
- const self = chainable({
258
- description(text) {
259
- description = text;
260
- return self;
261
- },
262
- requires(reqs) {
263
- const reqNames = Object.values(reqs).map((r) => readPreconditionName(r));
264
- const requiresKeys = {};
265
- Object.entries(reqs).forEach(([key, p]) => {
266
- requiresKeys[key] = readPreconditionName(p);
267
- });
268
- return castOutcome(
269
- buildTestOutcome({
270
- id,
271
- name,
272
- reqNames,
273
- requiresKeys,
274
- sourcePath,
275
- uiOnly,
276
- getDescription: () => description
277
- })
278
- );
279
- }
280
- });
281
- return self;
409
+ function testId(value2, ...bindings) {
410
+ if (typeof value2 === "string") {
411
+ return { by: "testId", value: value2 };
412
+ }
413
+ return { by: "testId", value: stringValueFromTemplate(value2, bindings) };
414
+ }
415
+ var button = named("button");
416
+ var cell = named("cell");
417
+ var checkbox = named("checkbox");
418
+ var combobox = named("combobox");
419
+ var dialog = named("dialog");
420
+ var heading = named("heading");
421
+ var img = named("img");
422
+ var link = named("link");
423
+ var listitem = named("listitem");
424
+ var menuitem = named("menuitem");
425
+ var option = named("option");
426
+ var radio = named("radio");
427
+ var row = named("row");
428
+ var searchbox = named("searchbox");
429
+ var tab = named("tab");
430
+ var textbox = named("textbox");
431
+ var alert = container("alert");
432
+ var banner = container("banner");
433
+ var complementary = container("complementary");
434
+ var contentinfo = container("contentinfo");
435
+ var form = container("form");
436
+ var list = container("list");
437
+ var main = container("main");
438
+ var menu = container("menu");
439
+ var navigation = container("navigation");
440
+ var region = container("region");
441
+ var status = container("status");
442
+ var table = container("table");
443
+ var tablist = container("tablist");
444
+ function named(roleName) {
445
+ return (name, ...bindings) => role(roleName, name, ...bindings);
446
+ }
447
+ function container(roleName) {
448
+ return (name, ...bindings) => role(roleName, name, ...bindings);
449
+ }
450
+ function nameValue(name, bindings) {
451
+ if (typeof name === "string") {
452
+ return name;
453
+ }
454
+ if (isBinding(name)) {
455
+ return name;
456
+ }
457
+ return stringValueFromTemplate(name, bindings);
282
458
  }
283
- function buildTestOutcome({
284
- getDescription,
285
- id,
286
- name,
287
- reqNames,
288
- requiresKeys,
289
- sourcePath,
290
- uiOnly
291
- }) {
292
- const description = getDescription();
293
- return chainable({
294
- expectedOutcome(text) {
295
- return buildTestStartsAt({
296
- description,
297
- expectedOutcome: text,
298
- id,
299
- name,
300
- reqNames,
301
- requiresKeys,
302
- sourcePath,
303
- uiOnly
304
- });
305
- }
306
- });
459
+
460
+ // src/singleton.ts
461
+ function singleton(name, config) {
462
+ const valueSpaceName = `singleton.${name}`;
463
+ const { constraints, generator, primitive } = config.value;
464
+ const schema = {
465
+ consistency: config.consistency ?? "strict",
466
+ default: config.default,
467
+ description: config.description,
468
+ name,
469
+ source: config.source,
470
+ type: primitive,
471
+ valueSpace: valueSpaceName
472
+ };
473
+ return {
474
+ is: isPredicate,
475
+ name,
476
+ schema,
477
+ source: config.source,
478
+ value: { __t: void 0, entity: name, field: "value", primitive, valueSpaceName },
479
+ valueSpaces: [{ constraints, generator, name: valueSpaceName, type: primitive }],
480
+ of: (value2) => ({
481
+ __entity: { kind: "singletonState", singleton: name, value: toSetValue(value2) },
482
+ is: isPredicate
483
+ })
484
+ };
485
+ function isPredicate(value2) {
486
+ return condLeaf({
487
+ assertion: { kind: "is", value: value2 },
488
+ kind: "singleton",
489
+ singleton: name,
490
+ wait: void 0
491
+ });
492
+ }
307
493
  }
308
- function buildTestStartsAt({
309
- description,
310
- expectedOutcome,
311
- id,
312
- name,
313
- reqNames,
314
- requiresKeys,
315
- sourcePath,
316
- uiOnly
317
- }) {
318
- return chainable({
319
- notImplemented: () => ({
320
- coverage: [],
321
- description,
322
- expectedOutcome,
323
- id,
324
- implemented: false,
494
+
495
+ // src/builtins.ts
496
+ function browserSingleton(name) {
497
+ return {
498
+ name,
499
+ is: (strings, ...values) => leaf({
500
+ kind: "browser",
325
501
  name,
326
- requires: [...reqNames],
327
- requiresKeys,
328
- sourcePath,
329
- startsAtFn: void 0,
330
- stepsFn: void 0,
331
- uiOnly
332
- }),
333
- startsAt(fn) {
334
- return buildTestSteps({
335
- description,
336
- expectedOutcome,
337
- id,
338
- name,
339
- reqNames,
340
- requiresKeys,
341
- sourcePath,
342
- startsAtFn: fn,
343
- uiOnly
344
- });
345
- }
346
- });
347
- }
348
- function buildTestSteps({
349
- description,
350
- expectedOutcome,
351
- id,
352
- name,
353
- reqNames,
354
- requiresKeys,
355
- sourcePath,
356
- startsAtFn,
357
- uiOnly
358
- }) {
359
- return chainable({
360
- steps: (stepsFn) => chainable({
361
- coverage: (...ids) => ({
362
- coverage: ids,
363
- description,
364
- expectedOutcome,
365
- id,
366
- implemented: true,
367
- name,
368
- requires: [...reqNames],
369
- requiresKeys,
370
- sourcePath,
371
- startsAtFn,
372
- stepsFn,
373
- uiOnly
374
- })
502
+ value: stringValueFromTemplate(strings, values),
503
+ wait: void 0
375
504
  })
376
- });
505
+ };
377
506
  }
507
+ var url = browserSingleton("url");
508
+ var title = browserSingleton("title");
509
+ var viewport = browserSingleton("viewport");
378
510
 
379
- // src/lint.ts
380
- function lint(result) {
381
- const diagnostics = [];
382
- result.tests.forEach((test2) => {
383
- const nodes = getOrderedNodes(test2);
384
- const report = (diagnostic) => {
385
- diagnostics.push({ ...diagnostic, test: test2.slug });
386
- };
387
- RULES.forEach((rule) => {
388
- rule(nodes, test2, report);
389
- });
390
- });
391
- return { diagnostics };
392
- }
393
- function getOrderedNodes(test2) {
394
- const result = [];
395
- let currentId = test2.spec.entryNode;
396
- const visited = /* @__PURE__ */ new Set();
397
- while (currentId != null && !visited.has(currentId)) {
398
- visited.add(currentId);
399
- const found = test2.spec.nodes[currentId];
400
- if (found == null) {
401
- break;
402
- }
403
- result.push(found);
404
- currentId = found.next;
405
- }
406
- return result;
407
- }
408
- function exactTextMatch(nodes, _test, report) {
409
- nodes.forEach((node) => {
410
- if (node.type === "assertText" && "operator" in node && node.operator !== "equals") {
411
- report({
412
- message: `${node.type} uses operator "${node.operator}" \u2014 only "equals" is allowed for determinism`,
413
- rule: "exact-text-match",
414
- step: node.label ?? node.id
415
- });
416
- }
417
- });
511
+ // src/actions.ts
512
+ function goto(strings, ...values) {
513
+ return stepBuilder({ kind: "goto", url: stringValueFromTemplate(strings, values) }, [], []);
418
514
  }
419
- function noHardcodedData(nodes, test2, report) {
420
- const hasVariables = Object.keys(test2.spec.variables ?? {}).length > 0;
421
- if (!hasVariables) {
422
- return;
423
- }
424
- nodes.forEach((node) => {
425
- if (node.type === "fill" && isStaticStringValue(node.value)) {
426
- const val = node.value.value;
427
- if (!isTemplateVar(val) && looksLikeDynamicData(val)) {
428
- report({
429
- message: `fill() uses hardcoded value "${val}" \u2014 consider using precondition data via {{namespace.key}}`,
430
- rule: "no-hardcoded-data",
431
- step: node.label ?? node.id
432
- });
433
- }
434
- }
435
- });
515
+ function click(locator) {
516
+ return stepBuilder({ kind: "click", locator }, [], []);
436
517
  }
437
- function preferPreconditionData(nodes, test2, report) {
438
- const variableKeys = Object.keys(test2.spec.variables ?? {});
439
- if (variableKeys.length === 0) {
440
- return;
441
- }
442
- const hasAnyTemplateRef = nodes.some((node) => JSON.stringify(node).includes("{{"));
443
- if (!hasAnyTemplateRef) {
444
- report({
445
- message: "Test requires preconditions but steps() never references precondition data \u2014 destructure and use it",
446
- rule: "prefer-precondition-data",
447
- step: void 0
448
- });
449
- }
518
+ function dblclick(locator) {
519
+ return stepBuilder({ kind: "dblclick", locator }, [], []);
450
520
  }
451
- function missingLabel(nodes, _test, report) {
452
- nodes.forEach((node) => {
453
- if (node.label == null || node.label.length === 0) {
454
- report({
455
- message: `Step "${node.id}" lacks .as("...") label \u2014 every step must be labeled`,
456
- rule: "missing-label",
457
- step: node.id
458
- });
459
- }
460
- });
521
+ function fill(locator, binding) {
522
+ return stepBuilder({ kind: "fill", locator, value: binding }, [], []);
461
523
  }
462
- function noDuplicateLabels(nodes, _test, report) {
463
- const seen = /* @__PURE__ */ new Map();
464
- nodes.forEach((node) => {
465
- if (node.label == null) {
466
- return;
467
- }
468
- const existing = seen.get(node.label);
469
- if (existing == null) {
470
- seen.set(node.label, node.id);
471
- } else {
472
- report({
473
- message: `Duplicate label "${node.label}" \u2014 also used by ${existing}`,
474
- rule: "no-duplicate-labels",
475
- step: node.label
476
- });
477
- }
478
- });
524
+ function clear(locator) {
525
+ return stepBuilder({ kind: "clear", locator }, [], []);
479
526
  }
480
- function assertAfterAction(nodes, _test, report) {
481
- let consecutiveActions = 0;
482
- nodes.forEach((node, index) => {
483
- if (index === 0 && node.type === "goto") {
484
- return;
485
- }
486
- if (isAssertionNode(node)) {
487
- consecutiveActions = 0;
488
- return;
489
- }
490
- consecutiveActions++;
491
- if (consecutiveActions === 3) {
492
- report({
493
- message: "3+ consecutive actions without an assertion \u2014 add verification between actions",
494
- rule: "assert-after-action",
495
- step: node.label ?? node.id
496
- });
497
- }
498
- });
527
+ function select(locator, binding) {
528
+ return stepBuilder({ kind: "select", locator, value: binding }, [], []);
499
529
  }
500
- function assertMatchesOutcome(nodes, _test, report) {
501
- if (nodes.length === 0) {
502
- return;
503
- }
504
- const lastNode = nodes.at(-1);
505
- if (lastNode != null && !isAssertionNode(lastNode)) {
506
- report({
507
- message: "Last step is an action, not an assertion \u2014 expectedOutcome should be verified by assertions at the end",
508
- rule: "assert-matches-outcome",
509
- step: lastNode.label ?? lastNode.id
510
- });
511
- }
530
+ function check(locator) {
531
+ return stepBuilder({ kind: "check", locator }, [], []);
512
532
  }
513
- function noEmptySteps(nodes, test2, report) {
514
- if (!test2.implemented) {
515
- return;
516
- }
517
- if (nodes.length === 0) {
518
- report({
519
- message: "Test has zero steps",
520
- rule: "no-empty-steps",
521
- step: void 0
522
- });
523
- }
533
+ function uncheck(locator) {
534
+ return stepBuilder({ kind: "uncheck", locator }, [], []);
524
535
  }
525
- function isStaticStringValue(val) {
526
- if (typeof val !== "object" || val == null) {
527
- return false;
528
- }
529
- if (!("type" in val) || val.type !== "static") {
530
- return false;
531
- }
532
- if (!("value" in val) || typeof val.value !== "string") {
533
- return false;
534
- }
535
- return true;
536
+ function hover(locator) {
537
+ return stepBuilder({ kind: "hover", locator }, [], []);
536
538
  }
537
- function isTemplateVar(value) {
538
- return value.includes("{{");
539
+ function upload(locator, files) {
540
+ return stepBuilder({ files: [...files], kind: "upload", locator }, [], []);
539
541
  }
540
- function looksLikeDynamicData(value) {
541
- return value.includes("@") || /\b[a-f0-9]{8,}\b/.test(value) || /^(test|ripplo|example|sample|demo)/i.test(value);
542
+ function press(pressKey, locator) {
543
+ return stepBuilder({ key: pressKey, kind: "press", locator }, [], []);
542
544
  }
543
- function isAssertionNode(node) {
544
- return node.type.startsWith("assert");
545
+ function stepBuilder(action, expected, captures) {
546
+ return {
547
+ captures,
548
+ step: { action, expect: [...expected] },
549
+ expect: (...predicates) => stepBuilder(
550
+ action,
551
+ [...expected, ...predicates.map((p) => toPredicate(p))],
552
+ [...captures, ...predicates.flatMap((p) => isCapturePredicate(p) ? [captureOf(p)] : [])]
553
+ )
554
+ };
545
555
  }
546
- var EFFECT_ASSERTION_TYPES = /* @__PURE__ */ new Set([
547
- "assertText",
548
- "assertValue",
549
- "assertCount",
550
- "assertUrl",
551
- "assertNotVisible",
552
- "assertChecked",
553
- "assertNotChecked",
554
- "assertAttribute",
555
- "assertDisabled",
556
- "assertEnabled"
557
- ]);
558
- function isEffectAssertion(node) {
559
- return EFFECT_ASSERTION_TYPES.has(node.type);
556
+
557
+ // src/finalize.ts
558
+ function finalize(body) {
559
+ const descriptors = [...new Set(body.given.map((item) => item.__entity))];
560
+ const absences = descriptors.filter((d) => d.kind === "none");
561
+ const singletonStates = descriptors.filter(
562
+ (d) => d.kind === "singletonState"
563
+ );
564
+ const entities = topoSort(
565
+ descriptors.filter(
566
+ (d) => d.kind === "of" || d.kind === "only" || d.kind === "maybe"
567
+ )
568
+ );
569
+ assertNoMaybeRefs({ absences, entities, singletonStates });
570
+ const captures = body.steps.flatMap((builder) => builder.captures);
571
+ assertScopedConditions(body.steps, new Set(singletonStates.map((s) => s.singleton)));
572
+ const aliases = assignAliases([...entities, ...captures.map((c) => c.descriptor)]);
573
+ const { names, params } = assignParams(allBindings(body, entities, absences, singletonStates));
574
+ const ctx = {
575
+ aliases,
576
+ captures: new Map(
577
+ captures.map((c) => [c.assertion, c.descriptor])
578
+ ),
579
+ params: names
580
+ };
581
+ return {
582
+ absent: absences.map((a) => ({ entity: a.entity, where: setMap(a.where, ctx) })),
583
+ exclusive: [...new Set(entities.filter((d) => d.kind === "only").map((d) => d.entity))],
584
+ maybe: setupsOf(entities, "maybe", ctx),
585
+ params,
586
+ singletons: resolveSingletons(singletonStates, ctx),
587
+ steps: body.steps.map((builder) => resolveStep(builder.step, ctx)),
588
+ world: entities.filter((d) => d.kind === "of" || d.kind === "only").map((d) => ({ as: aliasFor(ctx, d), entity: d.entity, set: setMap(d.props, ctx) }))
589
+ };
560
590
  }
561
- function noAssertions(nodes, test2, report) {
562
- if (!test2.implemented || nodes.length === 0) {
591
+ function resolveSingletons(states, ctx) {
592
+ return states.reduce((acc, state) => {
593
+ const value2 = resolveValue(state.value, ctx);
594
+ const existing = acc[state.singleton];
595
+ if (existing !== void 0 && !sameSetValue(existing, value2)) {
596
+ throw new Error(
597
+ `singleton "${state.singleton}" is given two conflicting values \u2014 a test may set each singleton to one value`
598
+ );
599
+ }
600
+ return { ...acc, [state.singleton]: value2 };
601
+ }, {});
602
+ }
603
+ function assertNoMaybeRefs({ absences, entities, singletonStates }) {
604
+ const offenders = [
605
+ ...entities.filter((d) => d.kind === "of" || d.kind === "only").flatMap((d) => maybeBindings(Object.values(d.props), `${d.kind}(${d.entity})`)),
606
+ ...absences.flatMap((a) => maybeBindings(Object.values(a.where), `none(${a.entity})`)),
607
+ ...singletonStates.flatMap((s) => maybeBindings([s.value], `singleton "${s.singleton}"`))
608
+ ];
609
+ const first = offenders[0];
610
+ if (first == null || first.binding.__bind.kind !== "field") {
563
611
  return;
564
612
  }
565
- if (!nodes.some((n) => isAssertionNode(n))) {
566
- report({
567
- message: "Test has zero assertion steps \u2014 cannot verify expectedOutcome. Add assert.* steps.",
568
- rule: "no-assertions",
569
- step: void 0
570
- });
571
- }
613
+ const { descriptor, field: field2 } = first.binding.__bind;
614
+ throw new Error(
615
+ `${first.site} references ${descriptor.entity}.${field2} from a maybe(${descriptor.entity}) \u2014 maybe entities are not materialized at setup; reference them only in steps`
616
+ );
572
617
  }
573
- function lowAssertionRatio(nodes, test2, report) {
574
- if (!test2.implemented || nodes.length <= 3) {
575
- return;
576
- }
577
- const assertions = nodes.filter((n) => isAssertionNode(n)).length;
578
- if (assertions === 0) {
579
- return;
580
- }
581
- const ratio = assertions / nodes.length;
582
- if (ratio < 0.15) {
583
- const pct = Math.round(ratio * 100);
584
- report({
585
- message: `Only ${String(assertions)}/${String(nodes.length)} steps are assertions (${String(pct)}%) \u2014 add more verification between actions`,
586
- rule: "low-assertion-ratio",
587
- step: void 0
588
- });
589
- }
618
+ function maybeBindings(values, site) {
619
+ return values.flatMap((value2) => valueBindings(value2)).filter((b) => b.__bind.kind === "field" && b.__bind.descriptor.kind === "maybe").map((binding) => ({ binding, site }));
590
620
  }
591
- function locatorSignature(node) {
592
- if (!("locator" in node) || node.locator == null) {
593
- return void 0;
621
+ function valueBindings(value2) {
622
+ if (isBinding(value2)) {
623
+ return [value2];
594
624
  }
595
- const loc = node.locator;
596
- if (loc.by === "role") {
597
- return `role:${loc.role}:${loc.name ?? ""}`;
625
+ if (isTemplate(value2)) {
626
+ return value2.template.flatMap((segment) => isBinding(segment) ? [segment] : []);
598
627
  }
599
- return `testId:${loc.value}`;
628
+ return [];
600
629
  }
601
- function tautologicalPostClickAssert(nodes, _test, report) {
602
- nodes.forEach((node, index) => {
603
- if (node.type !== "click") {
604
- return;
605
- }
606
- const sig = locatorSignature(node);
607
- if (sig == null) {
608
- return;
609
- }
610
- const window = nodes.slice(index + 1, index + 4);
611
- const matchesSameAssertVisible = window.find(
612
- (n) => n.type === "assertVisible" && locatorSignature(n) === sig
613
- );
614
- if (matchesSameAssertVisible == null) {
615
- return;
616
- }
617
- const hasOtherEffect = window.some(
618
- (n) => isEffectAssertion(n) || isAssertionNode(n) && locatorSignature(n) !== sig
619
- );
620
- if (hasOtherEffect) {
621
- return;
622
- }
623
- report({
624
- message: `click "${node.label ?? node.id}" is followed only by assert.visible on the same locator \u2014 verifies nothing about the click's effect`,
625
- rule: "tautological-post-click-assert",
626
- step: node.label ?? node.id
627
- });
628
- });
630
+ function isTemplate(value2) {
631
+ return typeof value2 === "object" && value2 !== null && "template" in value2;
629
632
  }
630
- var STOPWORDS = /* @__PURE__ */ new Set([
631
- "the",
632
- "is",
633
- "a",
634
- "an",
635
- "and",
636
- "or",
637
- "of",
638
- "to",
639
- "in",
640
- "on",
641
- "at",
642
- "for",
643
- "that",
644
- "this",
645
- "with",
646
- "be",
647
- "are",
648
- "was",
649
- "were",
650
- "it",
651
- "its",
652
- "as",
653
- "by",
654
- "from",
655
- "after",
656
- "before",
657
- "should",
658
- "will",
659
- "can",
660
- "still",
661
- "but",
662
- "not",
663
- "no",
664
- "so",
665
- "if",
666
- "then",
667
- "than",
668
- "user",
669
- "users",
670
- "page",
671
- "view",
672
- "shows",
673
- "show",
674
- "see",
675
- "click",
676
- "clicks",
677
- "clicked",
678
- "clickable",
679
- "visible",
680
- "appears",
681
- "appear",
682
- "displayed",
683
- "stays",
684
- "remains"
685
- ]);
686
- function tokenize(text) {
687
- return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 3 && !STOPWORDS.has(t));
633
+ function topoSort(entities) {
634
+ return emitReady([], entities);
688
635
  }
689
- function nodeKeywords(node) {
690
- const fromLabel = node.label == null ? [] : tokenize(node.label);
691
- if (!("locator" in node) || node.locator == null) {
692
- return fromLabel;
636
+ function emitReady(done, remaining) {
637
+ if (remaining.length === 0) {
638
+ return [...done];
693
639
  }
694
- const loc = node.locator;
695
- const locName = loc.by === "role" ? loc.name ?? "" : loc.value;
696
- return [...fromLabel, ...tokenize(locName)];
640
+ const ready = remaining.filter(
641
+ (d) => depsOf(d, remaining).every((dep) => done.includes(dep) || dep === d)
642
+ );
643
+ if (ready.length === 0) {
644
+ throw new Error("cyclic dependency between given entities");
645
+ }
646
+ return emitReady(
647
+ [...done, ...ready],
648
+ remaining.filter((d) => !ready.includes(d))
649
+ );
697
650
  }
698
- function expectedOutcomeKeywordCoverage(nodes, test2, report) {
699
- if (!test2.implemented || nodes.length === 0) {
651
+ function depsOf(descriptor, among) {
652
+ return Object.values(descriptor.props).flatMap((v2) => valueBindings(v2)).flatMap((b) => b.__bind.kind === "field" ? [b.__bind.descriptor] : []).filter((target) => target !== descriptor && among.includes(target));
653
+ }
654
+ function assertScopedConditions(steps, givenSingletons) {
655
+ steps.flatMap((builder) => builder.step.expect).forEach((predicate) => {
656
+ assertPredicateScoped(predicate, givenSingletons);
657
+ });
658
+ }
659
+ function assertPredicateScoped(predicate, given) {
660
+ if (predicate.kind === "not") {
661
+ assertPredicateScoped(predicate.predicate, given);
700
662
  return;
701
663
  }
702
- const outcomeTokens = new Set(tokenize(test2.expectedOutcome));
703
- if (outcomeTokens.size === 0) {
664
+ if (predicate.kind !== "when") {
704
665
  return;
705
666
  }
706
- const assertions = nodes.filter((n) => isAssertionNode(n));
707
- if (assertions.length === 0) {
708
- return;
667
+ conditionSingletons(predicate.condition).forEach((name) => {
668
+ if (!given.has(name)) {
669
+ throw new Error(
670
+ `when() conditions on singleton "${name}", which is not in the test's given \u2014 add ${name}.of(...) to given`
671
+ );
672
+ }
673
+ });
674
+ assertPredicateScoped(predicate.consequence, given);
675
+ }
676
+ function conditionSingletons(predicate) {
677
+ if (predicate.kind === "singleton") {
678
+ return [predicate.singleton];
709
679
  }
710
- const covered = assertions.some(
711
- (node) => nodeKeywords(node).some((tok) => outcomeTokens.has(tok))
712
- );
713
- if (!covered) {
714
- report({
715
- message: `No assertion references any keyword from expectedOutcome (${[...outcomeTokens].join(", ")}) \u2014 the outcome may not actually be verified`,
716
- rule: "expected-outcome-keyword-coverage",
717
- step: void 0
718
- });
680
+ if (predicate.kind === "not") {
681
+ return conditionSingletons(predicate.predicate);
682
+ }
683
+ if (predicate.kind === "and") {
684
+ return predicate.predicates.flatMap((p) => conditionSingletons(p));
719
685
  }
686
+ return [];
687
+ }
688
+ function assignAliases(entities) {
689
+ return entities.reduce(
690
+ (acc, d) => {
691
+ const ordinal = acc.counts[d.entity] ?? 0;
692
+ return {
693
+ aliases: new Map([...acc.aliases, [d, `${d.entity}_${String(ordinal)}`]]),
694
+ counts: { ...acc.counts, [d.entity]: ordinal + 1 }
695
+ };
696
+ },
697
+ { aliases: /* @__PURE__ */ new Map(), counts: {} }
698
+ ).aliases;
699
+ }
700
+ function assignParams(bindings) {
701
+ const unique = [...new Set(bindings.filter((b) => b.__bind.kind === "param"))];
702
+ const assigned = unique.reduce(
703
+ (acc, b) => {
704
+ const token = tokenOf(b);
705
+ const ordinal = acc.counts[token.base] ?? 0;
706
+ const name = ordinal === 0 ? token.base : `${token.base}_${String(ordinal - 1)}`;
707
+ if (name in acc.params) {
708
+ throw new Error(
709
+ `param name "${name}" collides with an existing param \u2014 rename the field so deduplicated param names stay unique`
710
+ );
711
+ }
712
+ return {
713
+ counts: { ...acc.counts, [token.base]: ordinal + 1 },
714
+ names: new Map([...acc.names, [b, name]]),
715
+ params: { ...acc.params, [name]: { example: token.example, valueSpace: token.valueSpace } }
716
+ };
717
+ },
718
+ { counts: {}, names: /* @__PURE__ */ new Map(), params: {} }
719
+ );
720
+ return { names: assigned.names, params: assigned.params };
720
721
  }
721
- var BACKEND_MUTATION_KEYWORDS = /save|submit|create|delete|remove|send|invite|update|confirm|publish|apply|pay|subscribe|upgrade|cancel|archive|rename/i;
722
- function isLikelyBackendMutation(node) {
723
- if (node.type === "upload") {
724
- return true;
722
+ function tokenOf(binding) {
723
+ if (binding.__bind.kind !== "param") {
724
+ throw new Error("internal: expected a param binding");
725
+ }
726
+ return binding.__bind.token;
727
+ }
728
+ function allBindings(body, entities, absences, singletonStates) {
729
+ return [
730
+ ...entities.flatMap((d) => Object.values(d.props).flatMap((v2) => valueBindings(v2))),
731
+ ...absences.flatMap((a) => Object.values(a.where).flatMap((v2) => valueBindings(v2))),
732
+ ...singletonStates.flatMap((s) => valueBindings(s.value)),
733
+ ...body.steps.flatMap((builder) => stepBindings(builder.step))
734
+ ];
735
+ }
736
+ function stepBindings(step) {
737
+ return [...actionBindings(step.action), ...step.expect.flatMap((p) => predicateBindings(p))];
738
+ }
739
+ function actionBindings(action) {
740
+ if (action.kind === "goto") {
741
+ return valueBindings(action.url);
742
+ }
743
+ if (action.kind === "fill" || action.kind === "select") {
744
+ return [...locatorBindings(action.locator), ...valueBindings(action.value)];
745
+ }
746
+ if (action.kind === "press") {
747
+ return action.locator == null ? [] : locatorBindings(action.locator);
725
748
  }
726
- if (node.type !== "click") {
727
- return false;
749
+ if (action.kind === "upload") {
750
+ return locatorBindings(action.locator);
728
751
  }
729
- const loc = node.locator;
730
- const name = loc.by === "role" ? loc.name ?? "" : loc.value;
731
- return BACKEND_MUTATION_KEYWORDS.test(name);
752
+ return locatorBindings(action.locator);
732
753
  }
733
- function mutationWithoutObserverCoverage(nodes, test2, report) {
734
- if (!test2.implemented || test2.spec.uiOnly === true) {
735
- return;
754
+ function locatorBindings(locator) {
755
+ if (locator.by === "inside") {
756
+ return [...locatorBindings(locator.scope), ...locatorBindings(locator.target)];
736
757
  }
737
- nodes.forEach((node, index) => {
738
- if (!isLikelyBackendMutation(node)) {
739
- return;
758
+ if (locator.by === "role") {
759
+ return locator.name == null ? [] : valueBindings(locator.name);
760
+ }
761
+ return valueBindings(locator.value);
762
+ }
763
+ function predicateBindings(predicate) {
764
+ switch (predicate.kind) {
765
+ case "visible":
766
+ case "disabled":
767
+ case "enabled":
768
+ case "focused": {
769
+ return locatorBindings(predicate.locator);
740
770
  }
741
- if ("uiOnly" in node && node.uiOnly === true) {
742
- return;
771
+ case "value":
772
+ case "text": {
773
+ return [...locatorBindings(predicate.locator), ...valueBindings(predicate.value)];
743
774
  }
744
- const rest = nodes.slice(index + 1);
745
- const nextMutationIdx = rest.findIndex((n) => isLikelyBackendMutation(n));
746
- const windowEnd = nextMutationIdx === -1 ? rest.length : nextMutationIdx;
747
- const window = rest.slice(0, windowEnd);
748
- const hasObserver = window.some((n) => n.type === "assertObserver");
749
- if (hasObserver) {
750
- return;
775
+ case "singleton": {
776
+ return valueBindings(predicate.assertion.value);
751
777
  }
752
- report({
753
- message: `"${node.label ?? node.id}" looks like it mutates backend state but no assert.backend(...) observer verifies it \u2014 the test can pass while the server is broken. You need to add an observer that checks the persisted result (see .ripplo/observers/ for examples). Only if this action truly does NOT change any server state (pure client-side interaction, presentation toggle, etc.) should you fall back to marking the step { uiOnly: true } to silence this rule.`,
754
- rule: "mutation-without-observer-coverage",
755
- step: node.label ?? node.id
756
- });
757
- });
758
- }
759
- function observerParamsReferenceVariables(nodes, test2, report) {
760
- const variableKeys = Object.keys(test2.spec.variables ?? {});
761
- if (variableKeys.length === 0) {
762
- return;
763
- }
764
- nodes.forEach((node) => {
765
- if (node.type !== "assertObserver") {
766
- return;
778
+ case "browser": {
779
+ return valueBindings(predicate.value);
767
780
  }
768
- const paramEntries = Object.entries(node.params);
769
- if (paramEntries.length === 0) {
770
- return;
781
+ case "state": {
782
+ return [
783
+ ...predicate.assertion.kind === "deleted" ? [] : Object.values(predicate.assertion.props).flatMap(
784
+ (v2) => isChanged(v2) ? [] : valueBindings(v2)
785
+ ),
786
+ ...Object.values(predicate.key).flatMap((v2) => whereBindings(v2))
787
+ ];
771
788
  }
772
- const anyReferencesVariable = paramEntries.some(([, ref]) => {
773
- return isStaticStringValue(ref) && isTemplateVar(ref.value);
774
- });
775
- if (anyReferencesVariable) {
776
- return;
789
+ case "not": {
790
+ return predicateBindings(predicate.predicate);
777
791
  }
778
- report({
779
- message: `assert.backend "${node.label ?? node.id}" passes only hardcoded params while the test defines precondition variables \u2014 observer assertions should reference {{namespace.key}} so they match the actual precondition data`,
780
- rule: "observer-params-reference-variables",
781
- step: node.label ?? node.id
782
- });
783
- });
784
- }
785
- function uploadFixtureName(nodes, _test, report) {
786
- nodes.forEach((node) => {
787
- if (node.type !== "upload") {
788
- return;
792
+ case "when": {
793
+ return [
794
+ ...predicateBindings(predicate.condition),
795
+ ...predicateBindings(predicate.consequence),
796
+ ...predicate.otherwise == null ? [] : predicateBindings(predicate.otherwise)
797
+ ];
789
798
  }
790
- node.files.forEach((name) => {
791
- if (name.length === 0) {
792
- report({
793
- message: `upload "${node.label ?? node.id}" references an empty fixture name`,
794
- rule: "upload-fixture-name",
795
- step: node.label ?? node.id
796
- });
797
- return;
798
- }
799
- if (name.includes("..") || name.startsWith("/")) {
800
- report({
801
- message: `upload "${node.label ?? node.id}" references "${name}" \u2014 fixture names must be relative paths under .ripplo/fixtures/, no ".." or absolute paths`,
802
- rule: "upload-fixture-name",
803
- step: node.label ?? node.id
804
- });
805
- }
806
- });
807
- });
808
- }
809
- function noLiteralTemplateStrings(nodes, test2, report) {
810
- const declaredKeys = new Set(Object.keys(test2.spec.variables ?? {}));
811
- const pattern = /\{\{([^{}]+?)\}\}/g;
812
- nodes.forEach((node) => {
813
- const serialized = JSON.stringify(node);
814
- const allMatches = [...serialized.matchAll(pattern)];
815
- const undeclared = [
816
- ...new Set(
817
- allMatches.map((m) => m[1]).filter((key) => key != null && !declaredKeys.has(key))
818
- )
819
- ];
820
- if (undeclared.length === 0) {
821
- return;
799
+ case "and": {
800
+ return predicate.predicates.flatMap((p) => predicateBindings(p));
822
801
  }
823
- const keyList = undeclared.map((k) => `{{${k}}}`).join(", ");
824
- const namespaces = [...new Set(undeclared.map((k) => k.split(".")[0] ?? k))].join(", ");
825
- report({
826
- message: `"${node.label ?? node.id}" contains literal template string(s) ${keyList} \u2014 destructure the proxy in .steps(({ ${namespaces} }) => \u2026) and pass the proxy value (e.g. \`${undeclared[0] ?? ""}\`) directly. Writing the template as a string bypasses type-checking and silently accepts typos.`,
827
- rule: "no-literal-template-strings",
828
- step: node.label ?? node.id
829
- });
830
- });
802
+ case "count": {
803
+ return [];
804
+ }
805
+ }
831
806
  }
832
- var RULES = [
833
- exactTextMatch,
834
- noHardcodedData,
835
- noLiteralTemplateStrings,
836
- preferPreconditionData,
837
- missingLabel,
838
- noDuplicateLabels,
839
- assertAfterAction,
840
- assertMatchesOutcome,
841
- noEmptySteps,
842
- noAssertions,
843
- lowAssertionRatio,
844
- tautologicalPostClickAssert,
845
- expectedOutcomeKeywordCoverage,
846
- mutationWithoutObserverCoverage,
847
- observerParamsReferenceVariables,
848
- uploadFixtureName
849
- ];
850
-
851
- // src/engine.ts
852
- var TEST_ID_PREFIX = "ripplo-test-";
853
- function notImplemented(reason) {
854
- return { reason: reason ?? "not implemented" };
855
- }
856
- function isNotImplemented(value) {
857
- return typeof value === "object" && value !== null && "reason" in value && Object.keys(value).length === 1;
858
- }
859
- function createEngine(ripplo, impls) {
860
- const preconditionDefs = wirePreconditions(ripplo.preconditions, impls.preconditions);
861
- const observerDefs = wireObservers(ripplo.observers, impls.observers);
862
- const preconditionsByName = new Map(preconditionDefs.map((d) => [d.name, d]));
863
- const observersByName = new Map(observerDefs.map((d) => [d.name, d]));
864
- return {
865
- executeObserver: (name, params) => executeObserver(observersByName, name, params),
866
- executePreconditions: (items, options) => executePreconditions({ defsByName: preconditionsByName, items, options }),
867
- getObservers: () => observerDefs,
868
- getPreconditions: () => preconditionDefs,
869
- getUnimplemented: () => ({
870
- observers: observerDefs.filter((o) => !o.implemented).map((o) => o.name),
871
- preconditions: preconditionDefs.filter((p) => !p.implemented).map((p) => p.name),
872
- tests: ripplo.tests.filter((t) => !t.implemented).map((t) => t.id)
873
- }),
874
- teardown: (items) => teardown({ defsByName: preconditionsByName, items })
875
- };
807
+ function whereBindings(value2) {
808
+ return isWithin2(value2) ? Object.values(value2.selection.where).flatMap((v2) => whereBindings(v2)) : valueBindings(value2);
876
809
  }
877
- function wirePreconditions(registry, impls) {
878
- return Object.entries(registry).map(([key, handle]) => {
879
- const impl = Reflect.get(impls, key);
880
- if (isNotImplemented(impl)) {
881
- return makeNotImplementedPreconditionDef(handle, impl);
882
- }
883
- return makeWiredPreconditionDef(handle, impl);
884
- });
810
+ function isWithin2(value2) {
811
+ return typeof value2 === "object" && value2 !== null && "kind" in value2;
812
+ }
813
+ function setMap(map, ctx) {
814
+ return Object.fromEntries(
815
+ Object.entries(map).map(([key2, value2]) => [key2, resolveValue(value2, ctx)])
816
+ );
885
817
  }
886
- function wireObservers(registry, impls) {
887
- return Object.entries(registry).map(([key, handle]) => {
888
- const impl = Reflect.get(impls, key);
889
- if (isNotImplemented(impl)) {
890
- return makeNotImplementedObserverDef(handle, impl);
818
+ function resolveValue(value2, ctx) {
819
+ if (isBinding(value2)) {
820
+ return resolveBinding(value2, ctx);
821
+ }
822
+ if (isTemplate(value2)) {
823
+ return resolveTemplate(value2, ctx);
824
+ }
825
+ return value2;
826
+ }
827
+ function resolveBinding(binding, ctx) {
828
+ const bind = binding.__bind;
829
+ if (bind.kind === "param") {
830
+ const name = ctx.params.get(binding);
831
+ if (name == null) {
832
+ throw new Error("internal: param binding was not collected");
891
833
  }
892
- return makeWiredObserverDef(handle, impl);
893
- });
834
+ return { ref: name };
835
+ }
836
+ const alias = ctx.aliases.get(bind.descriptor);
837
+ if (alias == null) {
838
+ throw new Error(
839
+ `references a "${bind.descriptor.entity}" entity that is not included in the test's given`
840
+ );
841
+ }
842
+ return { ref: `${alias}.${bind.field}` };
894
843
  }
895
- function makeNotImplementedPreconditionDef(handle, sentinel) {
896
- const name = readPreconditionName(handle);
844
+ function resolveTemplate(template, ctx) {
897
845
  return {
898
- dependsOn: readPreconditionDepMappingKeys(handle),
899
- depMapping: readPreconditionDepMapping(handle),
900
- description: readDescriptionOf(handle),
901
- implemented: false,
902
- name,
903
- returns: [],
904
- teardown: void 0,
905
- setup: () => Promise.reject(new Error(`Precondition "${name}" is ${sentinel.reason}`))
846
+ template: template.template.map(
847
+ (segment) => isBinding(segment) ? resolveBinding(segment, ctx) : segment
848
+ )
906
849
  };
907
850
  }
908
- function makeWiredPreconditionDef(handle, impl) {
909
- const name = readPreconditionName(handle);
910
- const mapping = readPreconditionDepMapping(handle);
911
- const typedImpl = impl;
912
- return {
913
- dependsOn: mapping.map(([, depName]) => depName),
914
- depMapping: mapping,
915
- description: readDescriptionOf(handle),
916
- implemented: true,
917
- name,
918
- returns: [],
919
- setup: async (items) => {
920
- const resolvedItems = items.map((item) => {
921
- const resolved = {};
922
- mapping.forEach(([key, depName]) => {
923
- const data = item.deps[depName];
924
- if (data != null) {
925
- resolved[key] = data;
926
- }
927
- });
928
- return { ctx: item.ctx, deps: resolved };
929
- });
930
- const results = await typedImpl.setup(resolvedItems);
931
- return results.map((r) => {
932
- const out = {};
933
- Object.entries(r).forEach(([key, value]) => {
934
- if (!isPrimitive(value)) {
935
- throw new TypeError(
936
- `Precondition "${name}" returned non-primitive value at key "${key}". Setup return values must be string, number, or boolean \u2014 produced via ctx.fixed/uniqueEmail/uniqueId.`
937
- );
938
- }
939
- out[key] = value;
940
- });
941
- return out;
942
- });
943
- },
944
- teardown: async (items) => {
945
- const typed = items;
946
- await typedImpl.teardown(typed);
947
- }
948
- };
851
+ function setupsOf(entities, kind, ctx) {
852
+ return entities.filter((d) => d.kind === kind).map((d) => ({ as: aliasFor(ctx, d), entity: d.entity, set: setMap(d.props, ctx) }));
949
853
  }
950
- function makeNotImplementedObserverDef(handle, sentinel) {
951
- const name = readObserverName(handle);
952
- return {
953
- budget: readBudgetOf(handle),
954
- description: readDescriptionOfObserver(handle),
955
- implemented: false,
956
- name,
957
- run: () => Promise.resolve(createFailOutcome(`observer "${name}" is ${sentinel.reason}`))
958
- };
854
+ function aliasFor(ctx, descriptor) {
855
+ const alias = ctx.aliases.get(descriptor);
856
+ if (alias == null) {
857
+ throw new Error(`internal: no alias for ${descriptor.entity}`);
858
+ }
859
+ return alias;
959
860
  }
960
- function makeWiredObserverDef(handle, impl) {
961
- const name = readObserverName(handle);
962
- const typedImpl = impl;
861
+ function resolveStep(step, ctx) {
963
862
  return {
964
- budget: readBudgetOf(handle),
965
- description: readDescriptionOfObserver(handle),
966
- implemented: true,
967
- name,
968
- run: async (ctx, params) => {
969
- return typedImpl(ctx, params);
970
- }
863
+ action: resolveAction(step.action, ctx),
864
+ expect: step.expect.map((predicate) => resolvePredicate(predicate, ctx))
971
865
  };
972
866
  }
973
- function readPreconditionDepMappingKeys(handle) {
974
- return readPreconditionDepMapping(handle).map(([, depName]) => depName);
975
- }
976
- function readDescriptionOf(handle) {
977
- return handle.description;
978
- }
979
- function readDescriptionOfObserver(handle) {
980
- return handle.description;
867
+ function resolveAction(action, ctx) {
868
+ if (action.kind === "goto") {
869
+ return { ...action, url: resolveString(action.url, ctx) };
870
+ }
871
+ if (action.kind === "fill" || action.kind === "select") {
872
+ return {
873
+ ...action,
874
+ locator: resolveLocator(action.locator, ctx),
875
+ value: resolveValue(action.value, ctx)
876
+ };
877
+ }
878
+ if (action.kind === "press") {
879
+ return {
880
+ ...action,
881
+ locator: action.locator == null ? void 0 : resolveLocator(action.locator, ctx)
882
+ };
883
+ }
884
+ if (action.kind === "upload") {
885
+ return { ...action, locator: resolveLocator(action.locator, ctx) };
886
+ }
887
+ return { ...action, locator: resolveLocator(action.locator, ctx) };
981
888
  }
982
- function readBudgetOf(handle) {
983
- return handle.budget;
889
+ function resolveString(value2, ctx) {
890
+ if (isBinding(value2)) {
891
+ return resolveBinding(value2, ctx);
892
+ }
893
+ if (isTemplate(value2)) {
894
+ return resolveTemplate(value2, ctx);
895
+ }
896
+ return value2;
984
897
  }
985
- async function executePreconditions({
986
- defsByName,
987
- items,
988
- options
989
- }) {
990
- const defaultDomain = deriveDefaultDomain(options?.appUrl);
991
- const initial = items.map((item) => {
992
- const cookiesRef = [];
898
+ function resolveLocator(locator, ctx) {
899
+ if (locator.by === "inside") {
993
900
  return {
994
- cookies: cookiesRef,
995
- cookiesRef,
996
- ctx: createSetupContext({ cookies: cookiesRef, defaultDomain, runId: item.runId }),
997
- data: {},
998
- executed: [],
999
- failure: void 0,
1000
- names: item.names,
1001
- runId: item.runId
901
+ by: "inside",
902
+ scope: resolveLocator(locator.scope, ctx),
903
+ target: resolveLocator(locator.target, ctx)
1002
904
  };
1003
- });
1004
- const order = topoOrder(defsByName, items);
1005
- const orderError = order.error;
1006
- if (orderError != null) {
1007
- return initial.map((s) => failResult(s, orderError));
1008
- }
1009
- const sequence = order.names ?? [];
1010
- let states = initial;
1011
- let i = 0;
1012
- while (i < sequence.length) {
1013
- const name = sequence[i];
1014
- if (name != null) {
1015
- states = await executeStep({ defsByName, name, states });
905
+ }
906
+ if (locator.by === "role") {
907
+ return locator.name == null ? locator : { ...locator, name: resolveString(locator.name, ctx) };
908
+ }
909
+ return { ...locator, value: resolveString(locator.value, ctx) };
910
+ }
911
+ function resolvePredicate(predicate, ctx) {
912
+ switch (predicate.kind) {
913
+ case "visible":
914
+ case "disabled":
915
+ case "enabled":
916
+ case "focused": {
917
+ return { ...predicate, locator: resolveLocator(predicate.locator, ctx) };
1016
918
  }
1017
- i += 1;
1018
- }
1019
- return states.map(
1020
- (s) => s.failure == null ? {
1021
- cookies: s.cookiesRef,
1022
- data: s.data,
1023
- error: void 0,
1024
- executed: s.executed,
1025
- runId: s.runId,
1026
- success: true
1027
- } : failResult(s, s.failure)
1028
- );
1029
- }
1030
- function topoOrder(defsByName, items) {
1031
- const seen = /* @__PURE__ */ new Set();
1032
- const order = [];
1033
- const visiting = /* @__PURE__ */ new Set();
1034
- const visitDeps = (deps) => {
1035
- const found = deps.find((d) => visit(d) != null);
1036
- return found == null ? void 0 : visit(found);
1037
- };
1038
- const visit = (name) => {
1039
- if (seen.has(name)) {
1040
- return void 0;
919
+ case "value":
920
+ case "text": {
921
+ return {
922
+ ...predicate,
923
+ locator: resolveLocator(predicate.locator, ctx),
924
+ value: resolveString(predicate.value, ctx)
925
+ };
1041
926
  }
1042
- if (visiting.has(name)) {
1043
- return `Cycle detected at precondition "${name}"`;
927
+ case "singleton": {
928
+ return {
929
+ ...predicate,
930
+ assertion: { ...predicate.assertion, value: resolveValue(predicate.assertion.value, ctx) }
931
+ };
1044
932
  }
1045
- const def = defsByName.get(name);
1046
- if (def == null) {
1047
- return `Unknown precondition: "${name}"`;
933
+ case "browser": {
934
+ return { ...predicate, value: resolveString(predicate.value, ctx) };
1048
935
  }
1049
- visiting.add(name);
1050
- const deps = def.depMapping.map(([, depName]) => depName);
1051
- const cycleError = visitDeps(deps);
1052
- visiting.delete(name);
1053
- if (cycleError != null) {
1054
- return cycleError;
936
+ case "state": {
937
+ return {
938
+ ...predicate,
939
+ assertion: resolveAssertion(predicate.assertion, ctx),
940
+ key: whereMap(predicate.key, ctx)
941
+ };
1055
942
  }
1056
- seen.add(name);
1057
- order.push(name);
1058
- return void 0;
1059
- };
1060
- let err;
1061
- let k = 0;
1062
- const allNames = items.flatMap((item) => item.names);
1063
- while (k < allNames.length && err == null) {
1064
- const n = allNames[k];
1065
- if (n != null) {
1066
- err = visit(n);
943
+ case "not": {
944
+ return { ...predicate, predicate: resolvePredicate(predicate.predicate, ctx) };
1067
945
  }
1068
- k += 1;
1069
- }
1070
- if (err != null) {
1071
- return { error: err, names: void 0 };
1072
- }
1073
- return { error: void 0, names: order };
1074
- }
1075
- async function executeStep({
1076
- defsByName,
1077
- name,
1078
- states
1079
- }) {
1080
- const isActive = (s) => s.failure == null && s.names.includes(name);
1081
- const active = states.filter((s) => isActive(s));
1082
- if (active.length === 0) {
1083
- return states;
1084
- }
1085
- const def = defsByName.get(name);
1086
- if (def == null) {
1087
- return states.map(
1088
- (s) => isActive(s) ? { ...s, failure: `Unknown precondition: "${name}"` } : s
1089
- );
1090
- }
1091
- if (!def.implemented) {
1092
- return states.map(
1093
- (s) => isActive(s) ? { ...s, failure: `Precondition "${name}" is not implemented` } : s
1094
- );
1095
- }
1096
- const stepResult = await runSetup({ active, def, name });
1097
- return states.map((s) => isActive(s) ? mergeStepResult(s, name, stepResult) : s);
1098
- }
1099
- async function runSetup({ active, def, name }) {
1100
- const itemsForCall = active.map((s) => ({ ctx: s.ctx, deps: s.data }));
1101
- try {
1102
- const results = await def.setup(itemsForCall);
1103
- if (results.length !== active.length) {
946
+ case "when": {
1104
947
  return {
1105
- batchError: `Precondition "${name}" returned ${String(results.length)} results for ${String(active.length)} items`,
1106
- perItem: /* @__PURE__ */ new Map()
948
+ ...predicate,
949
+ condition: resolvePredicate(predicate.condition, ctx),
950
+ consequence: resolvePredicate(predicate.consequence, ctx),
951
+ otherwise: predicate.otherwise == null ? void 0 : resolvePredicate(predicate.otherwise, ctx)
1107
952
  };
1108
953
  }
1109
- const entries = active.map((s, idx) => {
1110
- const r = results[idx];
1111
- return r == null ? void 0 : [s.runId, r];
1112
- });
1113
- const valid = entries.filter(
1114
- (e) => e != null
1115
- );
1116
- if (valid.length !== active.length) {
954
+ case "and": {
1117
955
  return {
1118
- batchError: `Precondition "${name}" returned a null result for at least one item`,
1119
- perItem: /* @__PURE__ */ new Map()
956
+ ...predicate,
957
+ predicates: predicate.predicates.map((p) => resolvePredicate(p, ctx))
1120
958
  };
1121
959
  }
1122
- return { batchError: void 0, perItem: new Map(valid) };
1123
- } catch (error) {
1124
- return {
1125
- batchError: error instanceof Error ? error.message : String(error),
1126
- perItem: /* @__PURE__ */ new Map()
1127
- };
960
+ case "count": {
961
+ return predicate;
962
+ }
1128
963
  }
1129
964
  }
1130
- function mergeStepResult(s, name, result) {
1131
- if (result.batchError != null) {
1132
- return { ...s, failure: result.batchError };
965
+ function resolveAssertion(assertion, ctx) {
966
+ if (assertion.kind === "deleted") {
967
+ return assertion;
968
+ }
969
+ const descriptor = ctx.captures.get(assertion);
970
+ if (descriptor == null) {
971
+ throw new Error("internal: capture assertion was not registered");
1133
972
  }
1134
- const data = result.perItem.get(s.runId);
1135
- if (data == null) {
1136
- return { ...s, failure: `Precondition "${name}" produced no result for run "${s.runId}"` };
973
+ const as = aliasFor(ctx, descriptor);
974
+ return assertion.kind === "created" ? { as, kind: "created", props: setMap(assertion.props, ctx) } : { as, kind: "updated", props: updateMap(assertion.props, ctx) };
975
+ }
976
+ function updateMap(map, ctx) {
977
+ return Object.fromEntries(
978
+ Object.entries(map).map(([key2, value2]) => [
979
+ key2,
980
+ isChanged(value2) ? value2 : resolveValue(value2, ctx)
981
+ ])
982
+ );
983
+ }
984
+ function whereMap(map, ctx) {
985
+ return Object.fromEntries(
986
+ Object.entries(map).map(([key2, value2]) => [
987
+ key2,
988
+ resolveWhere(value2, ctx)
989
+ ])
990
+ );
991
+ }
992
+ function resolveWhere(value2, ctx) {
993
+ return isWithin2(value2) ? { ...value2, selection: { ...value2.selection, where: whereMap(value2.selection.where, ctx) } } : resolveValue(value2, ctx);
994
+ }
995
+
996
+ // src/test.ts
997
+ function test(intent, fn) {
998
+ const sourcePath = captureSourcePath();
999
+ if (fn == null) {
1000
+ return { spec: stubSpec(intent, sourcePath) };
1137
1001
  }
1002
+ const final = finalize(fn());
1138
1003
  return {
1139
- ...s,
1140
- data: { ...s.data, [name]: data },
1141
- executed: [...s.executed, name]
1004
+ spec: {
1005
+ absent: final.absent,
1006
+ exclusive: final.exclusive,
1007
+ intent,
1008
+ maybe: final.maybe,
1009
+ name: slugify(intent),
1010
+ params: final.params,
1011
+ singletons: final.singletons,
1012
+ sourcePath,
1013
+ steps: final.steps,
1014
+ stub: false,
1015
+ world: final.world
1016
+ }
1142
1017
  };
1143
1018
  }
1144
- function failResult(s, error) {
1019
+ function stubSpec(intent, sourcePath) {
1145
1020
  return {
1146
- cookies: s.cookies,
1147
- data: s.data,
1148
- error,
1149
- executed: s.executed,
1150
- runId: s.runId,
1151
- success: false
1021
+ absent: [],
1022
+ exclusive: [],
1023
+ intent,
1024
+ maybe: [],
1025
+ name: slugify(intent),
1026
+ params: {},
1027
+ singletons: {},
1028
+ sourcePath,
1029
+ steps: [],
1030
+ stub: true,
1031
+ world: []
1152
1032
  };
1153
1033
  }
1154
- async function executeObserver(observersByName, name, params) {
1155
- const def = observersByName.get(name);
1156
- if (def == null) {
1157
- return { error: `Unknown observer: "${name}"`, outcome: void 0, success: false };
1158
- }
1159
- if (!def.implemented) {
1160
- return { error: `Observer "${name}" is not implemented`, outcome: void 0, success: false };
1034
+ var TESTS_ANCHOR_PATTERN = /[/\\]\.ripplo[/\\]tests[/\\]([^):]+?)(?::\d+:\d+\)?)?$/;
1035
+ function captureSourcePath() {
1036
+ const stack = new Error("capture").stack;
1037
+ if (stack == null) {
1038
+ return void 0;
1161
1039
  }
1162
- const ctx = createObserverContext(crypto.randomUUID().slice(0, 12));
1163
- try {
1164
- const outcome = await def.run(ctx, params);
1165
- return { error: void 0, outcome, success: true };
1166
- } catch (error) {
1167
- const message = error instanceof Error ? error.message : String(error);
1168
- return { error: void 0, outcome: createFailOutcome(message), success: true };
1040
+ const match = stack.split("\n").map((line) => TESTS_ANCHOR_PATTERN.exec(line)).find((m) => m != null);
1041
+ const captured = match?.[1];
1042
+ return captured == null ? void 0 : captured.replaceAll("\\", "/");
1043
+ }
1044
+ function slugify(intent) {
1045
+ const slug = intent.toLowerCase().replaceAll(/[^a-z0-9]+/g, " ").trim().split(" ").join("-");
1046
+ if (slug.length === 0) {
1047
+ throw new Error(`test intent "${intent}" slugifies to an empty string`);
1169
1048
  }
1049
+ return slug;
1170
1050
  }
1171
- function createObserverContext(runId) {
1051
+
1052
+ // src/params.ts
1053
+ function arbitrary(field2, example) {
1054
+ const token = {
1055
+ base: `${field2.entity}_${field2.field}`,
1056
+ example,
1057
+ valueSpace: field2.valueSpaceName
1058
+ };
1059
+ return paramBinding(token);
1060
+ }
1061
+
1062
+ // src/engine.ts
1063
+ import { err, ok, okAsync, Result, ResultAsync } from "neverthrow";
1064
+ function createEngine(ripplo, impls, teardown) {
1065
+ const entities = new Map(Object.entries(impls.entities));
1066
+ const singletons = new Map(
1067
+ Object.entries(impls.singletons).map(([name, impl]) => [
1068
+ name,
1069
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- per-name impls typed at the call site; erase the contravariant scalar seed to the loose dispatch shape
1070
+ impl
1071
+ ])
1072
+ );
1073
+ assertDeclared(ripplo, [...entities.keys()], [...singletons.keys()]);
1074
+ const entityImpl = (name) => lookup(entities.get(name), name);
1075
+ const singletonImpl = (name) => lookup(singletons.get(name), name);
1172
1076
  return {
1173
- runId,
1174
- fail: (reason) => createFailOutcome(reason),
1175
- pass: () => createPassOutcome(),
1176
- retry: (reason) => createRetryOutcome(reason)
1077
+ read: (request, runId) => ResultAsync.combine([
1078
+ ResultAsync.combine(
1079
+ request.entities.map((name) => readEntity(entityImpl(name), name, runId))
1080
+ ),
1081
+ ResultAsync.combine(
1082
+ request.singletons.map((name) => readSingleton(singletonImpl(name), name, runId))
1083
+ )
1084
+ ]).map(([entityPairs, singletonPairs]) => ({
1085
+ entities: Object.fromEntries(entityPairs),
1086
+ singletons: Object.fromEntries(singletonPairs)
1087
+ })),
1088
+ seed: (request, runId) => ResultAsync.fromPromise(teardown(runId), implFailure).andThen(() => seedSingletons(request.singletons, singletonImpl, runId)).andThen(() => seedSetups(request.entities, entityImpl, runId)),
1089
+ teardown: (runId) => ResultAsync.fromPromise(teardown(runId), implFailure)
1177
1090
  };
1178
1091
  }
1179
- async function teardown({
1180
- defsByName,
1181
- items
1182
- }) {
1183
- const errors = /* @__PURE__ */ new Map();
1184
- const allNamesUnique = [...new Set(items.flatMap((i2) => i2.names))];
1185
- const order = topoOrder(
1186
- defsByName,
1187
- allNamesUnique.map((n) => ({ names: [n], runId: "" }))
1092
+ function assertDeclared(ripplo, entityNames, singletonNames) {
1093
+ const backendEntities = new Set(
1094
+ ripplo.entities.filter((e) => e.schema.source === "backend").map((e) => e.name)
1095
+ );
1096
+ const backendSingletons = new Set(
1097
+ ripplo.singletons.filter((s) => s.schema.source === "backend").map((s) => s.name)
1188
1098
  );
1189
- const reversed = (order.names ?? []).toReversed();
1190
- let i = 0;
1191
- while (i < reversed.length) {
1192
- const name = reversed[i];
1193
- if (name != null) {
1194
- await teardownStep({ defsByName, errors, items, name });
1099
+ entityNames.forEach((name) => {
1100
+ if (!backendEntities.has(name)) {
1101
+ throw new Error(`engine impl "${name}" has no matching backend entity`);
1102
+ }
1103
+ });
1104
+ singletonNames.forEach((name) => {
1105
+ if (!backendSingletons.has(name)) {
1106
+ throw new Error(`engine impl "${name}" has no matching backend singleton`);
1195
1107
  }
1196
- i += 1;
1197
- }
1198
- return items.map((it) => {
1199
- const err = errors.get(it.runId);
1200
- return { error: err, runId: it.runId, success: err == null };
1201
1108
  });
1202
1109
  }
1203
- async function teardownStep({
1204
- defsByName,
1205
- errors,
1206
- items,
1207
- name
1208
- }) {
1209
- const def = defsByName.get(name);
1210
- if (def?.teardown == null) {
1211
- return;
1110
+ function lookup(impl, name) {
1111
+ return impl == null ? err({ message: `no engine impl for "${name}"` }) : ok(impl);
1112
+ }
1113
+ function readEntity(impl, name, runId) {
1114
+ return impl.asyncAndThen(
1115
+ (i) => ResultAsync.fromPromise(i.read({ runId }), implFailure).map((rows) => [
1116
+ name,
1117
+ [...rows]
1118
+ ])
1119
+ );
1120
+ }
1121
+ function implFailure(error) {
1122
+ return { message: error instanceof Error ? error.message : String(error) };
1123
+ }
1124
+ function readSingleton(impl, name, runId) {
1125
+ return impl.asyncAndThen(
1126
+ (i) => ResultAsync.fromPromise(i.read({ runId }), implFailure).map((value2) => [
1127
+ name,
1128
+ value2
1129
+ ])
1130
+ );
1131
+ }
1132
+ function seedSingletons(values, singletonImpl, runId) {
1133
+ return ResultAsync.combine(
1134
+ Object.entries(values).map(
1135
+ ([name, value2]) => singletonImpl(name).asyncAndThen(
1136
+ (impl) => ResultAsync.fromPromise(impl.seed({ runId, value: value2 }), implFailure)
1137
+ )
1138
+ )
1139
+ );
1140
+ }
1141
+ function seedSetups(specs, entityImpl, runId) {
1142
+ const waves = dependencyWaves(specs);
1143
+ return waves.reduce((accR, wave) => accR.andThen((acc) => seedWave(wave, entityImpl, acc, runId)), okAsync({ env: /* @__PURE__ */ new Map(), rows: [] })).map((fold) => orderRows(specs, fold.rows));
1144
+ }
1145
+ function dependencyWaves(specs) {
1146
+ return buildWaves([], specs);
1147
+ }
1148
+ function buildWaves(done, remaining) {
1149
+ if (remaining.length === 0) {
1150
+ return done;
1212
1151
  }
1213
- const active = items.filter((i) => i.names.includes(name));
1214
- if (active.length === 0) {
1215
- return;
1152
+ const seeded = new Set(done.flat().map((spec) => spec.as));
1153
+ const ready = remaining.filter((spec) => refAliases(spec).every((alias) => seeded.has(alias)));
1154
+ if (ready.length === 0) {
1155
+ return [...done, remaining];
1216
1156
  }
1217
- const teardownItems = active.map((it) => ({
1218
- ctx: { data: it.data[name] ?? {} }
1219
- }));
1220
- try {
1221
- await def.teardown(teardownItems);
1222
- } catch (error) {
1223
- const message = error instanceof Error ? error.message : String(error);
1224
- active.forEach((it) => {
1225
- if (!errors.has(it.runId)) {
1226
- errors.set(it.runId, message);
1227
- }
1228
- });
1157
+ return buildWaves(
1158
+ [...done, ready],
1159
+ remaining.filter((spec) => !ready.includes(spec))
1160
+ );
1161
+ }
1162
+ function refAliases(spec) {
1163
+ return Object.values(spec.fields).flatMap((value2) => {
1164
+ if (value2 === null || typeof value2 !== "object" || !("ref" in value2)) {
1165
+ return [];
1166
+ }
1167
+ const lastDot = value2.ref.lastIndexOf(".");
1168
+ return lastDot === -1 ? [] : [value2.ref.slice(0, lastDot)];
1169
+ });
1170
+ }
1171
+ function seedWave(wave, entityImpl, acc, runId) {
1172
+ return ResultAsync.combine(
1173
+ wave.map((spec) => seedSetup(entityImpl(spec.entity), acc, spec, runId))
1174
+ ).map((folds) => mergeFolds(acc, folds));
1175
+ }
1176
+ function seedSetup(impl, acc, spec, runId) {
1177
+ return resolveFields(spec.fields, acc.env).asyncAndThen((fields) => runSeed(impl, fields, runId)).map(({ row: row2, session }) => foldRow(acc, spec, row2, session));
1178
+ }
1179
+ function resolveFields(fields, env) {
1180
+ return Result.combine(
1181
+ Object.entries(fields).map(
1182
+ ([field2, value2]) => resolveValue2(value2, env).map((cell2) => [field2, cell2])
1183
+ )
1184
+ ).map((entries) => Object.fromEntries(entries));
1185
+ }
1186
+ function resolveValue2(value2, env) {
1187
+ if (value2 === null || typeof value2 !== "object") {
1188
+ return ok(value2);
1229
1189
  }
1190
+ if ("ref" in value2) {
1191
+ return env.has(value2.ref) ? ok(env.get(value2.ref) ?? null) : err({ message: `setup ref "${value2.ref}" was not produced by an earlier entity` });
1192
+ }
1193
+ throw new Error("internal: a setup value resolved to an unresolved template");
1194
+ }
1195
+ function runSeed(impl, fields, runId) {
1196
+ return impl.asyncAndThen((i) => ResultAsync.fromPromise(i.seed({ fields, runId }), implFailure));
1197
+ }
1198
+ function foldRow(acc, spec, row2, session) {
1199
+ const next = Object.entries(row2).map(([field2, value2]) => [
1200
+ `${spec.as}.${field2}`,
1201
+ value2
1202
+ ]);
1203
+ return {
1204
+ env: new Map([...acc.env, ...next]),
1205
+ rows: [...acc.rows, { as: spec.as, row: row2, session }]
1206
+ };
1230
1207
  }
1231
- function createSetupContext({
1232
- cookies,
1233
- defaultDomain,
1234
- runId
1208
+ function mergeFolds(base, folds) {
1209
+ return {
1210
+ env: new Map([...base.env, ...folds.flatMap((fold) => [...fold.env])]),
1211
+ rows: [...base.rows, ...folds.flatMap((fold) => fold.rows.slice(base.rows.length))]
1212
+ };
1213
+ }
1214
+ function orderRows(specs, rows) {
1215
+ const byAs = new Map(rows.map((row2) => [row2.as, row2]));
1216
+ return specs.flatMap((spec) => {
1217
+ const row2 = byAs.get(spec.as);
1218
+ return row2 == null ? [] : [row2];
1219
+ });
1220
+ }
1221
+
1222
+ // src/client-engine.ts
1223
+ import { z as z8 } from "zod";
1224
+
1225
+ // ../spec/src/leaves.ts
1226
+ import { z } from "zod";
1227
+ var budgetSchema = z.enum(["fast", "slow", "async"]);
1228
+ var valueRefSchema = z.object({ ref: z.string().min(1) });
1229
+ var primitiveSchema = z.union([z.string(), z.number(), z.boolean()]);
1230
+ var templateSchema = z.object({
1231
+ template: z.array(z.union([z.string(), valueRefSchema])).min(1)
1232
+ });
1233
+ var setValueSchema = z.union([valueRefSchema, primitiveSchema, templateSchema, z.null()]);
1234
+ var changedSchema = z.object({ kind: z.literal("changed") });
1235
+ var updateValueSchema = z.union([setValueSchema, changedSchema]);
1236
+ var stringValueSchema = z.union([z.string(), valueRefSchema, templateSchema]);
1237
+ var roleLocatorSchema = z.object({
1238
+ by: z.literal("role"),
1239
+ name: z.union([stringValueSchema, z.undefined()]).optional().transform((value2) => value2),
1240
+ role: z.string().min(1)
1241
+ });
1242
+ var testIdLocatorSchema = z.object({ by: z.literal("testId"), value: stringValueSchema });
1243
+ var insideLocatorSchema = z.object({
1244
+ by: z.literal("inside"),
1245
+ scope: z.lazy(() => locatorSchema),
1246
+ target: z.lazy(() => locatorSchema)
1247
+ });
1248
+ var locatorSchema = z.discriminatedUnion("by", [
1249
+ roleLocatorSchema,
1250
+ testIdLocatorSchema,
1251
+ insideLocatorSchema
1252
+ ]);
1253
+ var primitiveTypeSchema = z.enum(["string", "number", "boolean"]);
1254
+ var consistencyClassSchema = z.enum(["strict", "eventual"]);
1255
+ var stringConstraintsSchema = z.object({
1256
+ kind: z.literal("string"),
1257
+ maxLength: z.number().int().positive().optional(),
1258
+ minLength: z.number().int().nonnegative().optional(),
1259
+ pattern: z.string().optional()
1260
+ });
1261
+ var numberConstraintsSchema = z.object({
1262
+ kind: z.literal("number"),
1263
+ max: z.number().int().optional(),
1264
+ min: z.number().int().optional()
1265
+ });
1266
+ var datetimeConstraintsSchema = z.object({
1267
+ kind: z.literal("datetime"),
1268
+ maxOffsetDays: z.number().int(),
1269
+ minOffsetDays: z.number().int()
1270
+ });
1271
+ var constraintsSchema = z.discriminatedUnion("kind", [
1272
+ stringConstraintsSchema,
1273
+ numberConstraintsSchema,
1274
+ datetimeConstraintsSchema
1275
+ ]);
1276
+ var generatorSchema = z.enum([
1277
+ "company.name",
1278
+ "date.iso",
1279
+ "internet.email",
1280
+ "internet.url",
1281
+ "person.fullName",
1282
+ "lorem.slug",
1283
+ "lorem.word"
1284
+ ]);
1285
+ var valueSpaceSchema = z.object({
1286
+ constraints: constraintsSchema.optional(),
1287
+ generator: generatorSchema,
1288
+ name: z.string().min(1),
1289
+ type: primitiveTypeSchema,
1290
+ values: z.array(primitiveSchema).min(1).optional()
1291
+ });
1292
+ var propSpecSchema = z.object({
1293
+ consistency: consistencyClassSchema.default("strict"),
1294
+ optional: z.boolean(),
1295
+ stable: z.boolean(),
1296
+ type: primitiveTypeSchema,
1297
+ valueSpace: z.string().min(1).optional()
1298
+ });
1299
+ var sourceSchema = z.enum(["backend", "client"]);
1300
+ var entitySchemaSchema = z.object({
1301
+ description: z.string().optional(),
1302
+ identity: z.array(z.string().min(1)).min(1),
1303
+ identityKind: z.enum(["surrogate", "natural"]),
1304
+ name: z.string().min(1),
1305
+ props: z.record(z.string().min(1), propSpecSchema),
1306
+ source: sourceSchema.default("backend")
1307
+ });
1308
+ var singletonSchemaSchema = z.object({
1309
+ consistency: consistencyClassSchema.default("strict"),
1310
+ default: primitiveSchema,
1311
+ description: z.string().optional(),
1312
+ name: z.string().min(1),
1313
+ source: sourceSchema.default("backend"),
1314
+ type: primitiveTypeSchema,
1315
+ valueSpace: z.string().min(1).optional()
1316
+ });
1317
+ var browserSingletonSchema = z.enum(["url", "title", "viewport"]);
1318
+
1319
+ // ../spec/src/predicate.ts
1320
+ import { z as z2 } from "zod";
1321
+ var stateAssertionSchema = z2.discriminatedUnion("kind", [
1322
+ z2.object({
1323
+ as: z2.string().min(1),
1324
+ kind: z2.literal("created"),
1325
+ props: z2.record(z2.string().min(1), setValueSchema)
1326
+ }),
1327
+ z2.object({
1328
+ as: z2.string().min(1),
1329
+ kind: z2.literal("updated"),
1330
+ props: z2.record(z2.string().min(1), updateValueSchema)
1331
+ }),
1332
+ z2.object({ kind: z2.literal("deleted") })
1333
+ ]);
1334
+ var singletonAssertionSchema = z2.object({ kind: z2.literal("is"), value: setValueSchema });
1335
+ var whereValueSchema = z2.lazy(
1336
+ () => z2.union([setValueSchema, withinSchema])
1337
+ );
1338
+ var selectionSchema = z2.object({
1339
+ entity: z2.string().min(1),
1340
+ where: z2.record(z2.string().min(1), whereValueSchema)
1341
+ });
1342
+ var withinSchema = z2.object({
1343
+ field: z2.string().min(1),
1344
+ kind: z2.literal("within"),
1345
+ selection: selectionSchema
1346
+ });
1347
+ var wait = z2.union([budgetSchema, z2.undefined()]).optional().transform((value2) => value2);
1348
+ var predicateSchema = z2.lazy(
1349
+ () => z2.discriminatedUnion("kind", [
1350
+ z2.object({ kind: z2.literal("visible"), locator: locatorSchema, wait }),
1351
+ z2.object({ kind: z2.literal("disabled"), locator: locatorSchema, wait }),
1352
+ z2.object({ kind: z2.literal("enabled"), locator: locatorSchema, wait }),
1353
+ z2.object({ kind: z2.literal("focused"), locator: locatorSchema, wait }),
1354
+ z2.object({ kind: z2.literal("value"), locator: locatorSchema, value: stringValueSchema, wait }),
1355
+ z2.object({ kind: z2.literal("text"), locator: locatorSchema, value: stringValueSchema, wait }),
1356
+ z2.object({
1357
+ assertion: singletonAssertionSchema,
1358
+ kind: z2.literal("singleton"),
1359
+ singleton: z2.string().min(1),
1360
+ wait
1361
+ }),
1362
+ z2.object({
1363
+ kind: z2.literal("browser"),
1364
+ name: browserSingletonSchema,
1365
+ value: stringValueSchema,
1366
+ wait
1367
+ }),
1368
+ z2.object({
1369
+ assertion: stateAssertionSchema,
1370
+ entity: z2.string().min(1),
1371
+ key: z2.record(z2.string().min(1), whereValueSchema),
1372
+ kind: z2.literal("state"),
1373
+ wait
1374
+ }),
1375
+ z2.object({ kind: z2.literal("not"), predicate: predicateSchema }),
1376
+ z2.object({ kind: z2.literal("and"), predicates: z2.array(predicateSchema) }),
1377
+ z2.object({
1378
+ entity: z2.string().min(1),
1379
+ kind: z2.literal("count"),
1380
+ value: z2.number().int().nonnegative()
1381
+ }),
1382
+ z2.object({
1383
+ condition: predicateSchema,
1384
+ consequence: predicateSchema,
1385
+ kind: z2.literal("when"),
1386
+ otherwise: z2.union([predicateSchema, z2.undefined()]).optional().transform((value2) => value2)
1387
+ })
1388
+ ])
1389
+ );
1390
+
1391
+ // ../spec/src/codec.ts
1392
+ import { z as z3 } from "zod";
1393
+ var envelopeSchema = z3.object({
1394
+ __codec: z3.string().min(1),
1395
+ data: z3.unknown(),
1396
+ version: z3.number().int().positive()
1397
+ });
1398
+ var CodecVersionError = class extends Error {
1399
+ codec;
1400
+ currentVersion;
1401
+ gotVersion;
1402
+ constructor(params) {
1403
+ super(
1404
+ `Unsupported ${params.codec} version ${String(params.gotVersion)} (current ${String(params.currentVersion)}). Upgrade Ripplo or rebuild with a compatible CLI.`
1405
+ );
1406
+ this.name = "CodecVersionError";
1407
+ this.codec = params.codec;
1408
+ this.currentVersion = params.currentVersion;
1409
+ this.gotVersion = params.gotVersion;
1410
+ }
1411
+ };
1412
+ var CodecMismatchError = class extends Error {
1413
+ constructor(params) {
1414
+ super(`Codec mismatch: expected "${params.expected}", got "${params.got}"`);
1415
+ this.name = "CodecMismatchError";
1416
+ }
1417
+ };
1418
+ function defineCodec({
1419
+ name,
1420
+ schema
1235
1421
  }) {
1236
- let callCounter = 0;
1237
- const nextSuffix = () => {
1238
- callCounter += 1;
1239
- return `${runId}-${String(callCounter)}`;
1422
+ return {
1423
+ currentVersion: 1,
1424
+ name,
1425
+ decode: (raw) => decode({ name, raw, schema }),
1426
+ encode: (value2) => ({
1427
+ __codec: name,
1428
+ data: value2,
1429
+ version: 1
1430
+ })
1240
1431
  };
1432
+ }
1433
+ function decode({
1434
+ name,
1435
+ raw,
1436
+ schema
1437
+ }) {
1438
+ const envelope = envelopeSchema.parse(raw);
1439
+ if (envelope.__codec !== name) {
1440
+ throw new CodecMismatchError({ expected: name, got: envelope.__codec });
1441
+ }
1442
+ if (envelope.version !== 1) {
1443
+ throw new CodecVersionError({ codec: name, currentVersion: 1, gotVersion: envelope.version });
1444
+ }
1445
+ return schema.parse(envelope.data);
1446
+ }
1447
+
1448
+ // ../spec/src/client-channel.ts
1449
+ var CLIENT_MOUNT_KEY = "__ripplo__";
1450
+ var CLIENT_SEED_KEY = "__ripplo_seed__";
1451
+
1452
+ // ../spec/src/lockfile.ts
1453
+ import { z as z4 } from "zod";
1454
+ var actionSchema = z4.discriminatedUnion("kind", [
1455
+ z4.object({ kind: z4.literal("goto"), url: stringValueSchema }),
1456
+ z4.object({ kind: z4.literal("fill"), locator: locatorSchema, value: setValueSchema }),
1457
+ z4.object({ kind: z4.literal("clear"), locator: locatorSchema }),
1458
+ z4.object({ kind: z4.literal("click"), locator: locatorSchema }),
1459
+ z4.object({ kind: z4.literal("dblclick"), locator: locatorSchema }),
1460
+ z4.object({ kind: z4.literal("select"), locator: locatorSchema, value: setValueSchema }),
1461
+ z4.object({ kind: z4.literal("check"), locator: locatorSchema }),
1462
+ z4.object({ kind: z4.literal("uncheck"), locator: locatorSchema }),
1463
+ z4.object({ kind: z4.literal("hover"), locator: locatorSchema }),
1464
+ z4.object({
1465
+ files: z4.array(z4.string().min(1)).min(1),
1466
+ kind: z4.literal("upload"),
1467
+ locator: locatorSchema
1468
+ }),
1469
+ z4.object({ key: z4.string().min(1), kind: z4.literal("press"), locator: locatorSchema.optional() })
1470
+ ]);
1471
+ var stepSchema = z4.object({
1472
+ action: actionSchema,
1473
+ expect: z4.array(predicateSchema).default([])
1474
+ });
1475
+ var paramSchema = z4.object({
1476
+ example: primitiveSchema.optional(),
1477
+ valueSpace: z4.string().min(1)
1478
+ });
1479
+ var setupSchema = z4.object({
1480
+ as: z4.string().min(1),
1481
+ entity: z4.string().min(1),
1482
+ set: z4.record(z4.string().min(1), setValueSchema)
1483
+ });
1484
+ var absenceSchema = z4.object({
1485
+ entity: z4.string().min(1),
1486
+ where: z4.record(z4.string().min(1), setValueSchema)
1487
+ });
1488
+ var testSchema = z4.object({
1489
+ absent: z4.array(absenceSchema).default([]),
1490
+ exclusive: z4.array(z4.string().min(1)).default([]),
1491
+ intent: z4.string().min(1),
1492
+ maybe: z4.array(setupSchema).default([]),
1493
+ name: z4.string().min(1),
1494
+ params: z4.record(z4.string().min(1), paramSchema),
1495
+ singletons: z4.record(z4.string().min(1), setValueSchema).default({}),
1496
+ sourcePath: z4.string().min(1).optional(),
1497
+ steps: z4.array(stepSchema).default([]),
1498
+ stub: z4.boolean().default(false),
1499
+ world: z4.array(setupSchema).default([])
1500
+ });
1501
+ var fixtureEntrySchema = z4.object({
1502
+ sha256: z4.string().regex(/^[0-9a-f]{64}$/u),
1503
+ size: z4.number().int().nonnegative()
1504
+ });
1505
+ var lockfileSchema = z4.object({
1506
+ entities: z4.array(entitySchemaSchema),
1507
+ fixtures: z4.record(z4.string().min(1), fixtureEntrySchema).default({}),
1508
+ singletons: z4.array(singletonSchemaSchema).default([]),
1509
+ tests: z4.array(testSchema),
1510
+ valueSpaces: z4.array(valueSpaceSchema)
1511
+ });
1512
+ var lockfileCodec = defineCodec({ name: "ripplo-lockfile", schema: lockfileSchema });
1513
+
1514
+ // ../spec/src/sync-payload.ts
1515
+ import { z as z5 } from "zod";
1516
+ var stepDescriptorSchema = z5.object({
1517
+ index: z5.number().int().nonnegative(),
1518
+ kind: z5.string(),
1519
+ target: z5.string(),
1520
+ value: z5.string()
1521
+ });
1522
+ var workflowSpecSchema = z5.object({
1523
+ steps: z5.array(stepDescriptorSchema),
1524
+ stub: z5.boolean().default(false)
1525
+ });
1526
+
1527
+ // ../spec/src/session.ts
1528
+ import { z as z6 } from "zod";
1529
+ var sameSiteSchema = z6.enum(["Strict", "Lax", "None"]);
1530
+ var cookieSchema = z6.object({
1531
+ domain: z6.string().min(1),
1532
+ expires: z6.number(),
1533
+ httpOnly: z6.boolean(),
1534
+ name: z6.string().min(1),
1535
+ path: z6.string().min(1),
1536
+ sameSite: sameSiteSchema,
1537
+ secure: z6.boolean(),
1538
+ value: z6.string()
1539
+ });
1540
+ var originSchema = z6.object({
1541
+ localStorage: z6.array(z6.object({ name: z6.string().min(1), value: z6.string() })),
1542
+ origin: z6.string().min(1)
1543
+ });
1544
+ var sessionSchema = z6.object({
1545
+ cookies: z6.array(cookieSchema),
1546
+ headers: z6.record(z6.string().min(1), z6.string()).optional(),
1547
+ origins: z6.array(originSchema)
1548
+ });
1549
+
1550
+ // ../spec/src/engine.ts
1551
+ import { z as z7 } from "zod";
1552
+ var cellSchema = z7.union([primitiveSchema, z7.null()]);
1553
+ var rowSchema = z7.record(z7.string().min(1), cellSchema);
1554
+ var setupSpecSchema = z7.object({
1555
+ as: z7.string().min(1),
1556
+ entity: z7.string().min(1),
1557
+ fields: z7.record(z7.string().min(1), setValueSchema)
1558
+ });
1559
+ var setupRequestSchema = z7.object({
1560
+ entities: z7.array(setupSpecSchema),
1561
+ runId: z7.string().min(1),
1562
+ singletons: z7.record(z7.string().min(1), cellSchema).default({})
1563
+ });
1564
+ var setupRowSchema = z7.object({
1565
+ as: z7.string().min(1),
1566
+ row: rowSchema,
1567
+ session: sessionSchema.optional()
1568
+ });
1569
+ var setupResponseSchema = z7.object({
1570
+ rows: z7.array(setupRowSchema)
1571
+ });
1572
+ var stateRequestSchema = z7.object({
1573
+ entities: z7.array(z7.string().min(1)),
1574
+ runId: z7.string().min(1),
1575
+ singletons: z7.array(z7.string().min(1)).default([])
1576
+ });
1577
+ var stateResponseSchema = z7.object({
1578
+ entities: z7.record(z7.string().min(1), z7.array(rowSchema)),
1579
+ singletons: z7.record(z7.string().min(1), cellSchema).default({})
1580
+ });
1581
+ var teardownRequestSchema = z7.object({
1582
+ runId: z7.string().min(1)
1583
+ });
1584
+ var teardownResponseSchema = z7.object({
1585
+ ok: z7.literal(true)
1586
+ });
1587
+
1588
+ // src/client-engine.ts
1589
+ var seedSchema = z8.record(z8.string(), z8.union([z8.string(), z8.number(), z8.boolean()])).catch({});
1590
+ function createClientEngine(_ripplo, impls) {
1591
+ const singletons = new Map(
1592
+ Object.entries(impls.singletons).map(([name, impl]) => [
1593
+ name,
1594
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- per-name impls typed at the call site; erase the contravariant scalar seed to the loose dispatch shape
1595
+ impl
1596
+ ])
1597
+ );
1598
+ const entities = new Map(Object.entries(impls.entities));
1599
+ const seed = seedSchema.parse(Reflect.get(globalThis, CLIENT_SEED_KEY));
1600
+ Object.entries(seed).forEach(([name, value2]) => singletons.get(name)?.seed(value2));
1241
1601
  return {
1242
- runId,
1243
- fixed: (value) => brandTestValue(value),
1244
- setCookie: (name, value, options) => {
1245
- const resolvedOptions = options != null && options.domain == null && defaultDomain != null ? { ...options, domain: defaultDomain } : options ?? void 0;
1246
- cookies.push({ name, options: resolvedOptions, value });
1247
- },
1248
- uniqueEmail: () => brandTestValue(`${TEST_ID_PREFIX}${nextSuffix()}@test.ripplo.ai`),
1249
- uniqueId: (prefix) => brandTestValue(`${TEST_ID_PREFIX}${prefix}-${nextSuffix()}`)
1602
+ readEntity: (name) => entities.get(name)?.read() ?? [],
1603
+ readSingleton: (name) => singletons.get(name)?.read() ?? null
1250
1604
  };
1251
1605
  }
1252
- function deriveDefaultDomain(baseUrl) {
1253
- if (baseUrl == null) {
1254
- return void 0;
1606
+ function mountClientEngine(ripplo, impls, { enabled: enabled2 }) {
1607
+ if (!enabled2) {
1608
+ return;
1255
1609
  }
1256
- try {
1257
- return new URL(baseUrl).hostname;
1258
- } catch {
1259
- return void 0;
1610
+ Reflect.set(globalThis, CLIENT_MOUNT_KEY, createClientEngine(ripplo, impls));
1611
+ }
1612
+
1613
+ // src/build.ts
1614
+ function buildLockfile(input) {
1615
+ assertUniqueNames(input.entities);
1616
+ const lockfile = lockfileSchema.parse({
1617
+ entities: input.entities.map((handle) => handle.schema),
1618
+ singletons: input.singletons.map((handle) => handle.schema),
1619
+ tests: input.tests.map((ripploTest) => ripploTest.spec),
1620
+ valueSpaces: dedupeByName([
1621
+ ...input.entities.flatMap((handle) => handle.valueSpaces),
1622
+ ...input.singletons.flatMap((handle) => handle.valueSpaces)
1623
+ ])
1624
+ });
1625
+ assertNoContradictions(lockfile);
1626
+ return lockfile;
1627
+ }
1628
+ function assertUniqueNames(entities) {
1629
+ const names = entities.map((handle) => handle.schema.name);
1630
+ const duplicate = names.find((name, index) => names.indexOf(name) !== index);
1631
+ if (duplicate != null) {
1632
+ throw new Error(`duplicate entity name "${duplicate}" \u2014 each entity name must be unique`);
1260
1633
  }
1261
1634
  }
1635
+ function dedupeByName(spaces) {
1636
+ const byName = new Map(spaces.map((space) => [space.name, space]));
1637
+ return [...byName.values()];
1638
+ }
1639
+ function assertNoContradictions(lockfile) {
1640
+ lockfile.tests.forEach((test2) => {
1641
+ assertNoContradiction(test2);
1642
+ assertNoDanglingRefs(test2);
1643
+ });
1644
+ }
1645
+ function assertNoContradiction(test2) {
1646
+ const setups = [...test2.world, ...test2.maybe];
1647
+ test2.absent.forEach((absence) => {
1648
+ const clash = setups.find(
1649
+ (setup) => setup.entity === absence.entity && whereMatches(absence.where, setup.set)
1650
+ );
1651
+ if (clash != null) {
1652
+ throw new Error(
1653
+ `test "${test2.name}": creates a "${absence.entity}" ("${clash.as}") that a none(${absence.entity}, \u2026) in its world forbids`
1654
+ );
1655
+ }
1656
+ });
1657
+ }
1658
+ function whereMatches(where, set) {
1659
+ return Object.entries(where).every(([field2, want]) => sameValue(want, set[field2]));
1660
+ }
1661
+ function sameValue(a, b) {
1662
+ return b !== void 0 && sameSetValue(a, b);
1663
+ }
1664
+ function assertNoDanglingRefs(test2) {
1665
+ const aliases = new Set([...test2.world, ...test2.maybe].map((setup) => setup.as));
1666
+ const paramKeys = new Set(Object.keys(test2.params));
1667
+ const fieldSets = [
1668
+ ...test2.world.map((setup) => setup.set),
1669
+ ...test2.maybe.map((setup) => setup.set),
1670
+ ...test2.absent.map((absence) => absence.where)
1671
+ ];
1672
+ fieldSets.forEach((set) => {
1673
+ assertSetRefs(test2.name, set, aliases, paramKeys);
1674
+ });
1675
+ }
1676
+ function assertSetRefs(testName, set, aliases, paramKeys) {
1677
+ Object.values(set).forEach((value2) => {
1678
+ if (!isRef(value2) || paramKeys.has(value2.ref)) {
1679
+ return;
1680
+ }
1681
+ const lastDot = value2.ref.lastIndexOf(".");
1682
+ if (lastDot === -1) {
1683
+ return;
1684
+ }
1685
+ const aliasPath = value2.ref.slice(0, lastDot);
1686
+ if (!aliases.has(aliasPath)) {
1687
+ throw new Error(
1688
+ `test "${testName}": ref "${value2.ref}" points at unknown alias "${aliasPath}"`
1689
+ );
1690
+ }
1691
+ });
1692
+ }
1693
+ function isRef(value2) {
1694
+ return value2 != null && typeof value2 === "object" && "ref" in value2;
1695
+ }
1696
+
1697
+ // src/ripplo.ts
1698
+ function createRipplo(input) {
1699
+ return {
1700
+ entities: input.entities,
1701
+ lockfile: buildLockfile({
1702
+ entities: input.entities,
1703
+ singletons: input.singletons,
1704
+ tests: input.tests
1705
+ }),
1706
+ singletons: input.singletons,
1707
+ tests: input.tests
1708
+ };
1709
+ }
1262
1710
  export {
1263
- DEFAULT_IGNORE_PATHS,
1264
- DEFAULT_WATCH_PATHS,
1265
- TEST_ID_PREFIX,
1266
- compile,
1711
+ CLIENT_MOUNT_KEY,
1712
+ CLIENT_SEED_KEY,
1713
+ alert,
1714
+ and,
1715
+ arbitrary,
1716
+ banner,
1717
+ button,
1718
+ cell,
1719
+ changed,
1720
+ check,
1721
+ checkbox,
1722
+ clear,
1723
+ click,
1724
+ combobox,
1725
+ complementary,
1726
+ contentinfo,
1727
+ count,
1728
+ createClientEngine,
1267
1729
  createEngine,
1268
1730
  createRipplo,
1269
- lint,
1270
- notImplemented,
1271
- observer,
1272
- precondition,
1273
- readAdapterWebhookSecret,
1274
- serializeCookie,
1731
+ dblclick,
1732
+ dialog,
1733
+ disabled,
1734
+ enabled,
1735
+ entity,
1736
+ field,
1737
+ fill,
1738
+ focused,
1739
+ form,
1740
+ goto,
1741
+ heading,
1742
+ hover,
1743
+ id,
1744
+ img,
1745
+ inside,
1746
+ key,
1747
+ link,
1748
+ list,
1749
+ listitem,
1750
+ main,
1751
+ menu,
1752
+ menuitem,
1753
+ mountClientEngine,
1754
+ navigation,
1755
+ not,
1756
+ option,
1757
+ press,
1758
+ radio,
1759
+ region,
1760
+ role,
1761
+ row,
1762
+ searchbox,
1763
+ select,
1764
+ singleton,
1765
+ status,
1766
+ tab,
1767
+ table,
1768
+ tablist,
1275
1769
  test,
1276
- toBatchRunResults,
1277
- toTeardownResults,
1278
- verifyWebhookSignature
1770
+ testId,
1771
+ text,
1772
+ textbox,
1773
+ title,
1774
+ uncheck,
1775
+ upload,
1776
+ url,
1777
+ v,
1778
+ value,
1779
+ viewport,
1780
+ visible,
1781
+ when,
1782
+ within
1279
1783
  };