slicetest 0.1.0 → 0.2.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/README.md +151 -11
- package/dist/app.d.ts +26 -3
- package/dist/app.js +80 -18
- package/dist/cli.js +18 -6
- package/dist/config.d.ts +68 -9
- package/dist/config.js +58 -22
- package/dist/db.d.ts +37 -5
- package/dist/db.js +149 -81
- package/dist/drivers/driver.d.ts +65 -0
- package/dist/drivers/driver.js +6 -0
- package/dist/drivers/index.d.ts +5 -0
- package/dist/drivers/index.js +5 -0
- package/dist/drivers/postgres.d.ts +19 -0
- package/dist/drivers/postgres.js +164 -0
- package/dist/global-setup.d.ts +7 -1
- package/dist/global-setup.js +132 -30
- package/dist/http.d.ts +3 -0
- package/dist/http.js +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/init.d.ts +18 -0
- package/dist/init.js +134 -0
- package/dist/openapi.d.ts +56 -0
- package/dist/openapi.js +305 -0
- package/dist/provided.d.ts +1 -0
- package/dist/runtime.d.ts +7 -1
- package/dist/runtime.js +163 -28
- package/dist/scenario.js +2 -2
- package/dist/stub.d.ts +14 -0
- package/dist/stub.js +34 -9
- package/dist/yaml-runtime.js +61 -1
- package/dist/yaml.d.ts +30 -1
- package/dist/yaml.js +39 -4
- package/package.json +8 -4
- package/schema/scenario.schema.json +132 -1
package/dist/stub.d.ts
CHANGED
|
@@ -11,6 +11,14 @@ export interface RecordedCall {
|
|
|
11
11
|
params: Record<string, string>;
|
|
12
12
|
/** Whether a registered route answered this call. */
|
|
13
13
|
matched: boolean;
|
|
14
|
+
/** What the stub answered, once it has. */
|
|
15
|
+
response?: {
|
|
16
|
+
status: number;
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
body: string;
|
|
19
|
+
};
|
|
20
|
+
/** Answered by the fallback (e.g. an example from the provider's OpenAPI spec), not a registered route. */
|
|
21
|
+
fallback?: boolean;
|
|
14
22
|
}
|
|
15
23
|
export interface StubResponse {
|
|
16
24
|
status?: number;
|
|
@@ -68,6 +76,12 @@ export declare class Stub {
|
|
|
68
76
|
/** Human-readable list of registered routes, for diagnostics. */
|
|
69
77
|
describeRoutes(): string[];
|
|
70
78
|
reset(): void;
|
|
79
|
+
/**
|
|
80
|
+
* Answer calls that no registered route matches, instead of failing with 501.
|
|
81
|
+
* Return undefined to leave a call unanswered (it then fails the scenario as usual).
|
|
82
|
+
* Kept across scenarios, unlike routes.
|
|
83
|
+
*/
|
|
84
|
+
fallback(respond: ((call: RecordedCall) => StubResponse | undefined) | undefined): this;
|
|
71
85
|
close(): Promise<void>;
|
|
72
86
|
}
|
|
73
87
|
/** `expected` is contained in `actual`: objects compare key by key, arrays element-wise. */
|
package/dist/stub.js
CHANGED
|
@@ -9,6 +9,7 @@ export class Stub {
|
|
|
9
9
|
#server;
|
|
10
10
|
#routes = [];
|
|
11
11
|
#calls = [];
|
|
12
|
+
#fallback;
|
|
12
13
|
url = "";
|
|
13
14
|
constructor(name) {
|
|
14
15
|
this.name = name;
|
|
@@ -80,6 +81,15 @@ export class Stub {
|
|
|
80
81
|
this.#routes = [];
|
|
81
82
|
this.#calls = [];
|
|
82
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Answer calls that no registered route matches, instead of failing with 501.
|
|
86
|
+
* Return undefined to leave a call unanswered (it then fails the scenario as usual).
|
|
87
|
+
* Kept across scenarios, unlike routes.
|
|
88
|
+
*/
|
|
89
|
+
fallback(respond) {
|
|
90
|
+
this.#fallback = respond;
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
83
93
|
async close() {
|
|
84
94
|
this.#server.closeAllConnections();
|
|
85
95
|
await new Promise((resolve) => this.#server.close(resolve));
|
|
@@ -115,7 +125,14 @@ export class Stub {
|
|
|
115
125
|
break;
|
|
116
126
|
}
|
|
117
127
|
if (!route) {
|
|
118
|
-
|
|
128
|
+
const out = this.#fallback?.(call);
|
|
129
|
+
if (!out) {
|
|
130
|
+
res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${call.method} ${call.path}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
call.matched = true;
|
|
134
|
+
call.fallback = true;
|
|
135
|
+
this.#send(call, res, out);
|
|
119
136
|
return;
|
|
120
137
|
}
|
|
121
138
|
call.matched = true;
|
|
@@ -128,19 +145,27 @@ export class Stub {
|
|
|
128
145
|
return;
|
|
129
146
|
}
|
|
130
147
|
try {
|
|
131
|
-
|
|
132
|
-
const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
|
|
133
|
-
const headers = { ...out.headers };
|
|
134
|
-
if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
|
|
135
|
-
headers["content-type"] = "application/json";
|
|
136
|
-
}
|
|
137
|
-
res.writeHead(out.status ?? 200, headers);
|
|
138
|
-
res.end(raw ? out.body : JSON.stringify(out.body));
|
|
148
|
+
this.#send(call, res, typeof route.respond === "function" ? await route.respond(call) : route.respond);
|
|
139
149
|
}
|
|
140
150
|
catch (e) {
|
|
141
151
|
res.writeHead(500).end(`slicetest: stub handler threw: ${e}`);
|
|
142
152
|
}
|
|
143
153
|
}
|
|
154
|
+
#send(call, res, out) {
|
|
155
|
+
const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
|
|
156
|
+
const headers = { ...out.headers };
|
|
157
|
+
if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
|
|
158
|
+
headers["content-type"] = "application/json";
|
|
159
|
+
}
|
|
160
|
+
const payload = raw ? out.body : JSON.stringify(out.body);
|
|
161
|
+
call.response = {
|
|
162
|
+
status: out.status ?? 200,
|
|
163
|
+
headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])),
|
|
164
|
+
body: payload === undefined ? "" : typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8"),
|
|
165
|
+
};
|
|
166
|
+
res.writeHead(out.status ?? 200, headers);
|
|
167
|
+
res.end(payload);
|
|
168
|
+
}
|
|
144
169
|
}
|
|
145
170
|
function compilePath(path) {
|
|
146
171
|
if (typeof path !== "string" || !path.includes("/:"))
|
package/dist/yaml-runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expect } from "vitest";
|
|
2
|
+
import { formatChanges } from "./db.js";
|
|
2
3
|
import "./matchers.js";
|
|
3
4
|
import { scenario } from "./scenario.js";
|
|
4
5
|
/** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
|
|
@@ -15,7 +16,8 @@ export function defineYamlScenarios(doc) {
|
|
|
15
16
|
async function runSteps(doc, sc, steps, ctx, vars) {
|
|
16
17
|
for (const [i, step] of steps.entries()) {
|
|
17
18
|
try {
|
|
18
|
-
|
|
19
|
+
// A log step waits by itself; `within` is its timeout.
|
|
20
|
+
await retry("within" in step && !("log" in step) ? step.within : undefined, () => runStep(step, ctx, vars));
|
|
19
21
|
}
|
|
20
22
|
catch (e) {
|
|
21
23
|
const label = step.name ?? describeStep(step);
|
|
@@ -39,9 +41,67 @@ function describeStep(step) {
|
|
|
39
41
|
return `insert ${step.insert}`;
|
|
40
42
|
if ("db" in step)
|
|
41
43
|
return `db ${step.db}`;
|
|
44
|
+
if ("changes" in step)
|
|
45
|
+
return "changes";
|
|
46
|
+
if ("checkpoint" in step)
|
|
47
|
+
return "checkpoint";
|
|
48
|
+
if ("log" in step)
|
|
49
|
+
return `log ${step.from ? `from ${step.from} ` : ""}/${step.log}/`;
|
|
42
50
|
return "sql";
|
|
43
51
|
}
|
|
52
|
+
/** Run `fn` until it passes or `within` ms have passed, then rethrow its last error. */
|
|
53
|
+
async function retry(within, fn) {
|
|
54
|
+
if (!within)
|
|
55
|
+
return fn();
|
|
56
|
+
const deadline = Date.now() + within;
|
|
57
|
+
for (;;) {
|
|
58
|
+
try {
|
|
59
|
+
return await fn();
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
if (Date.now() >= deadline) {
|
|
63
|
+
if (e instanceof Error)
|
|
64
|
+
e.message = `${e.message}\n(still failing after retrying for ${within}ms)`;
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
44
71
|
async function runStep(step, ctx, vars) {
|
|
72
|
+
if ("checkpoint" in step) {
|
|
73
|
+
await ctx.db.checkpoint();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if ("log" in step) {
|
|
77
|
+
const target = step.from ? ctx.service(step.from) : ctx.app;
|
|
78
|
+
await target.waitForLog(new RegExp(interpolate(step.log, vars)), step.within ?? 5000);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if ("changes" in step) {
|
|
82
|
+
const actual = await ctx.db.changes();
|
|
83
|
+
const expected = interpolate(step.changes, vars);
|
|
84
|
+
const unexpected = Object.keys(actual).filter((t) => !(t in expected));
|
|
85
|
+
if (unexpected.length) {
|
|
86
|
+
throw new Error(`unexpected changes in ${unexpected.join(", ")}:\n${formatChanges(Object.fromEntries(unexpected.map((t) => [t, actual[t]])))}`);
|
|
87
|
+
}
|
|
88
|
+
for (const [table, spec] of Object.entries(expected)) {
|
|
89
|
+
const got = actual[table] ?? { inserted: [], updated: [], deleted: [] };
|
|
90
|
+
for (const kind of ["inserted", "updated", "deleted"]) {
|
|
91
|
+
const want = spec[kind];
|
|
92
|
+
if (want === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
const rows = kind === "updated" ? got.updated.map((u) => u.after) : got[kind];
|
|
95
|
+
if (typeof want === "number") {
|
|
96
|
+
if (rows.length !== want)
|
|
97
|
+
throw new Error(`expected ${want} row(s) ${kind} in ${table}, got ${rows.length}\n${formatChanges({ [table]: got }) || " (no changes)"}`);
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
check(rows, want, vars, `${kind} rows of ${table}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
45
105
|
if ("stub" in step) {
|
|
46
106
|
const [method, path] = splitCall(step.on);
|
|
47
107
|
let route = ctx.stub(step.stub).on(method, interpolate(path, vars), conditions(step.when, vars));
|
package/dist/yaml.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface YamlScenario {
|
|
|
19
19
|
timeout?: number;
|
|
20
20
|
steps: Step[];
|
|
21
21
|
}
|
|
22
|
-
export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep) & {
|
|
22
|
+
export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep) & {
|
|
23
23
|
line: number;
|
|
24
24
|
name?: string;
|
|
25
25
|
};
|
|
@@ -76,6 +76,7 @@ export interface SqlStep {
|
|
|
76
76
|
count?: number;
|
|
77
77
|
};
|
|
78
78
|
capture?: Record<string, string>;
|
|
79
|
+
within?: number;
|
|
79
80
|
}
|
|
80
81
|
export interface DbStep {
|
|
81
82
|
db: string;
|
|
@@ -86,6 +87,8 @@ export interface DbStep {
|
|
|
86
87
|
count?: number;
|
|
87
88
|
};
|
|
88
89
|
capture?: Record<string, string>;
|
|
90
|
+
/** Retry for up to this many milliseconds, for effects the app applies asynchronously. */
|
|
91
|
+
within?: number;
|
|
89
92
|
}
|
|
90
93
|
export interface ReceivedStep {
|
|
91
94
|
received: string;
|
|
@@ -93,6 +96,32 @@ export interface ReceivedStep {
|
|
|
93
96
|
when?: Conditions;
|
|
94
97
|
/** Exact number of matching calls. Default: at least one. */
|
|
95
98
|
times?: number;
|
|
99
|
+
within?: number;
|
|
100
|
+
}
|
|
101
|
+
/** Expected rows per table: a count, or a list of subset rows. */
|
|
102
|
+
export type ChangeSpec = {
|
|
103
|
+
inserted?: number | unknown[];
|
|
104
|
+
updated?: number | unknown[];
|
|
105
|
+
deleted?: number | unknown[];
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* The database changed exactly in these tables since the scenario started (or
|
|
109
|
+
* the last checkpoint). Tables that aren't listed must be unchanged.
|
|
110
|
+
*/
|
|
111
|
+
export interface ChangesStep {
|
|
112
|
+
changes: Record<string, ChangeSpec>;
|
|
113
|
+
within?: number;
|
|
114
|
+
}
|
|
115
|
+
/** Wait until the app (or `from:` a service) prints a line matching `log` (a regex) during the scenario. */
|
|
116
|
+
export interface LogStep {
|
|
117
|
+
log: string;
|
|
118
|
+
from?: string;
|
|
119
|
+
/** Milliseconds to wait. Default 5000. */
|
|
120
|
+
within?: number;
|
|
121
|
+
}
|
|
122
|
+
/** Later `changes` steps only see what happens after this step. */
|
|
123
|
+
export interface CheckpointStep {
|
|
124
|
+
checkpoint: true;
|
|
96
125
|
}
|
|
97
126
|
export declare class YamlScenarioError extends Error {
|
|
98
127
|
}
|
package/dist/yaml.js
CHANGED
|
@@ -3,9 +3,12 @@ const KINDS = {
|
|
|
3
3
|
stub: ["on", "when", "reply", "sequence", "networkError", "times", "delay"],
|
|
4
4
|
request: ["headers", "query", "json", "form", "body", "follow", "expect", "capture"],
|
|
5
5
|
insert: ["rows", "capture"],
|
|
6
|
-
sql: ["params", "expect", "capture"],
|
|
7
|
-
db: ["where", "orderBy", "expect", "capture"],
|
|
8
|
-
received: ["call", "when", "times"],
|
|
6
|
+
sql: ["params", "expect", "capture", "within"],
|
|
7
|
+
db: ["where", "orderBy", "expect", "capture", "within"],
|
|
8
|
+
received: ["call", "when", "times", "within"],
|
|
9
|
+
changes: ["within"],
|
|
10
|
+
log: ["from", "within"],
|
|
11
|
+
checkpoint: [],
|
|
9
12
|
};
|
|
10
13
|
const EXPECT_KEYS = {
|
|
11
14
|
request: ["status", "headers", "json", "text"],
|
|
@@ -13,6 +16,7 @@ const EXPECT_KEYS = {
|
|
|
13
16
|
db: ["rows", "count"],
|
|
14
17
|
};
|
|
15
18
|
const CONDITION_KEYS = ["query", "headers", "json", "body"];
|
|
19
|
+
const CHANGE_KEYS = ["inserted", "updated", "deleted"];
|
|
16
20
|
const RESPONSE_KEYS = ["status", "headers", "body"];
|
|
17
21
|
const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
|
|
18
22
|
const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
|
|
@@ -88,7 +92,26 @@ function parseStep(node, fail, lineOf) {
|
|
|
88
92
|
if (!allowed.has(key))
|
|
89
93
|
fail(at(key), `unknown key "${key}" in a ${kind} step (allowed: ${[...allowed].join(", ")})`);
|
|
90
94
|
}
|
|
91
|
-
if (
|
|
95
|
+
if (kind === "changes") {
|
|
96
|
+
const tables = raw.changes;
|
|
97
|
+
if (!tables || typeof tables !== "object" || Array.isArray(tables))
|
|
98
|
+
fail(at(kind), "`changes:` must map table names to { inserted, updated, deleted }");
|
|
99
|
+
for (const [table, spec] of Object.entries(tables)) {
|
|
100
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec))
|
|
101
|
+
fail(at(kind), `changes of "${table}" must be a mapping such as { inserted: 1 }`);
|
|
102
|
+
for (const [k, v] of Object.entries(spec)) {
|
|
103
|
+
if (!CHANGE_KEYS.includes(k))
|
|
104
|
+
fail(at(kind), `unknown key "${k}" in changes of "${table}" (allowed: ${CHANGE_KEYS.join(", ")})`);
|
|
105
|
+
if (!Array.isArray(v) && !(typeof v === "number" && v >= 0))
|
|
106
|
+
fail(at(kind), `${table}.${k} must be a count or a list of rows`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (kind === "checkpoint") {
|
|
111
|
+
if (raw.checkpoint !== true)
|
|
112
|
+
fail(at(kind), "use `checkpoint: true`");
|
|
113
|
+
}
|
|
114
|
+
else if (typeof raw[kind] !== "string" || !raw[kind])
|
|
92
115
|
fail(at(kind), `\`${kind}:\` must be a non-empty string`);
|
|
93
116
|
const keysOf = (key, allowedKeys) => {
|
|
94
117
|
const v = raw[key];
|
|
@@ -105,6 +128,8 @@ function parseStep(node, fail, lineOf) {
|
|
|
105
128
|
if (raw[key] !== undefined && !CALL.test(String(raw[key])))
|
|
106
129
|
fail(at(key), `\`${key}\` must look like "POST /path", got "${raw[key]}"`);
|
|
107
130
|
};
|
|
131
|
+
if (raw.within !== undefined && (typeof raw.within !== "number" || raw.within <= 0))
|
|
132
|
+
fail(at("within"), "`within` must be a positive number of milliseconds");
|
|
108
133
|
const number = (key) => {
|
|
109
134
|
if (raw[key] !== undefined && (typeof raw[key] !== "number" || raw[key] < 0))
|
|
110
135
|
fail(at(key), `\`${key}\` must be a non-negative number`);
|
|
@@ -142,6 +167,16 @@ function parseStep(node, fail, lineOf) {
|
|
|
142
167
|
if (!raw.rows || typeof raw.rows !== "object")
|
|
143
168
|
fail(at("rows"), "an insert step needs `rows` (a mapping or a list of mappings)");
|
|
144
169
|
break;
|
|
170
|
+
case "log":
|
|
171
|
+
try {
|
|
172
|
+
new RegExp(raw.log);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
fail(at("log"), `\`log\` must be a regular expression: ${e.message}`);
|
|
176
|
+
}
|
|
177
|
+
if (raw.from !== undefined && typeof raw.from !== "string")
|
|
178
|
+
fail(at("from"), "`from` must be a service name");
|
|
179
|
+
break;
|
|
145
180
|
case "received":
|
|
146
181
|
call("call");
|
|
147
182
|
number("times");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slicetest",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Framework-agnostic slice tests: drive a real app process over HTTP with a real Postgres and stubbed outbound APIs.",
|
|
3
|
+
"version": "0.2.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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"testing",
|
|
7
7
|
"integration-testing",
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"vitest-plugin",
|
|
15
15
|
"yaml",
|
|
16
16
|
"testcontainers",
|
|
17
|
-
"language-agnostic"
|
|
17
|
+
"language-agnostic",
|
|
18
|
+
"openapi",
|
|
19
|
+
"contract-testing"
|
|
18
20
|
],
|
|
19
21
|
"author": "pon-bok",
|
|
20
22
|
"repository": {
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
"build": "tsc -p tsconfig.build.json",
|
|
53
55
|
"typecheck": "tsc --noEmit",
|
|
54
56
|
"test": "vitest run",
|
|
55
|
-
"test:dist": "npm run build && vitest run --config examples/vitest.dist.config.ts && node dist/cli.js --config examples/slicetest.config.yaml",
|
|
57
|
+
"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",
|
|
56
58
|
"prepublishOnly": "npm run typecheck && npm run build"
|
|
57
59
|
},
|
|
58
60
|
"peerDependencies": {
|
|
@@ -60,6 +62,8 @@
|
|
|
60
62
|
},
|
|
61
63
|
"dependencies": {
|
|
62
64
|
"@testcontainers/postgresql": "^12.2.0",
|
|
65
|
+
"ajv": "^8.20.0",
|
|
66
|
+
"ajv-formats": "^3.0.1",
|
|
63
67
|
"pg": "^8.23.0",
|
|
64
68
|
"testcontainers": "^12.2.0",
|
|
65
69
|
"yaml": "^2.9.1"
|
|
@@ -277,6 +277,11 @@
|
|
|
277
277
|
"additionalProperties": {
|
|
278
278
|
"type": "string"
|
|
279
279
|
}
|
|
280
|
+
},
|
|
281
|
+
"within": {
|
|
282
|
+
"type": "integer",
|
|
283
|
+
"minimum": 1,
|
|
284
|
+
"description": "Retry this step for up to this many milliseconds, for effects the app applies asynchronously"
|
|
280
285
|
}
|
|
281
286
|
}
|
|
282
287
|
},
|
|
@@ -325,6 +330,11 @@
|
|
|
325
330
|
"additionalProperties": {
|
|
326
331
|
"type": "string"
|
|
327
332
|
}
|
|
333
|
+
},
|
|
334
|
+
"within": {
|
|
335
|
+
"type": "integer",
|
|
336
|
+
"minimum": 1,
|
|
337
|
+
"description": "Retry this step for up to this many milliseconds, for effects the app applies asynchronously"
|
|
328
338
|
}
|
|
329
339
|
}
|
|
330
340
|
},
|
|
@@ -368,6 +378,127 @@
|
|
|
368
378
|
"type": "integer",
|
|
369
379
|
"minimum": 0,
|
|
370
380
|
"description": "Exact count; default: at least once"
|
|
381
|
+
},
|
|
382
|
+
"within": {
|
|
383
|
+
"type": "integer",
|
|
384
|
+
"minimum": 1,
|
|
385
|
+
"description": "Retry this step for up to this many milliseconds, for effects the app applies asynchronously"
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
"type": "object",
|
|
391
|
+
"required": [
|
|
392
|
+
"changes"
|
|
393
|
+
],
|
|
394
|
+
"additionalProperties": false,
|
|
395
|
+
"properties": {
|
|
396
|
+
"changes": {
|
|
397
|
+
"type": "object",
|
|
398
|
+
"description": "The database changed exactly in these tables since the scenario started (or the last checkpoint). Unlisted tables must be unchanged.",
|
|
399
|
+
"additionalProperties": {
|
|
400
|
+
"type": "object",
|
|
401
|
+
"additionalProperties": false,
|
|
402
|
+
"properties": {
|
|
403
|
+
"inserted": {
|
|
404
|
+
"oneOf": [
|
|
405
|
+
{
|
|
406
|
+
"type": "integer",
|
|
407
|
+
"minimum": 0
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
"type": "array",
|
|
411
|
+
"items": {
|
|
412
|
+
"type": "object"
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
],
|
|
416
|
+
"description": "Count, or subset rows"
|
|
417
|
+
},
|
|
418
|
+
"updated": {
|
|
419
|
+
"oneOf": [
|
|
420
|
+
{
|
|
421
|
+
"type": "integer",
|
|
422
|
+
"minimum": 0
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
"type": "array",
|
|
426
|
+
"items": {
|
|
427
|
+
"type": "object"
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
],
|
|
431
|
+
"description": "Count, or subset rows (after the update)"
|
|
432
|
+
},
|
|
433
|
+
"deleted": {
|
|
434
|
+
"oneOf": [
|
|
435
|
+
{
|
|
436
|
+
"type": "integer",
|
|
437
|
+
"minimum": 0
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
"type": "array",
|
|
441
|
+
"items": {
|
|
442
|
+
"type": "object"
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
],
|
|
446
|
+
"description": "Count, or subset rows"
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
"name": {
|
|
452
|
+
"type": "string",
|
|
453
|
+
"description": "Label shown when this step fails"
|
|
454
|
+
},
|
|
455
|
+
"within": {
|
|
456
|
+
"type": "integer",
|
|
457
|
+
"minimum": 1,
|
|
458
|
+
"description": "Retry this step for up to this many milliseconds, for effects the app applies asynchronously"
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
"type": "object",
|
|
464
|
+
"required": [
|
|
465
|
+
"checkpoint"
|
|
466
|
+
],
|
|
467
|
+
"additionalProperties": false,
|
|
468
|
+
"properties": {
|
|
469
|
+
"checkpoint": {
|
|
470
|
+
"const": true,
|
|
471
|
+
"description": "Later `changes` steps only see what happens after this step"
|
|
472
|
+
},
|
|
473
|
+
"name": {
|
|
474
|
+
"type": "string",
|
|
475
|
+
"description": "Label shown when this step fails"
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
"type": "object",
|
|
481
|
+
"required": [
|
|
482
|
+
"log"
|
|
483
|
+
],
|
|
484
|
+
"additionalProperties": false,
|
|
485
|
+
"properties": {
|
|
486
|
+
"log": {
|
|
487
|
+
"type": "string",
|
|
488
|
+
"description": "Regular expression; waits until the app (or `from:` a service) prints a matching line during this scenario"
|
|
489
|
+
},
|
|
490
|
+
"from": {
|
|
491
|
+
"type": "string",
|
|
492
|
+
"description": "Service name (from the `services` option). Default: the app"
|
|
493
|
+
},
|
|
494
|
+
"within": {
|
|
495
|
+
"type": "integer",
|
|
496
|
+
"minimum": 1,
|
|
497
|
+
"description": "Milliseconds to wait. Default 5000"
|
|
498
|
+
},
|
|
499
|
+
"name": {
|
|
500
|
+
"type": "string",
|
|
501
|
+
"description": "Label shown when this step fails"
|
|
371
502
|
}
|
|
372
503
|
}
|
|
373
504
|
}
|
|
@@ -402,4 +533,4 @@
|
|
|
402
533
|
"additionalProperties": true
|
|
403
534
|
}
|
|
404
535
|
}
|
|
405
|
-
}
|
|
536
|
+
}
|