@ripplo/testing 0.0.11 → 0.1.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.
package/dist/index.js CHANGED
@@ -1,62 +1,97 @@
1
1
  import {
2
2
  compile
3
- } from "./chunk-LEIKZ6BE.js";
3
+ } from "./chunk-KNF4K4JH.js";
4
4
  import "./chunk-MGATMMCZ.js";
5
5
  import {
6
- DEFAULT_IGNORE_PATHS,
7
- DEFAULT_WATCH_PATHS,
6
+ buildObserver,
8
7
  buildSetCookieHeader,
9
8
  createEngine,
10
- dslConfigSchema,
11
- readPreconditionName,
12
9
  serializeCookie,
13
10
  verifyWebhookSignature
14
- } from "./chunk-7ETQVVAA.js";
11
+ } from "./chunk-CD3M7H5A.js";
12
+ import {
13
+ DEFAULT_IGNORE_PATHS,
14
+ DEFAULT_WATCH_PATHS,
15
+ dslConfigSchema,
16
+ readObserverName,
17
+ readPreconditionName
18
+ } from "./chunk-3IL457A7.js";
15
19
 
16
20
  // src/builder.ts
17
21
  function createRipplo(rawConfig) {
18
22
  const config = dslConfigSchema.parse(rawConfig);
19
23
  const preconditions = [];
24
+ const observers = [];
20
25
  const tests = [];
21
26
  const preconditionNames = /* @__PURE__ */ new Set();
27
+ const observerNames = /* @__PURE__ */ new Set();
22
28
  const testNames = /* @__PURE__ */ new Set();
29
+ function implementPrecondition(handle, impl) {
30
+ const preconditionName = readPreconditionName(handle);
31
+ const idx = preconditions.findIndex((p) => p.name === preconditionName);
32
+ if (idx === -1) {
33
+ throw new Error(`Cannot implement unknown precondition: "${preconditionName}"`);
34
+ }
35
+ const existing = preconditions[idx];
36
+ if (existing == null) {
37
+ throw new Error(`Cannot implement unknown precondition: "${preconditionName}"`);
38
+ }
39
+ const mapping = existing.depMapping;
40
+ preconditions[idx] = {
41
+ ...existing,
42
+ implemented: true,
43
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TData narrows Record<string, string>, safe at engine boundary
44
+ teardown: impl.teardown,
45
+ setup: async (ctx, allDeps) => {
46
+ const resolved = {};
47
+ mapping.forEach(([key, depName]) => {
48
+ const data = allDeps[depName];
49
+ if (data != null) {
50
+ resolved[key] = data;
51
+ }
52
+ });
53
+ return impl.setup(ctx, resolved);
54
+ }
55
+ };
56
+ }
57
+ function implementObserver(handle, impl) {
58
+ const name = readObserverName(handle);
59
+ const idx = observers.findIndex((o) => o.name === name);
60
+ if (idx === -1) {
61
+ throw new Error(`Cannot implement unknown observer: "${name}"`);
62
+ }
63
+ const existing = observers[idx];
64
+ if (existing == null) {
65
+ throw new Error(`Cannot implement unknown observer: "${name}"`);
66
+ }
67
+ observers[idx] = {
68
+ ...existing,
69
+ implemented: true,
70
+ run: async (ctx, params) => {
71
+ return impl(ctx, params);
72
+ }
73
+ };
74
+ }
23
75
  return {
76
+ implementObserver,
77
+ implementPrecondition,
24
78
  getConfig: () => config,
79
+ getObservers: () => observers,
25
80
  getPreconditions: () => preconditions,
26
81
  getTests: () => tests,
27
82
  getUnimplemented() {
28
83
  return {
84
+ observers: observers.filter((o) => !o.implemented).map((o) => o.name),
29
85
  preconditions: preconditions.filter((p) => !p.implemented).map((p) => p.name),
30
86
  tests: tests.filter((t) => !t.implemented).map((t) => t.name)
31
87
  };
32
88
  },
33
- implement(handle, impl) {
34
- const preconditionName = readPreconditionName(handle);
35
- const idx = preconditions.findIndex((p) => p.name === preconditionName);
36
- if (idx === -1) {
37
- throw new Error(`Cannot implement unknown precondition: "${preconditionName}"`);
89
+ observer(name) {
90
+ if (observerNames.has(name)) {
91
+ observers.splice(0, observers.length, ...observers.filter((o) => o.name !== name));
38
92
  }
39
- const existing = preconditions[idx];
40
- if (existing == null) {
41
- throw new Error(`Cannot implement unknown precondition: "${preconditionName}"`);
42
- }
43
- const mapping = existing.depMapping;
44
- preconditions[idx] = {
45
- ...existing,
46
- implemented: true,
47
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TData narrows Record<string, string>, safe at engine boundary
48
- teardown: impl.teardown,
49
- setup: async (ctx, allDeps) => {
50
- const resolved = {};
51
- mapping.forEach(([key, depName]) => {
52
- const data = allDeps[depName];
53
- if (data != null) {
54
- resolved[key] = data;
55
- }
56
- });
57
- return impl.setup(ctx, resolved);
58
- }
59
- };
93
+ observerNames.add(name);
94
+ return buildObserver({ name, observers });
60
95
  },
61
96
  precondition(name) {
62
97
  if (preconditionNames.has(name)) {
@@ -69,13 +104,13 @@ function createRipplo(rawConfig) {
69
104
  preconditionNames.add(name);
70
105
  return buildPrecondition(name, preconditions);
71
106
  },
72
- test(id) {
107
+ test(id, options) {
73
108
  validateTestId(id);
74
109
  if (testNames.has(id)) {
75
110
  tests.splice(0, tests.length, ...tests.filter((t) => t.id !== id));
76
111
  }
77
112
  testNames.add(id);
78
- return buildTestName(id, tests);
113
+ return buildTestName(id, tests, options?.uiOnly);
79
114
  }
80
115
  };
81
116
  }
@@ -187,14 +222,19 @@ function validateTestId(id) {
187
222
  );
188
223
  }
189
224
  }
190
- function buildTestName(id, tests) {
225
+ function buildTestName(id, tests, uiOnly) {
191
226
  return {
192
227
  name(displayName) {
193
- return buildTestRequires({ id, name: displayName, tests });
228
+ return buildTestRequires({ id, name: displayName, tests, uiOnly });
194
229
  }
195
230
  };
196
231
  }
197
- function buildTestRequires({ id, name, tests }) {
232
+ function buildTestRequires({
233
+ id,
234
+ name,
235
+ tests,
236
+ uiOnly
237
+ }) {
198
238
  let description = "";
199
239
  const self = {
200
240
  requires: castBuilder(requiresImpl),
@@ -210,7 +250,7 @@ function buildTestRequires({ id, name, tests }) {
210
250
  Object.entries(reqs).forEach(([key, precondition]) => {
211
251
  requiresKeys[key] = readPreconditionName(precondition);
212
252
  });
213
- return buildOutcome({ description, id, name, reqNames, requiresKeys, tests });
253
+ return buildOutcome({ description, id, name, reqNames, requiresKeys, tests, uiOnly });
214
254
  }
215
255
  }
216
256
  function buildOutcome({
@@ -219,7 +259,8 @@ function buildOutcome({
219
259
  name,
220
260
  reqNames,
221
261
  requiresKeys,
222
- tests
262
+ tests,
263
+ uiOnly
223
264
  }) {
224
265
  let description = initialDesc;
225
266
  return {
@@ -235,7 +276,8 @@ function buildOutcome({
235
276
  name,
236
277
  reqNames,
237
278
  requiresKeys,
238
- tests
279
+ tests,
280
+ uiOnly
239
281
  });
240
282
  }
241
283
  };
@@ -247,7 +289,8 @@ function buildStartsAt({
247
289
  name,
248
290
  reqNames,
249
291
  requiresKeys,
250
- tests
292
+ tests,
293
+ uiOnly
251
294
  }) {
252
295
  return {
253
296
  startsAt: castBuilder(startsAtImpl),
@@ -261,7 +304,8 @@ function buildStartsAt({
261
304
  requires: [...reqNames],
262
305
  requiresKeys,
263
306
  startsAtFn: void 0,
264
- stepsFn: void 0
307
+ stepsFn: void 0,
308
+ uiOnly
265
309
  });
266
310
  }
267
311
  };
@@ -279,7 +323,8 @@ function buildStartsAt({
279
323
  requires: [...reqNames],
280
324
  requiresKeys,
281
325
  startsAtFn: fn,
282
- stepsFn
326
+ stepsFn,
327
+ uiOnly
283
328
  });
284
329
  }
285
330
  }
@@ -624,6 +669,73 @@ function expectedOutcomeKeywordCoverage(nodes, test, report) {
624
669
  });
625
670
  }
626
671
  }
672
+ var BACKEND_MUTATION_KEYWORDS = /save|submit|create|delete|remove|send|invite|update|confirm|publish|apply|pay|subscribe|upgrade|cancel|archive|rename/i;
673
+ function isLikelyBackendMutation(node) {
674
+ if (node.type === "upload") {
675
+ return true;
676
+ }
677
+ if (node.type === "handleDialog" && node.action === "accept") {
678
+ return true;
679
+ }
680
+ if (node.type !== "click") {
681
+ return false;
682
+ }
683
+ const loc = node.locator;
684
+ const name = loc.by === "role" ? loc.name ?? "" : loc.value;
685
+ return BACKEND_MUTATION_KEYWORDS.test(name);
686
+ }
687
+ function mutationWithoutObserverCoverage(nodes, test, report) {
688
+ if (!test.implemented || test.spec.uiOnly === true) {
689
+ return;
690
+ }
691
+ nodes.forEach((node, index) => {
692
+ if (!isLikelyBackendMutation(node)) {
693
+ return;
694
+ }
695
+ if ("uiOnly" in node && node.uiOnly === true) {
696
+ return;
697
+ }
698
+ const rest = nodes.slice(index + 1);
699
+ const nextMutationIdx = rest.findIndex((n) => isLikelyBackendMutation(n));
700
+ const windowEnd = nextMutationIdx === -1 ? rest.length : nextMutationIdx;
701
+ const window = rest.slice(0, windowEnd);
702
+ const hasObserver = window.some((n) => n.type === "assertObserver");
703
+ if (hasObserver) {
704
+ return;
705
+ }
706
+ report({
707
+ 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.`,
708
+ rule: "mutation-without-observer-coverage",
709
+ step: node.label ?? node.id
710
+ });
711
+ });
712
+ }
713
+ function observerParamsReferenceVariables(nodes, test, report) {
714
+ const variableKeys = Object.keys(test.spec.variables ?? {});
715
+ if (variableKeys.length === 0) {
716
+ return;
717
+ }
718
+ nodes.forEach((node) => {
719
+ if (node.type !== "assertObserver") {
720
+ return;
721
+ }
722
+ const paramEntries = Object.entries(node.params);
723
+ if (paramEntries.length === 0) {
724
+ return;
725
+ }
726
+ const anyReferencesVariable = paramEntries.some(([, ref]) => {
727
+ return isStaticStringValue(ref) && isTemplateVar(ref.value);
728
+ });
729
+ if (anyReferencesVariable) {
730
+ return;
731
+ }
732
+ report({
733
+ 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`,
734
+ rule: "observer-params-reference-variables",
735
+ step: node.label ?? node.id
736
+ });
737
+ });
738
+ }
627
739
  var RULES = [
628
740
  exactTextMatch,
629
741
  noHardcodedData,
@@ -636,7 +748,9 @@ var RULES = [
636
748
  noAssertions,
637
749
  lowAssertionRatio,
638
750
  tautologicalPostClickAssert,
639
- expectedOutcomeKeywordCoverage
751
+ expectedOutcomeKeywordCoverage,
752
+ mutationWithoutObserverCoverage,
753
+ observerParamsReferenceVariables
640
754
  ];
641
755
  export {
642
756
  DEFAULT_IGNORE_PATHS,