slicetest 0.3.0 → 0.4.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/yaml.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
2
2
  const KINDS = {
3
3
  stub: ["on", "when", "reply", "sequence", "networkError", "times", "delay"],
4
- request: ["headers", "query", "json", "form", "body", "follow", "expect", "capture"],
4
+ request: ["headers", "query", "json", "form", "body", "follow", "concurrency", "expect", "capture"],
5
5
  insert: ["rows", "capture"],
6
6
  sql: ["params", "expect", "capture", "within"],
7
7
  db: ["where", "orderBy", "expect", "capture", "within"],
@@ -10,17 +10,21 @@ const KINDS = {
10
10
  log: ["from", "within"],
11
11
  checkpoint: [],
12
12
  snapshot: ["mask"],
13
+ mail: ["times", "within", "capture"],
13
14
  };
14
15
  const EXPECT_KEYS = {
15
- request: ["status", "headers", "json", "text"],
16
+ request: ["status", "statuses", "headers", "json", "text"],
16
17
  sql: ["rows", "count"],
17
18
  db: ["rows", "count"],
18
19
  };
19
20
  const CONDITION_KEYS = ["query", "headers", "json", "body"];
21
+ const MAIL_KEYS = ["to", "from", "subject", "text", "html"];
20
22
  const CHANGE_KEYS = ["inserted", "updated", "deleted"];
21
23
  const RESPONSE_KEYS = ["status", "headers", "body"];
22
24
  const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
23
25
  const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
26
+ /** A request may also go to a captured URL of the app, e.g. a link from a mail: `GET {{link}}`. */
27
+ const REQUEST = /^[A-Za-z]+\s+(\/\S*|\{\{[^}]+\}\}\S*)$/;
24
28
  export class YamlScenarioError extends Error {
25
29
  }
26
30
  export function parseScenarioFile(text, file) {
@@ -108,6 +112,14 @@ function parseStep(node, fail, lineOf) {
108
112
  }
109
113
  }
110
114
  }
115
+ else if (kind === "mail") {
116
+ const filter = raw.mail;
117
+ if (!filter || typeof filter !== "object" || Array.isArray(filter))
118
+ fail(at(kind), "`mail:` must be a mapping such as { to: alice@example.com, subject: Welcome } ({} for any)");
119
+ for (const k of Object.keys(filter))
120
+ if (!MAIL_KEYS.includes(k))
121
+ fail(at(kind), `unknown key "${k}" in mail (allowed: ${MAIL_KEYS.join(", ")})`);
122
+ }
111
123
  else if (kind === "checkpoint") {
112
124
  if (raw.checkpoint !== true)
113
125
  fail(at(kind), "use `checkpoint: true`");
@@ -131,8 +143,8 @@ function parseStep(node, fail, lineOf) {
131
143
  fail(at(key), `unknown key "${k}" in ${key} (allowed: ${allowedKeys.join(", ")})`);
132
144
  }
133
145
  };
134
- const call = (key) => {
135
- if (raw[key] !== undefined && !CALL.test(String(raw[key])))
146
+ const call = (key, pattern = CALL) => {
147
+ if (raw[key] !== undefined && !pattern.test(String(raw[key])))
136
148
  fail(at(key), `\`${key}\` must look like "POST /path", got "${raw[key]}"`);
137
149
  };
138
150
  if (raw.within !== undefined && (typeof raw.within !== "number" || raw.within <= 0))
@@ -166,9 +178,23 @@ function parseStep(node, fail, lineOf) {
166
178
  break;
167
179
  }
168
180
  case "request":
169
- call("request");
181
+ call("request", REQUEST);
170
182
  if (["json", "form", "body"].filter((k) => raw[k] !== undefined).length > 1)
171
183
  fail(node, "use only one of json / form / body");
184
+ if (raw.concurrency !== undefined && !(Number.isInteger(raw.concurrency) && raw.concurrency >= 1))
185
+ fail(at("concurrency"), "`concurrency` must be a positive whole number");
186
+ if (raw.concurrency !== undefined && raw.capture !== undefined)
187
+ fail(at("capture"), "`capture` can't be used with `concurrency`: there is more than one response");
188
+ {
189
+ const statuses = raw.expect?.statuses;
190
+ if (statuses !== undefined) {
191
+ if (raw.concurrency === undefined)
192
+ fail(at("expect"), "`expect.statuses` needs `concurrency`");
193
+ if (!statuses || typeof statuses !== "object" || Object.entries(statuses).some(([k, v]) => !/^\d{3}$/.test(k) || !Number.isInteger(v))) {
194
+ fail(at("expect"), "`expect.statuses` maps status codes to counts, e.g. { 201: 1, 409: 9 }");
195
+ }
196
+ }
197
+ }
172
198
  break;
173
199
  case "insert":
174
200
  if (!raw.rows || typeof raw.rows !== "object")
@@ -188,6 +214,9 @@ function parseStep(node, fail, lineOf) {
188
214
  call("call");
189
215
  number("times");
190
216
  break;
217
+ case "mail":
218
+ number("times");
219
+ break;
191
220
  }
192
221
  return { ...raw, line: lineOf(node) };
193
222
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "slicetest",
3
- "version": "0.3.0",
4
- "description": "Framework-agnostic slice tests: drive a real app process over HTTP with a real Postgres and stubbed outbound APIs, checked against OpenAPI contracts.",
3
+ "version": "0.4.0",
4
+ "description": "Framework-agnostic slice tests: drive a real app process over HTTP with a real Postgres, MySQL or SQLite and stubbed outbound APIs, checked against OpenAPI contracts.",
5
5
  "keywords": [
6
6
  "testing",
7
7
  "integration-testing",
@@ -9,6 +9,7 @@
9
9
  "api-testing",
10
10
  "postgres",
11
11
  "mysql",
12
+ "sqlite",
12
13
  "stub",
13
14
  "mock-server",
14
15
  "vitest",
@@ -55,7 +56,7 @@
55
56
  "build": "tsc -p tsconfig.build.json",
56
57
  "typecheck": "tsc --noEmit",
57
58
  "test": "vitest run",
58
- "test:dist": "npm run build && tsc -p examples/tsconfig.dist.json && vitest run --config examples/vitest.dist.config.ts && node dist/cli.js --config examples/slicetest.config.yaml",
59
+ "test:dist": "npm run build && tsc -p examples/tsconfig.dist.json && vitest run --config examples/vitest.dist.config.ts && node dist/cli.js --config examples/slicetest.config.yaml && node test/record-dist.mjs",
59
60
  "prepublishOnly": "npm run typecheck && npm run build"
60
61
  },
61
62
  "peerDependencies": {
@@ -180,6 +180,11 @@
180
180
  "follow": {
181
181
  "type": "boolean"
182
182
  },
183
+ "concurrency": {
184
+ "type": "integer",
185
+ "minimum": 1,
186
+ "description": "Send the request this many times at once, to provoke races. `expect` applies to every response"
187
+ },
183
188
  "expect": {
184
189
  "type": "object",
185
190
  "additionalProperties": false,
@@ -196,7 +201,18 @@
196
201
  "json": {
197
202
  "description": "Subset of the response JSON"
198
203
  },
199
- "text": {}
204
+ "text": {},
205
+ "statuses": {
206
+ "type": "object",
207
+ "propertyNames": {
208
+ "pattern": "^\\d{3}$"
209
+ },
210
+ "additionalProperties": {
211
+ "type": "integer",
212
+ "minimum": 0
213
+ },
214
+ "description": "With `concurrency`: how many responses have each status, e.g. { 201: 1, 409: 9 }"
215
+ }
200
216
  }
201
217
  },
202
218
  "capture": {
@@ -525,6 +541,138 @@
525
541
  "description": "Label shown when this step fails"
526
542
  }
527
543
  }
544
+ },
545
+ {
546
+ "type": "object",
547
+ "required": [
548
+ "mail"
549
+ ],
550
+ "additionalProperties": false,
551
+ "properties": {
552
+ "mail": {
553
+ "type": "object",
554
+ "additionalProperties": false,
555
+ "description": "The app sent mail matching these fields (needs `mail: true` in the config). to/from are exact addresses; subject/text/html are substrings. {} matches any message",
556
+ "properties": {
557
+ "to": {
558
+ "oneOf": [
559
+ {
560
+ "type": "string"
561
+ },
562
+ {
563
+ "type": "object",
564
+ "required": [
565
+ "$regex"
566
+ ],
567
+ "additionalProperties": false,
568
+ "properties": {
569
+ "$regex": {
570
+ "type": "string"
571
+ }
572
+ }
573
+ }
574
+ ]
575
+ },
576
+ "from": {
577
+ "oneOf": [
578
+ {
579
+ "type": "string"
580
+ },
581
+ {
582
+ "type": "object",
583
+ "required": [
584
+ "$regex"
585
+ ],
586
+ "additionalProperties": false,
587
+ "properties": {
588
+ "$regex": {
589
+ "type": "string"
590
+ }
591
+ }
592
+ }
593
+ ]
594
+ },
595
+ "subject": {
596
+ "oneOf": [
597
+ {
598
+ "type": "string"
599
+ },
600
+ {
601
+ "type": "object",
602
+ "required": [
603
+ "$regex"
604
+ ],
605
+ "additionalProperties": false,
606
+ "properties": {
607
+ "$regex": {
608
+ "type": "string"
609
+ }
610
+ }
611
+ }
612
+ ]
613
+ },
614
+ "text": {
615
+ "oneOf": [
616
+ {
617
+ "type": "string"
618
+ },
619
+ {
620
+ "type": "object",
621
+ "required": [
622
+ "$regex"
623
+ ],
624
+ "additionalProperties": false,
625
+ "properties": {
626
+ "$regex": {
627
+ "type": "string"
628
+ }
629
+ }
630
+ }
631
+ ]
632
+ },
633
+ "html": {
634
+ "oneOf": [
635
+ {
636
+ "type": "string"
637
+ },
638
+ {
639
+ "type": "object",
640
+ "required": [
641
+ "$regex"
642
+ ],
643
+ "additionalProperties": false,
644
+ "properties": {
645
+ "$regex": {
646
+ "type": "string"
647
+ }
648
+ }
649
+ }
650
+ ]
651
+ }
652
+ }
653
+ },
654
+ "times": {
655
+ "type": "integer",
656
+ "minimum": 0,
657
+ "description": "Exact number of matching messages. Default: at least one"
658
+ },
659
+ "within": {
660
+ "type": "integer",
661
+ "minimum": 1,
662
+ "description": "Milliseconds to wait. Default 5000"
663
+ },
664
+ "capture": {
665
+ "type": "object",
666
+ "additionalProperties": {
667
+ "type": "string"
668
+ },
669
+ "description": "Values of the last matching message: subject, text, html, from, to.0, links.0"
670
+ },
671
+ "name": {
672
+ "type": "string",
673
+ "description": "Label shown when this step fails"
674
+ }
675
+ }
528
676
  }
529
677
  ]
530
678
  },