@ripplo/testing 0.7.9 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -108,7 +108,13 @@ function not(input) {
108
108
  if (isConditionInput(input)) {
109
109
  return condLeaf({ kind: "not", predicate: input.predicate });
110
110
  }
111
- return leaf({ kind: "not", predicate: toPredicate(input) });
111
+ const predicate = toPredicate(input);
112
+ if (predicate.kind === "when") {
113
+ throw new Error(
114
+ "when() cannot be wrapped in not() \u2014 express the negation in branch conditions"
115
+ );
116
+ }
117
+ return leaf({ kind: "not", predicate });
112
118
  }
113
119
  function and(...conditions) {
114
120
  return condLeaf({ kind: "and", predicates: conditions.map((c) => c.predicate) });
@@ -118,26 +124,52 @@ function count(entity2) {
118
124
  is: (n) => condLeaf({ entity: entity2.name, kind: "count", value: n })
119
125
  };
120
126
  }
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");
127
+ function branch(name) {
128
+ const close = (condition) => (consequence) => {
129
+ const resolved = toPredicate(consequence);
130
+ if (containsWhen(resolved)) {
131
+ throw new Error(
132
+ `branch "${name}" nests a when() inside its consequence \u2014 whens are one level deep; split the scenario into another workflow`
133
+ );
134
+ }
135
+ return { __branch: true, branch: { condition, consequence: resolved, name } };
136
+ };
137
+ return {
138
+ expect: close(void 0),
139
+ if: (condition) => ({
140
+ expect: close(condition.predicate)
141
+ })
142
+ };
143
+ }
144
+ function when(...branches) {
145
+ if (branches.length === 0) {
146
+ throw new Error("when() needs at least one branch(...)");
147
+ }
148
+ const rows = branches.map((input) => input.branch);
149
+ const fallbacks = rows.filter((row2) => row2.condition == null);
150
+ if (fallbacks.length > 1) {
151
+ throw new Error("when() allows one unconditional branch \u2014 give the others .if(...) conditions");
152
+ }
153
+ if (fallbacks.length === 1 && rows.at(-1)?.condition != null) {
154
+ throw new Error("when() requires the unconditional branch to come last");
136
155
  }
137
- return leaf(chain);
156
+ const dup = rows.find((row2, index) => rows.findIndex((r) => r.name === row2.name) !== index);
157
+ if (dup != null) {
158
+ throw new Error(`when() branch name "${dup.name}" is used twice \u2014 branch names must be unique`);
159
+ }
160
+ return leaf({ branches: rows, kind: "when" });
138
161
  }
139
- function isClause(clause) {
140
- return Array.isArray(clause);
162
+ function containsWhen(predicate) {
163
+ if (predicate.kind === "when") {
164
+ return true;
165
+ }
166
+ if (predicate.kind === "not") {
167
+ return containsWhen(predicate.predicate);
168
+ }
169
+ if (predicate.kind === "and") {
170
+ return predicate.predicates.some((p) => containsWhen(p));
171
+ }
172
+ return false;
141
173
  }
142
174
 
143
175
  // src/util.ts
@@ -676,14 +708,17 @@ function assertPredicateScoped(predicate, given) {
676
708
  if (predicate.kind !== "when") {
677
709
  return;
678
710
  }
679
- conditionSingletons(predicate.condition).forEach((name) => {
680
- if (!given.has(name)) {
681
- throw new Error(
682
- `when() conditions on singleton "${name}", which is not in the test's given \u2014 add ${name}.of(...) to given`
683
- );
684
- }
711
+ predicate.branches.forEach((row2) => {
712
+ const names = row2.condition == null ? [] : conditionSingletons(row2.condition);
713
+ names.forEach((name) => {
714
+ if (!given.has(name)) {
715
+ throw new Error(
716
+ `when() conditions on singleton "${name}", which is not in the workflow's given \u2014 add ${name}.of(...) to given`
717
+ );
718
+ }
719
+ });
720
+ assertPredicateScoped(row2.consequence, given);
685
721
  });
686
- assertPredicateScoped(predicate.consequence, given);
687
722
  }
688
723
  function conditionSingletons(predicate) {
689
724
  if (predicate.kind === "singleton") {
@@ -802,11 +837,10 @@ function predicateBindings(predicate) {
802
837
  return predicateBindings(predicate.predicate);
803
838
  }
804
839
  case "when": {
805
- return [
806
- ...predicateBindings(predicate.condition),
807
- ...predicateBindings(predicate.consequence),
808
- ...predicate.otherwise == null ? [] : predicateBindings(predicate.otherwise)
809
- ];
840
+ return predicate.branches.flatMap((row2) => [
841
+ ...row2.condition == null ? [] : predicateBindings(row2.condition),
842
+ ...predicateBindings(row2.consequence)
843
+ ]);
810
844
  }
811
845
  case "and": {
812
846
  return predicate.predicates.flatMap((p) => predicateBindings(p));
@@ -958,9 +992,11 @@ function resolvePredicate(predicate, ctx) {
958
992
  case "when": {
959
993
  return {
960
994
  ...predicate,
961
- condition: resolvePredicate(predicate.condition, ctx),
962
- consequence: resolvePredicate(predicate.consequence, ctx),
963
- otherwise: predicate.otherwise == null ? void 0 : resolvePredicate(predicate.otherwise, ctx)
995
+ branches: predicate.branches.map((row2) => ({
996
+ condition: row2.condition == null ? void 0 : resolveCondition(row2.condition, ctx),
997
+ consequence: resolvePredicate(row2.consequence, ctx),
998
+ name: row2.name
999
+ }))
964
1000
  };
965
1001
  }
966
1002
  case "and": {
@@ -1004,9 +1040,34 @@ function whereMap(map, ctx) {
1004
1040
  function resolveWhere(value2, ctx) {
1005
1041
  return isWithin2(value2) ? { ...value2, selection: { ...value2.selection, where: whereMap(value2.selection.where, ctx) } } : resolveValue(value2, ctx);
1006
1042
  }
1043
+ function resolveCondition(condition, ctx) {
1044
+ switch (condition.kind) {
1045
+ case "singleton": {
1046
+ return {
1047
+ ...condition,
1048
+ assertion: {
1049
+ ...condition.assertion,
1050
+ value: resolveValue(condition.assertion.value, ctx)
1051
+ }
1052
+ };
1053
+ }
1054
+ case "count": {
1055
+ return condition;
1056
+ }
1057
+ case "not": {
1058
+ return { ...condition, predicate: resolveCondition(condition.predicate, ctx) };
1059
+ }
1060
+ case "and": {
1061
+ return {
1062
+ ...condition,
1063
+ predicates: condition.predicates.map((p) => resolveCondition(p, ctx))
1064
+ };
1065
+ }
1066
+ }
1067
+ }
1007
1068
 
1008
- // src/test.ts
1009
- function test(intent, fn) {
1069
+ // src/workflow.ts
1070
+ function workflow(intent, fn) {
1010
1071
  const sourcePath = captureSourcePath();
1011
1072
  if (fn == null) {
1012
1073
  return { spec: stubSpec(intent, sourcePath) };
@@ -1024,6 +1085,7 @@ function test(intent, fn) {
1024
1085
  sourcePath,
1025
1086
  steps: final.steps,
1026
1087
  stub: false,
1088
+ tests: [],
1027
1089
  world: final.world
1028
1090
  }
1029
1091
  };
@@ -1040,23 +1102,24 @@ function stubSpec(intent, sourcePath) {
1040
1102
  sourcePath,
1041
1103
  steps: [],
1042
1104
  stub: true,
1105
+ tests: [],
1043
1106
  world: []
1044
1107
  };
1045
1108
  }
1046
- var TESTS_ANCHOR_PATTERN = /[/\\]\.ripplo[/\\]tests[/\\]([^):]+?)(?::\d+:\d+\)?)?$/;
1109
+ var WORKFLOWS_ANCHOR_PATTERN = /[/\\]\.ripplo[/\\]workflows[/\\]([^):]+?)(?::\d+:\d+\)?)?$/;
1047
1110
  function captureSourcePath() {
1048
1111
  const stack = new Error("capture").stack;
1049
1112
  if (stack == null) {
1050
1113
  return void 0;
1051
1114
  }
1052
- const match = stack.split("\n").map((line) => TESTS_ANCHOR_PATTERN.exec(line)).find((m) => m != null);
1115
+ const match = stack.split("\n").map((line) => WORKFLOWS_ANCHOR_PATTERN.exec(line)).find((m) => m != null);
1053
1116
  const captured = match?.[1];
1054
1117
  return captured == null ? void 0 : captured.replaceAll("\\", "/");
1055
1118
  }
1056
1119
  function slugify(intent) {
1057
1120
  const slug = intent.toLowerCase().replaceAll(/[^a-z0-9]+/g, " ").trim().split(" ").join("-");
1058
1121
  if (slug.length === 0) {
1059
- throw new Error(`test intent "${intent}" slugifies to an empty string`);
1122
+ throw new Error(`workflow intent "${intent}" slugifies to an empty string`);
1060
1123
  }
1061
1124
  return slug;
1062
1125
  }
@@ -1357,6 +1420,32 @@ var withinSchema = z2.object({
1357
1420
  selection: selectionSchema
1358
1421
  });
1359
1422
  var wait = z2.union([budgetSchema, z2.undefined()]).optional().transform((value2) => value2);
1423
+ var singletonPredicateSchema = z2.object({
1424
+ assertion: singletonAssertionSchema,
1425
+ kind: z2.literal("singleton"),
1426
+ singleton: z2.string().min(1),
1427
+ wait
1428
+ });
1429
+ var countPredicateSchema = z2.object({
1430
+ entity: z2.string().min(1),
1431
+ kind: z2.literal("count"),
1432
+ value: z2.number().int().nonnegative()
1433
+ });
1434
+ var conditionSchema = z2.lazy(
1435
+ () => z2.discriminatedUnion("kind", [
1436
+ singletonPredicateSchema,
1437
+ countPredicateSchema,
1438
+ z2.object({ kind: z2.literal("not"), predicate: conditionSchema }),
1439
+ z2.object({ kind: z2.literal("and"), predicates: z2.array(conditionSchema) })
1440
+ ])
1441
+ );
1442
+ var whenBranchSchema = z2.lazy(
1443
+ () => z2.object({
1444
+ condition: z2.union([conditionSchema, z2.undefined()]).optional().transform((value2) => value2),
1445
+ consequence: predicateSchema,
1446
+ name: z2.string().min(1)
1447
+ })
1448
+ );
1360
1449
  var predicateSchema = z2.lazy(
1361
1450
  () => z2.discriminatedUnion("kind", [
1362
1451
  z2.object({ kind: z2.literal("visible"), locator: locatorSchema, wait }),
@@ -1365,12 +1454,7 @@ var predicateSchema = z2.lazy(
1365
1454
  z2.object({ kind: z2.literal("focused"), locator: locatorSchema, wait }),
1366
1455
  z2.object({ kind: z2.literal("value"), locator: locatorSchema, value: stringValueSchema, wait }),
1367
1456
  z2.object({ kind: z2.literal("text"), locator: locatorSchema, value: stringValueSchema, wait }),
1368
- z2.object({
1369
- assertion: singletonAssertionSchema,
1370
- kind: z2.literal("singleton"),
1371
- singleton: z2.string().min(1),
1372
- wait
1373
- }),
1457
+ singletonPredicateSchema,
1374
1458
  z2.object({
1375
1459
  kind: z2.literal("browser"),
1376
1460
  name: browserSingletonSchema,
@@ -1386,17 +1470,8 @@ var predicateSchema = z2.lazy(
1386
1470
  }),
1387
1471
  z2.object({ kind: z2.literal("not"), predicate: predicateSchema }),
1388
1472
  z2.object({ kind: z2.literal("and"), predicates: z2.array(predicateSchema) }),
1389
- z2.object({
1390
- entity: z2.string().min(1),
1391
- kind: z2.literal("count"),
1392
- value: z2.number().int().nonnegative()
1393
- }),
1394
- z2.object({
1395
- condition: predicateSchema,
1396
- consequence: predicateSchema,
1397
- kind: z2.literal("when"),
1398
- otherwise: z2.union([predicateSchema, z2.undefined()]).optional().transform((value2) => value2)
1399
- })
1473
+ countPredicateSchema,
1474
+ z2.object({ branches: z2.array(whenBranchSchema), kind: z2.literal("when") })
1400
1475
  ])
1401
1476
  );
1402
1477
 
@@ -1497,7 +1572,19 @@ var absenceSchema = z4.object({
1497
1572
  entity: z4.string().min(1),
1498
1573
  where: z4.record(z4.string().min(1), setValueSchema)
1499
1574
  });
1500
- var testSchema = z4.object({
1575
+ var resolvedTestSchema = z4.object({
1576
+ absent: z4.array(absenceSchema).default([]),
1577
+ exclusive: z4.array(z4.string().min(1)).default([]),
1578
+ intent: z4.string().min(1),
1579
+ name: z4.string().min(1),
1580
+ params: z4.record(z4.string().min(1), paramSchema),
1581
+ singletons: z4.record(z4.string().min(1), setValueSchema).default({}),
1582
+ slug: z4.string().min(1),
1583
+ steps: z4.array(stepSchema).default([]),
1584
+ workflow: z4.string().min(1),
1585
+ world: z4.array(setupSchema).default([])
1586
+ });
1587
+ var workflowSchema = z4.object({
1501
1588
  absent: z4.array(absenceSchema).default([]),
1502
1589
  exclusive: z4.array(z4.string().min(1)).default([]),
1503
1590
  intent: z4.string().min(1),
@@ -1508,6 +1595,7 @@ var testSchema = z4.object({
1508
1595
  sourcePath: z4.string().min(1).optional(),
1509
1596
  steps: z4.array(stepSchema).default([]),
1510
1597
  stub: z4.boolean().default(false),
1598
+ tests: z4.array(resolvedTestSchema).default([]),
1511
1599
  world: z4.array(setupSchema).default([])
1512
1600
  });
1513
1601
  var fixtureEntrySchema = z4.object({
@@ -1518,8 +1606,8 @@ var lockfileSchema = z4.object({
1518
1606
  entities: z4.array(entitySchemaSchema),
1519
1607
  fixtures: z4.record(z4.string().min(1), fixtureEntrySchema).default({}),
1520
1608
  singletons: z4.array(singletonSchemaSchema).default([]),
1521
- tests: z4.array(testSchema),
1522
- valueSpaces: z4.array(valueSpaceSchema)
1609
+ valueSpaces: z4.array(valueSpaceSchema),
1610
+ workflows: z4.array(workflowSchema)
1523
1611
  });
1524
1612
  var lockfileCodec = defineCodec({ name: "ripplo-lockfile", schema: lockfileSchema });
1525
1613
 
@@ -1531,6 +1619,9 @@ var stepDescriptorSchema = z5.object({
1531
1619
  target: z5.string(),
1532
1620
  value: z5.string()
1533
1621
  });
1622
+ function slugify2(name) {
1623
+ return name.toLowerCase().replaceAll(/[^a-z0-9]/g, "-").split("-").filter((part) => part.length > 0).join("-");
1624
+ }
1534
1625
 
1535
1626
  // ../spec/src/session.ts
1536
1627
  import { z as z6 } from "zod";
@@ -1618,17 +1709,491 @@ function mountClientEngine(ripplo, impls, { enabled: enabled2 }) {
1618
1709
  Reflect.set(globalThis, CLIENT_MOUNT_KEY, createClientEngine(ripplo, impls));
1619
1710
  }
1620
1711
 
1712
+ // src/expand.ts
1713
+ var MAX_CANDIDATES = 4096;
1714
+ function singletonValuesOf(singletons) {
1715
+ return new Map(
1716
+ singletons.flatMap((singleton2) => {
1717
+ const space = singleton2.valueSpaces.find((s) => s.name === singleton2.schema.valueSpace);
1718
+ return space?.values == null ? [] : [[singleton2.schema.name, space.values]];
1719
+ })
1720
+ );
1721
+ }
1722
+ function expandWorkflow(workflow2, options) {
1723
+ if (workflow2.stub) {
1724
+ return { ...workflow2, tests: [] };
1725
+ }
1726
+ assertUniqueBranchNames(workflow2);
1727
+ const targets = collectTargets(workflow2.steps);
1728
+ const whenIds = new Map(targets.map((target, index) => [target.when, index]));
1729
+ const sims = proposeCandidates(workflow2, options).map((candidate) => simulate(workflow2, candidate)).filter((sim) => sim != null);
1730
+ if (targets.length === 0) {
1731
+ return { ...workflow2, tests: [requireMainTest(workflow2, sims)] };
1732
+ }
1733
+ const { tests } = targets.reduce(
1734
+ (acc, target) => coverTarget({ acc, sims, target, whenIds, workflow: workflow2 }),
1735
+ { covered: /* @__PURE__ */ new Set(), tests: [] }
1736
+ );
1737
+ return { ...workflow2, tests };
1738
+ }
1739
+ function resolveValidTest(workflow2, sim, name) {
1740
+ const test = resolveTest(workflow2, sim, name);
1741
+ return danglingRefHeads(test).length === 0 ? test : null;
1742
+ }
1743
+ function danglingRefHeads(test) {
1744
+ const known = /* @__PURE__ */ new Set([
1745
+ ...test.world.map((setup) => setup.as),
1746
+ ...Object.keys(test.params),
1747
+ ...test.steps.flatMap((step) => createdAliasesIn(step))
1748
+ ]);
1749
+ return [...new Set(collectRefObjects(test).map((ref) => headOf(ref)))].filter(
1750
+ (head) => !known.has(head)
1751
+ );
1752
+ }
1753
+ function createdAliasesIn(step) {
1754
+ return step.expect.flatMap((predicate) => {
1755
+ if (predicate.kind !== "state" || predicate.assertion.kind === "deleted") {
1756
+ return [];
1757
+ }
1758
+ return [predicate.assertion.as];
1759
+ });
1760
+ }
1761
+ function coverTarget({ acc, sims, target, whenIds, workflow: workflow2 }) {
1762
+ const key2 = choiceKey(whenIds, target.when, target.index);
1763
+ if (acc.covered.has(key2)) {
1764
+ return { covered: acc.covered, tests: acc.tests };
1765
+ }
1766
+ const picked = sims.filter((s) => s.choices.get(target.when) === target.index).reduce((found, s) => {
1767
+ if (found != null) {
1768
+ return found;
1769
+ }
1770
+ const test = resolveValidTest(workflow2, s, target.name);
1771
+ return test == null ? null : { sim: s, test };
1772
+ }, null);
1773
+ if (picked == null) {
1774
+ throw new Error(
1775
+ `workflow "${workflow2.name}": branch "${target.name}" is unreachable \u2014 no combination of optional entities and singleton values reaches it`
1776
+ );
1777
+ }
1778
+ const reached = [...picked.sim.choices].map(([when2, index]) => choiceKey(whenIds, when2, index));
1779
+ return {
1780
+ covered: /* @__PURE__ */ new Set([...acc.covered, ...reached]),
1781
+ tests: [...acc.tests, picked.test]
1782
+ };
1783
+ }
1784
+ function choiceKey(whenIds, when2, index) {
1785
+ const id2 = whenIds.get(when2);
1786
+ if (id2 == null) {
1787
+ throw new Error("internal: when node missing from target index");
1788
+ }
1789
+ return `${String(id2)}:${String(index)}`;
1790
+ }
1791
+ function requireMainTest(workflow2, sims) {
1792
+ const test = sims.reduce(
1793
+ (found, sim) => found ?? resolveValidTest(workflow2, sim, "main"),
1794
+ null
1795
+ );
1796
+ if (test == null) {
1797
+ throw new Error(
1798
+ `workflow "${workflow2.name}": no valid seed found \u2014 steps may reference optional entities no candidate provides, or a condition mentions values the solver cannot pin`
1799
+ );
1800
+ }
1801
+ return test;
1802
+ }
1803
+ function assertUniqueBranchNames(workflow2) {
1804
+ const names = collectTargets(workflow2.steps).map((target) => target.name);
1805
+ const dup = names.find((name, index) => names.indexOf(name) !== index);
1806
+ if (dup != null) {
1807
+ throw new Error(
1808
+ `workflow "${workflow2.name}": branch name "${dup}" is used twice \u2014 branch names must be unique within a workflow`
1809
+ );
1810
+ }
1811
+ }
1812
+ function collectTargets(steps) {
1813
+ return steps.flatMap((step) => step.expect.flatMap((predicate) => targetsIn(predicate)));
1814
+ }
1815
+ function targetsIn(predicate) {
1816
+ if (predicate.kind === "not") {
1817
+ return targetsIn(predicate.predicate);
1818
+ }
1819
+ if (predicate.kind === "and") {
1820
+ return predicate.predicates.flatMap((p) => targetsIn(p));
1821
+ }
1822
+ if (predicate.kind !== "when") {
1823
+ return [];
1824
+ }
1825
+ return predicate.branches.flatMap((row2, index) => [
1826
+ { index, name: row2.name, when: predicate },
1827
+ ...targetsIn(row2.consequence)
1828
+ ]);
1829
+ }
1830
+ function proposeCandidates(workflow2, options) {
1831
+ const subsets = maybeSubsets(workflow2.maybe);
1832
+ const pinSets = pinAssignments(workflow2, options);
1833
+ if (subsets.length * pinSets.length > MAX_CANDIDATES) {
1834
+ throw new Error(
1835
+ `workflow "${workflow2.name}": too many optional entities and singleton values to solve \u2014 split the workflow or convert maybe(...) entities to of(...)`
1836
+ );
1837
+ }
1838
+ return subsets.flatMap((maybes) => pinSets.map((pins) => ({ maybes, pins })));
1839
+ }
1840
+ function maybeSubsets(maybe) {
1841
+ const masks = Array.from({ length: 1 << maybe.length }, (_, mask) => mask);
1842
+ return masks.map((mask) => ({ mask, size: maybe.filter((_, i) => (mask & 1 << i) !== 0).length })).toSorted((a, b) => a.size === b.size ? a.mask - b.mask : a.size - b.size).map(({ mask }) => maybe.filter((_, i) => (mask & 1 << i) !== 0));
1843
+ }
1844
+ function pinAssignments(workflow2, options) {
1845
+ const domains = pinDomains(workflow2, options);
1846
+ return Object.entries(domains).reduce(
1847
+ (acc, [param, domain]) => acc.flatMap((pins) => domain.map((value2) => ({ ...pins, [param]: value2 }))),
1848
+ [{}]
1849
+ );
1850
+ }
1851
+ function pinDomains(workflow2, options) {
1852
+ const literals = conditionSingletonLiterals(workflow2.steps);
1853
+ return Object.entries(workflow2.singletons).reduce((acc, [name, value2]) => {
1854
+ const param = paramRefOf(value2);
1855
+ const compared = literals.get(name);
1856
+ if (param == null || compared == null) {
1857
+ return acc;
1858
+ }
1859
+ const enumValues = options.singletonValues.get(name);
1860
+ return { ...acc, [param]: withComplements(compared, enumValues) };
1861
+ }, {});
1862
+ }
1863
+ function conditionSingletonLiterals(steps) {
1864
+ const pairs = steps.flatMap(
1865
+ (step) => step.expect.flatMap((predicate) => conditionLiteralsIn(predicate))
1866
+ );
1867
+ return pairs.reduce(
1868
+ (acc, [name, literal]) => new Map([...acc, [name, [.../* @__PURE__ */ new Set([...acc.get(name) ?? [], literal])]]]),
1869
+ /* @__PURE__ */ new Map()
1870
+ );
1871
+ }
1872
+ function conditionLiteralsIn(predicate) {
1873
+ if (predicate.kind === "not") {
1874
+ return conditionLiteralsIn(predicate.predicate);
1875
+ }
1876
+ if (predicate.kind === "and") {
1877
+ return predicate.predicates.flatMap((p) => conditionLiteralsIn(p));
1878
+ }
1879
+ if (predicate.kind !== "when") {
1880
+ return [];
1881
+ }
1882
+ return predicate.branches.flatMap((row2) => [
1883
+ ...row2.condition == null ? [] : singletonLiteralsInCondition(row2.condition),
1884
+ ...conditionLiteralsIn(row2.consequence)
1885
+ ]);
1886
+ }
1887
+ function singletonLiteralsInCondition(condition) {
1888
+ switch (condition.kind) {
1889
+ case "singleton": {
1890
+ const value2 = condition.assertion.value;
1891
+ return value2 == null || typeof value2 !== "object" ? [[condition.singleton, value2]] : [];
1892
+ }
1893
+ case "count": {
1894
+ return [];
1895
+ }
1896
+ case "not": {
1897
+ return singletonLiteralsInCondition(condition.predicate);
1898
+ }
1899
+ case "and": {
1900
+ return condition.predicates.flatMap((p) => singletonLiteralsInCondition(p));
1901
+ }
1902
+ }
1903
+ }
1904
+ function paramRefOf(value2) {
1905
+ if (value2 == null || typeof value2 !== "object" || !("ref" in value2)) {
1906
+ return void 0;
1907
+ }
1908
+ return value2.ref.includes(".") ? void 0 : value2.ref;
1909
+ }
1910
+ function withComplements(values, enumValues) {
1911
+ if (enumValues != null) {
1912
+ return [.../* @__PURE__ */ new Set([...values, ...enumValues])];
1913
+ }
1914
+ const complements = values.flatMap((value2) => {
1915
+ if (typeof value2 === "boolean") {
1916
+ return [!value2];
1917
+ }
1918
+ if (typeof value2 === "number") {
1919
+ return [value2 + 1];
1920
+ }
1921
+ if (typeof value2 === "string") {
1922
+ return [syntheticDistinct(value2, values)];
1923
+ }
1924
+ return [];
1925
+ });
1926
+ return [.../* @__PURE__ */ new Set([...values, ...complements])];
1927
+ }
1928
+ function syntheticDistinct(seed, taken) {
1929
+ const candidate = `${seed}-alt`;
1930
+ return taken.includes(candidate) ? syntheticDistinct(candidate, taken) : candidate;
1931
+ }
1932
+ function simulate(workflow2, candidate) {
1933
+ const initial = {
1934
+ choices: /* @__PURE__ */ new Map(),
1935
+ state: {
1936
+ rows: [...workflow2.world, ...candidate.maybes],
1937
+ singles: seedSingles(workflow2.singletons, candidate.pins)
1938
+ }
1939
+ };
1940
+ const folded = workflow2.steps.reduce(
1941
+ (acc, step) => acc == null ? null : stepSim(acc, step),
1942
+ initial
1943
+ );
1944
+ return folded == null ? null : { candidate, choices: folded.choices };
1945
+ }
1946
+ function seedSingles(singletons, pins) {
1947
+ return Object.fromEntries(
1948
+ Object.entries(singletons).map(([name, value2]) => {
1949
+ const param = paramRefOf(value2);
1950
+ return [name, param != null && param in pins ? pins[param] ?? null : value2];
1951
+ })
1952
+ );
1953
+ }
1954
+ function stepSim(acc, step) {
1955
+ const rows = step.expect.filter((p) => p.kind === "state").reduce((current, effect) => applyEffect(current, effect), acc.state.rows);
1956
+ const preWhens = { rows, singles: acc.state.singles };
1957
+ const whens = step.expect.filter((p) => p.kind === "when");
1958
+ const resolved = whens.reduce((current, when2) => current == null ? null : foldWhen(current, when2, preWhens), {
1959
+ choices: acc.choices,
1960
+ sets: {}
1961
+ });
1962
+ if (resolved == null) {
1963
+ return null;
1964
+ }
1965
+ const immediate = singletonSets(step.expect);
1966
+ return {
1967
+ choices: resolved.choices,
1968
+ state: { rows, singles: { ...preWhens.singles, ...immediate, ...resolved.sets } }
1969
+ };
1970
+ }
1971
+ function foldWhen(acc, when2, state) {
1972
+ const picked = pickBranch(when2, state);
1973
+ if (picked === "unknown") {
1974
+ return null;
1975
+ }
1976
+ if (picked == null) {
1977
+ return acc;
1978
+ }
1979
+ const row2 = when2.branches[picked];
1980
+ if (row2 == null) {
1981
+ return acc;
1982
+ }
1983
+ const choices = new Map([...acc.choices, [when2, picked]]);
1984
+ const consequence = row2.consequence;
1985
+ if (consequence.kind === "when") {
1986
+ return foldWhen({ choices, sets: acc.sets }, consequence, state);
1987
+ }
1988
+ return { choices, sets: { ...acc.sets, ...singletonSets([consequence]) } };
1989
+ }
1990
+ function pickBranch(when2, state) {
1991
+ return when2.branches.reduce((picked, row2, index) => {
1992
+ if (picked !== void 0) {
1993
+ return picked;
1994
+ }
1995
+ if (row2.condition == null) {
1996
+ return index;
1997
+ }
1998
+ const holds = evalCondition(row2.condition, state);
1999
+ if (holds === "unknown") {
2000
+ return "unknown";
2001
+ }
2002
+ return holds ? index : void 0;
2003
+ }, void 0);
2004
+ }
2005
+ function evalCondition(condition, state) {
2006
+ switch (condition.kind) {
2007
+ case "count": {
2008
+ return state.rows.filter((row2) => row2.entity === condition.entity).length === condition.value;
2009
+ }
2010
+ case "singleton": {
2011
+ return compareValues(state.singles[condition.singleton], condition.assertion.value);
2012
+ }
2013
+ case "not": {
2014
+ return negate(evalCondition(condition.predicate, state));
2015
+ }
2016
+ case "and": {
2017
+ return conjoin(condition.predicates.map((p) => evalCondition(p, state)));
2018
+ }
2019
+ }
2020
+ }
2021
+ function compareValues(seeded, wanted) {
2022
+ if (seeded === void 0) {
2023
+ return "unknown";
2024
+ }
2025
+ const seededRef = isRefValue(seeded);
2026
+ const wantedRef = isRefValue(wanted);
2027
+ if (seededRef != null && wantedRef != null) {
2028
+ return seededRef === wantedRef ? true : "unknown";
2029
+ }
2030
+ if (seededRef != null || wantedRef != null || isTemplateValue(seeded) || isTemplateValue(wanted)) {
2031
+ return "unknown";
2032
+ }
2033
+ return sameSetValue(seeded, wanted);
2034
+ }
2035
+ function isRefValue(value2) {
2036
+ return value2 != null && typeof value2 === "object" && "ref" in value2 ? value2.ref : void 0;
2037
+ }
2038
+ function isTemplateValue(value2) {
2039
+ return value2 != null && typeof value2 === "object" && "template" in value2;
2040
+ }
2041
+ function negate(value2) {
2042
+ return value2 === "unknown" ? "unknown" : !value2;
2043
+ }
2044
+ function conjoin(values) {
2045
+ if (values.includes(false)) {
2046
+ return false;
2047
+ }
2048
+ return values.every((v2) => v2 === true) ? true : "unknown";
2049
+ }
2050
+ function singletonSets(predicates) {
2051
+ return Object.fromEntries(
2052
+ predicates.flatMap((predicate) => {
2053
+ if (predicate.kind === "singleton") {
2054
+ return [[predicate.singleton, predicate.assertion.value]];
2055
+ }
2056
+ return [];
2057
+ })
2058
+ );
2059
+ }
2060
+ function applyEffect(rows, effect) {
2061
+ if (effect.assertion.kind === "created") {
2062
+ return [
2063
+ ...rows,
2064
+ { as: effect.assertion.as, entity: effect.entity, set: effect.assertion.props }
2065
+ ];
2066
+ }
2067
+ if (effect.assertion.kind === "deleted") {
2068
+ return rows.filter((row2) => row2.entity !== effect.entity || !matchesKey(row2, effect.key, rows));
2069
+ }
2070
+ return rows;
2071
+ }
2072
+ function matchesKey(row2, key2, rows) {
2073
+ return Object.entries(key2).every(([field2, want]) => matchesField({ field: field2, row: row2, rows, want }));
2074
+ }
2075
+ function matchesField({ field: field2, row: row2, rows, want }) {
2076
+ if (isWithinValue(want)) {
2077
+ const candidates = rows.filter(
2078
+ (candidate) => candidate.entity === want.selection.entity && matchesKey(candidate, want.selection.where, rows)
2079
+ ).map((candidate) => fieldValue(candidate, want.field));
2080
+ const own = fieldValue(row2, field2);
2081
+ return candidates.some((candidate) => valuesEqual(own, candidate));
2082
+ }
2083
+ return valuesEqual(fieldValue(row2, field2), want);
2084
+ }
2085
+ function isWithinValue(value2) {
2086
+ return value2 != null && typeof value2 === "object" && "kind" in value2;
2087
+ }
2088
+ function fieldValue(row2, field2) {
2089
+ return row2.set[field2] ?? { ref: `${row2.as}.${field2}` };
2090
+ }
2091
+ function valuesEqual(a, b) {
2092
+ const aRef = isRefValue(a);
2093
+ const bRef = isRefValue(b);
2094
+ if (aRef != null || bRef != null) {
2095
+ return aRef === bRef;
2096
+ }
2097
+ if (isTemplateValue(a) || isTemplateValue(b)) {
2098
+ return false;
2099
+ }
2100
+ return sameSetValue(a, b);
2101
+ }
2102
+ function resolveTest(workflow2, sim, name) {
2103
+ const steps = workflow2.steps.map((step) => ({
2104
+ action: step.action,
2105
+ expect: step.expect.flatMap((predicate) => resolvePredicate2(predicate, sim.choices))
2106
+ }));
2107
+ const world = [...workflow2.world, ...sim.candidate.maybes];
2108
+ const singletons = seedSingles(workflow2.singletons, sim.candidate.pins);
2109
+ const used = usedRefHeads({ singletons, steps, workflow: workflow2, world });
2110
+ return {
2111
+ absent: workflow2.absent,
2112
+ exclusive: workflow2.exclusive,
2113
+ intent: workflow2.intent,
2114
+ name,
2115
+ params: Object.fromEntries(
2116
+ Object.entries(workflow2.params).filter(([param]) => used.has(param))
2117
+ ),
2118
+ singletons,
2119
+ slug: slugify2(name),
2120
+ steps,
2121
+ workflow: workflow2.name,
2122
+ world
2123
+ };
2124
+ }
2125
+ function resolvePredicate2(predicate, choices) {
2126
+ if (predicate.kind === "not") {
2127
+ const inner = resolvePredicate2(predicate.predicate, choices);
2128
+ return inner.map((p) => ({ kind: "not", predicate: p }));
2129
+ }
2130
+ if (predicate.kind === "and") {
2131
+ return [
2132
+ {
2133
+ kind: "and",
2134
+ predicates: predicate.predicates.flatMap((p) => resolvePredicate2(p, choices))
2135
+ }
2136
+ ];
2137
+ }
2138
+ if (predicate.kind !== "when") {
2139
+ return [predicate];
2140
+ }
2141
+ const picked = choices.get(predicate);
2142
+ const row2 = picked == null ? void 0 : predicate.branches[picked];
2143
+ return row2 == null ? [] : resolvePredicate2(row2.consequence, choices);
2144
+ }
2145
+ function usedRefHeads({ singletons, steps, workflow: workflow2, world }) {
2146
+ const values = [
2147
+ ...world.flatMap((setup) => Object.values(setup.set)),
2148
+ ...workflow2.absent.flatMap((absence) => Object.values(absence.where)),
2149
+ ...Object.values(singletons),
2150
+ ...steps.flatMap((step) => stepRefValues(step))
2151
+ ];
2152
+ return new Set(values.flatMap((value2) => refStrings(value2)).map((ref) => headOf(ref)));
2153
+ }
2154
+ function stepRefValues(step) {
2155
+ return collectRefObjects(step).map((ref) => ({ ref }));
2156
+ }
2157
+ function collectRefObjects(node) {
2158
+ if (Array.isArray(node)) {
2159
+ return node.flatMap((item) => collectRefObjects(item));
2160
+ }
2161
+ if (node == null || typeof node !== "object") {
2162
+ return [];
2163
+ }
2164
+ if ("ref" in node && typeof node.ref === "string" && Object.keys(node).length === 1) {
2165
+ return [node.ref];
2166
+ }
2167
+ return Object.values(node).flatMap((value2) => collectRefObjects(value2));
2168
+ }
2169
+ function refStrings(value2) {
2170
+ const ref = isRefValue(value2);
2171
+ if (ref != null) {
2172
+ return [ref];
2173
+ }
2174
+ if (value2 != null && typeof value2 === "object" && "template" in value2) {
2175
+ return value2.template.flatMap((segment) => typeof segment === "string" ? [] : [segment.ref]);
2176
+ }
2177
+ return [];
2178
+ }
2179
+ function headOf(ref) {
2180
+ const dot = ref.indexOf(".");
2181
+ return dot === -1 ? ref : ref.slice(0, dot);
2182
+ }
2183
+
1621
2184
  // src/build.ts
1622
2185
  function buildLockfile(input) {
1623
2186
  assertUniqueNames(input.entities);
1624
2187
  const lockfile = lockfileSchema.parse({
1625
2188
  entities: input.entities.map((handle) => handle.schema),
1626
2189
  singletons: input.singletons.map((handle) => handle.schema),
1627
- tests: input.tests.map((ripploTest) => ripploTest.spec),
1628
2190
  valueSpaces: dedupeByName([
1629
2191
  ...input.entities.flatMap((handle) => handle.valueSpaces),
1630
2192
  ...input.singletons.flatMap((handle) => handle.valueSpaces)
1631
- ])
2193
+ ]),
2194
+ workflows: input.workflows.map(
2195
+ (ripploWorkflow) => expandWorkflow(ripploWorkflow.spec, { singletonValues: singletonValuesOf(input.singletons) })
2196
+ )
1632
2197
  });
1633
2198
  assertNoContradictions(lockfile);
1634
2199
  return lockfile;
@@ -1645,20 +2210,20 @@ function dedupeByName(spaces) {
1645
2210
  return [...byName.values()];
1646
2211
  }
1647
2212
  function assertNoContradictions(lockfile) {
1648
- lockfile.tests.forEach((test2) => {
1649
- assertNoContradiction(test2);
1650
- assertNoDanglingRefs(test2);
2213
+ lockfile.workflows.forEach((workflow2) => {
2214
+ assertNoContradiction(workflow2);
2215
+ assertNoDanglingRefs(workflow2);
1651
2216
  });
1652
2217
  }
1653
- function assertNoContradiction(test2) {
1654
- const setups = [...test2.world, ...test2.maybe];
1655
- test2.absent.forEach((absence) => {
2218
+ function assertNoContradiction(workflow2) {
2219
+ const setups = [...workflow2.world, ...workflow2.maybe];
2220
+ workflow2.absent.forEach((absence) => {
1656
2221
  const clash = setups.find(
1657
2222
  (setup) => setup.entity === absence.entity && whereMatches(absence.where, setup.set)
1658
2223
  );
1659
2224
  if (clash != null) {
1660
2225
  throw new Error(
1661
- `test "${test2.name}": creates a "${absence.entity}" ("${clash.as}") that a none(${absence.entity}, \u2026) in its world forbids`
2226
+ `test "${workflow2.name}": creates a "${absence.entity}" ("${clash.as}") that a none(${absence.entity}, \u2026) in its world forbids`
1662
2227
  );
1663
2228
  }
1664
2229
  });
@@ -1669,19 +2234,19 @@ function whereMatches(where, set) {
1669
2234
  function sameValue(a, b) {
1670
2235
  return b !== void 0 && sameSetValue(a, b);
1671
2236
  }
1672
- function assertNoDanglingRefs(test2) {
1673
- const aliases = new Set([...test2.world, ...test2.maybe].map((setup) => setup.as));
1674
- const paramKeys = new Set(Object.keys(test2.params));
2237
+ function assertNoDanglingRefs(workflow2) {
2238
+ const aliases = new Set([...workflow2.world, ...workflow2.maybe].map((setup) => setup.as));
2239
+ const paramKeys = new Set(Object.keys(workflow2.params));
1675
2240
  const fieldSets = [
1676
- ...test2.world.map((setup) => setup.set),
1677
- ...test2.maybe.map((setup) => setup.set),
1678
- ...test2.absent.map((absence) => absence.where)
2241
+ ...workflow2.world.map((setup) => setup.set),
2242
+ ...workflow2.maybe.map((setup) => setup.set),
2243
+ ...workflow2.absent.map((absence) => absence.where)
1679
2244
  ];
1680
2245
  fieldSets.forEach((set) => {
1681
- assertSetRefs(test2.name, set, aliases, paramKeys);
2246
+ assertSetRefs(workflow2.name, set, aliases, paramKeys);
1682
2247
  });
1683
2248
  }
1684
- function assertSetRefs(testName, set, aliases, paramKeys) {
2249
+ function assertSetRefs(workflowName, set, aliases, paramKeys) {
1685
2250
  Object.values(set).forEach((value2) => {
1686
2251
  if (!isRef(value2) || paramKeys.has(value2.ref)) {
1687
2252
  return;
@@ -1693,7 +2258,7 @@ function assertSetRefs(testName, set, aliases, paramKeys) {
1693
2258
  const aliasPath = value2.ref.slice(0, lastDot);
1694
2259
  if (!aliases.has(aliasPath)) {
1695
2260
  throw new Error(
1696
- `test "${testName}": ref "${value2.ref}" points at unknown alias "${aliasPath}"`
2261
+ `test "${workflowName}": ref "${value2.ref}" points at unknown alias "${aliasPath}"`
1697
2262
  );
1698
2263
  }
1699
2264
  });
@@ -1709,10 +2274,10 @@ function createRipplo(input) {
1709
2274
  lockfile: buildLockfile({
1710
2275
  entities: input.entities,
1711
2276
  singletons: input.singletons,
1712
- tests: input.tests
2277
+ workflows: input.workflows
1713
2278
  }),
1714
2279
  singletons: input.singletons,
1715
- tests: input.tests
2280
+ workflows: input.workflows
1716
2281
  };
1717
2282
  }
1718
2283
  export {
@@ -1723,6 +2288,7 @@ export {
1723
2288
  and,
1724
2289
  arbitrary,
1725
2290
  banner,
2291
+ branch,
1726
2292
  button,
1727
2293
  cell,
1728
2294
  changed,
@@ -1784,7 +2350,6 @@ export {
1784
2350
  table,
1785
2351
  tablist,
1786
2352
  tabpanel,
1787
- test,
1788
2353
  testId,
1789
2354
  text,
1790
2355
  textbox,
@@ -1799,5 +2364,6 @@ export {
1799
2364
  viewport,
1800
2365
  visible,
1801
2366
  when,
1802
- within
2367
+ within,
2368
+ workflow
1803
2369
  };