@speqkit/plugin-use 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/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +187 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
- package/src/index.ts +251 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 speqkit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# @speqkit/plugin-use
|
|
2
|
+
|
|
3
|
+
Composition: calling something declared somewhere else.
|
|
4
|
+
|
|
5
|
+
```yaml
|
|
6
|
+
# speq.yaml
|
|
7
|
+
plugins:
|
|
8
|
+
- use
|
|
9
|
+
|
|
10
|
+
use:
|
|
11
|
+
modulesDir: modules # defaults, all relative to the project root
|
|
12
|
+
sharedDir: shared
|
|
13
|
+
fixturesDir: fixtures
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
One step type, three forms — a shared block, a module action, a fixture. They
|
|
17
|
+
differ only in what they hand back, which is why they are one plugin and one
|
|
18
|
+
keyword: a tester should not have to learn where our filing cabinet has a
|
|
19
|
+
divider.
|
|
20
|
+
|
|
21
|
+
```yaml
|
|
22
|
+
steps:
|
|
23
|
+
- id: setup
|
|
24
|
+
type: use
|
|
25
|
+
ref: register-tenant # shared/register-tenant.yaml
|
|
26
|
+
|
|
27
|
+
- id: category
|
|
28
|
+
type: use
|
|
29
|
+
action: menu.createCategory # modules/menu.yaml, action `createCategory`
|
|
30
|
+
properties:
|
|
31
|
+
accessToken: "${setup.token}"
|
|
32
|
+
name: "starters"
|
|
33
|
+
|
|
34
|
+
- id: item
|
|
35
|
+
type: use
|
|
36
|
+
fixture: menu-item # fixtures/menu-item.yaml
|
|
37
|
+
overrides:
|
|
38
|
+
name: "speq-item"
|
|
39
|
+
|
|
40
|
+
- type: http
|
|
41
|
+
method: POST
|
|
42
|
+
url: "/categories/${category.id}/items"
|
|
43
|
+
body: "${item}"
|
|
44
|
+
assert:
|
|
45
|
+
- type: status
|
|
46
|
+
expected: 201
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## What each form hands back
|
|
50
|
+
|
|
51
|
+
A `use` step is a step: it binds by `id` like every other one, and `${id.…}`
|
|
52
|
+
reads what it published.
|
|
53
|
+
|
|
54
|
+
**A shared block** is a file of steps, pulled into the tests that need the same
|
|
55
|
+
world built. Without `returns` it publishes its own steps by id; with one, it
|
|
56
|
+
publishes exactly what it says and nothing else.
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
# shared/register-tenant.yaml
|
|
60
|
+
steps:
|
|
61
|
+
- id: tenant
|
|
62
|
+
type: http
|
|
63
|
+
method: POST
|
|
64
|
+
url: /auth/register
|
|
65
|
+
body: { slug: "${tenantSlug}" }
|
|
66
|
+
assert:
|
|
67
|
+
- type: status
|
|
68
|
+
expected: 201
|
|
69
|
+
|
|
70
|
+
returns:
|
|
71
|
+
token: "${tenant.body.access_token}"
|
|
72
|
+
restaurantId: "${tenant.body.restaurant.id}"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`returns` is worth adding the moment a block is used more than twice. Without
|
|
76
|
+
it every caller reaches through the block's internals — `${setup.tenant.body.
|
|
77
|
+
access_token}` — and renaming a step inside the block breaks fifty tests that
|
|
78
|
+
never mentioned it.
|
|
79
|
+
|
|
80
|
+
**A module action** is the same thing with parameters. `properties` are the
|
|
81
|
+
variables its steps see, and they are declared so a caller that forgets one is
|
|
82
|
+
told before the run rather than during it.
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
# modules/menu.yaml
|
|
86
|
+
actions:
|
|
87
|
+
createCategory:
|
|
88
|
+
properties: [accessToken, name]
|
|
89
|
+
steps:
|
|
90
|
+
- id: created
|
|
91
|
+
type: http
|
|
92
|
+
method: POST
|
|
93
|
+
url: /categories
|
|
94
|
+
headers: { authorization: "Bearer ${accessToken}" }
|
|
95
|
+
body: { name: "${name}" }
|
|
96
|
+
returns:
|
|
97
|
+
id: "${created.body.id}"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
An action's steps run in a child scope, so `${created…}` is invisible to the
|
|
101
|
+
test that called it. That is the point: what escapes an action is its
|
|
102
|
+
`returns`, which makes the action's insides free to change.
|
|
103
|
+
|
|
104
|
+
**A fixture** is a call whose result is data rather than an effect — a body
|
|
105
|
+
built somewhere else so the test can stay about what it is proving.
|
|
106
|
+
`overrides` merges at the top level and beats what the fixture built, so a test
|
|
107
|
+
pins the one field it means to assert on and leaves the rest generated.
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
# fixtures/menu-item.yaml
|
|
111
|
+
fixture:
|
|
112
|
+
build:
|
|
113
|
+
name: "${gen:string}"
|
|
114
|
+
description: "${gen:string}"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Where files are looked up
|
|
118
|
+
|
|
119
|
+
Paths are relative to the **project root**, or bare inside the directory for
|
|
120
|
+
their kind: `ref: register-tenant` is `shared/register-tenant.yaml`, and
|
|
121
|
+
`ref: blocks/other.yaml` is `<root>/blocks/other.yaml`. The `.yaml` is
|
|
122
|
+
optional.
|
|
123
|
+
|
|
124
|
+
Deliberately not relative to the test file. A plugin is never told which file
|
|
125
|
+
the step it is running came from, so `../../../shared/x.yaml` could only be
|
|
126
|
+
resolved against the wrong directory — and how deep a suite tree happens to be
|
|
127
|
+
is not something a shared block should have an opinion about. A path written
|
|
128
|
+
the v1 way is refused by `speq validate`, with the fix in the hint.
|
|
129
|
+
|
|
130
|
+
## What it checks before anything runs
|
|
131
|
+
|
|
132
|
+
`speq validate` catches all of it in milliseconds, naming the file and the step:
|
|
133
|
+
|
|
134
|
+
- the block, module or fixture file is not on disk
|
|
135
|
+
- the module has no such action — and which ones it has
|
|
136
|
+
- an action was called without a property it declares
|
|
137
|
+
- two of `ref` / `action` / `fixture` on one step
|
|
138
|
+
- `as:`, which is the v1 spelling for naming a result — write `id:`
|
|
139
|
+
|
|
140
|
+
## The one thing it cannot say
|
|
141
|
+
|
|
142
|
+
A step type can return a result or throw; the contract gives it no way to
|
|
143
|
+
report `failed`. So when a step *inside* a block fails an assertion, the `use`
|
|
144
|
+
step errors rather than failing, carrying the inner step's id and message. The
|
|
145
|
+
difference between "the system was wrong" and "we could not ask" is lost at
|
|
146
|
+
that boundary. It is written down here rather than papered over — the fix
|
|
147
|
+
belongs in the contract, not in a workaround.
|
|
148
|
+
|
|
149
|
+
## Migrating from speq v1
|
|
150
|
+
|
|
151
|
+
| v1 | here |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| `type: use` + `ref: "../../shared/x.yaml"` | `ref: x` — root-relative or bare |
|
|
154
|
+
| `as: parentCategory` | `id: parentCategory` |
|
|
155
|
+
| `{{tenant.response.body.token}}` | `${setup.tenant.body.token}`, or a `returns` on the block |
|
|
156
|
+
| `bodyFromFixture: { ref, overrides }` on a step | a `use` step with `fixture:`, then `body: "${item}"` |
|
|
157
|
+
|
|
158
|
+
`speq migrate` does the mechanical part. Adding `returns` to a block that
|
|
159
|
+
outgrew publishing its internals is the part worth doing by hand.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAiDA,wBA0JE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { definePlugin } from '@speqkit/plugin-api';
|
|
5
|
+
/** The step the plugin appends to a block to read its `returns` out. */
|
|
6
|
+
const CAPTURE = 'use.capture';
|
|
7
|
+
export default definePlugin({
|
|
8
|
+
name: '@speqkit/plugin-use',
|
|
9
|
+
configSchema: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
modulesDir: { type: 'string' },
|
|
13
|
+
sharedDir: { type: 'string' },
|
|
14
|
+
fixturesDir: { type: 'string' }
|
|
15
|
+
},
|
|
16
|
+
additionalProperties: false
|
|
17
|
+
},
|
|
18
|
+
setup(ctx) {
|
|
19
|
+
const root = ctx.host.root;
|
|
20
|
+
const config = ctx.config();
|
|
21
|
+
const dirs = {
|
|
22
|
+
modules: config.modulesDir ?? 'modules',
|
|
23
|
+
shared: config.sharedDir ?? 'shared',
|
|
24
|
+
fixtures: config.fixturesDir ?? 'fixtures'
|
|
25
|
+
};
|
|
26
|
+
// Read once per run: a file cannot change under a suite that is already
|
|
27
|
+
// executing, and a shared block pulled into fifty tests would otherwise be
|
|
28
|
+
// parsed fifty times.
|
|
29
|
+
const files = new Map();
|
|
30
|
+
const read = (path) => {
|
|
31
|
+
if (!files.has(path))
|
|
32
|
+
files.set(path, parseYaml(readFileSync(path, 'utf8')) ?? {});
|
|
33
|
+
return files.get(path);
|
|
34
|
+
};
|
|
35
|
+
const locate = (spec, dir) => {
|
|
36
|
+
const named = spec.endsWith('.yaml') || spec.endsWith('.yml') ? spec : `${spec}.yaml`;
|
|
37
|
+
if (isAbsolute(named))
|
|
38
|
+
return named;
|
|
39
|
+
// Root-relative on purpose. A plugin is not told which file the step it
|
|
40
|
+
// is running came from, so `../../../shared/x.yaml` could only ever be
|
|
41
|
+
// resolved against the wrong directory — and the depth of a suite tree
|
|
42
|
+
// is not something a shared block should have an opinion about.
|
|
43
|
+
return named.includes('/') ? join(root, named) : join(root, dir, named);
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* `use.capture` exists so that `returns` is resolved by the kernel in the
|
|
47
|
+
* scope the block ran in, rather than by a second `${...}` implementation
|
|
48
|
+
* living in this plugin and drifting from the first.
|
|
49
|
+
*/
|
|
50
|
+
ctx.defineStepType(CAPTURE, {
|
|
51
|
+
schema: { type: 'object', properties: { values: {} }, additionalProperties: false },
|
|
52
|
+
execute: (_exec, input) => (input.values ?? {})
|
|
53
|
+
});
|
|
54
|
+
ctx.defineStepType('use', {
|
|
55
|
+
schema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
ref: { type: 'string' },
|
|
59
|
+
action: { type: 'string' },
|
|
60
|
+
fixture: { type: 'string' },
|
|
61
|
+
properties: { type: 'object' },
|
|
62
|
+
overrides: { type: 'object' }
|
|
63
|
+
},
|
|
64
|
+
additionalProperties: false
|
|
65
|
+
},
|
|
66
|
+
/**
|
|
67
|
+
* Everything checkable without running anything: that exactly one of the
|
|
68
|
+
* three forms was written, that the file is on disk, that the action
|
|
69
|
+
* exists in the module and got the properties it declares. All of it
|
|
70
|
+
* used to be found out mid-run, from a step that could not name the file
|
|
71
|
+
* it was reading.
|
|
72
|
+
*/
|
|
73
|
+
validate(step) {
|
|
74
|
+
const problems = [];
|
|
75
|
+
const forms = ['ref', 'action', 'fixture'].filter((k) => typeof step[k] === 'string');
|
|
76
|
+
if (step.as !== undefined) {
|
|
77
|
+
problems.push({
|
|
78
|
+
path: 'as',
|
|
79
|
+
message: "'as' is the v1 spelling",
|
|
80
|
+
hint: "name a result with 'id', the way every other step does"
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (forms.length === 0)
|
|
84
|
+
return [...problems, "a 'use' step needs one of 'ref', 'action' or 'fixture'"];
|
|
85
|
+
if (forms.length > 1)
|
|
86
|
+
return [...problems, `'${forms.join("' and '")}' exclude each other`];
|
|
87
|
+
if (typeof step.ref === 'string') {
|
|
88
|
+
if (step.ref.startsWith('.')) {
|
|
89
|
+
problems.push({
|
|
90
|
+
path: 'ref',
|
|
91
|
+
message: `'${step.ref}' is relative to the test file`,
|
|
92
|
+
hint: `paths are relative to the project root, or bare inside ${dirs.shared}/`
|
|
93
|
+
});
|
|
94
|
+
return problems;
|
|
95
|
+
}
|
|
96
|
+
const path = locate(step.ref, dirs.shared);
|
|
97
|
+
if (!existsSync(path))
|
|
98
|
+
return [...problems, { path: 'ref', message: `no such block: ${path}` }];
|
|
99
|
+
const block = read(path);
|
|
100
|
+
if (!Array.isArray(block.steps) || block.steps.length === 0) {
|
|
101
|
+
problems.push({ path: 'ref', message: `${step.ref} has no steps` });
|
|
102
|
+
}
|
|
103
|
+
return problems;
|
|
104
|
+
}
|
|
105
|
+
if (typeof step.fixture === 'string') {
|
|
106
|
+
const path = locate(step.fixture, dirs.fixtures);
|
|
107
|
+
if (!existsSync(path))
|
|
108
|
+
return [...problems, { path: 'fixture', message: `no such fixture: ${path}` }];
|
|
109
|
+
const built = read(path).fixture;
|
|
110
|
+
if (!built || typeof built.build !== 'object') {
|
|
111
|
+
problems.push({ path: 'fixture', message: `${step.fixture} has no 'fixture.build' block` });
|
|
112
|
+
}
|
|
113
|
+
return problems;
|
|
114
|
+
}
|
|
115
|
+
const [moduleName, actionName, ...rest] = String(step.action).split('.');
|
|
116
|
+
if (!moduleName || !actionName || rest.length > 0) {
|
|
117
|
+
return [...problems, { path: 'action', message: `'${String(step.action)}' is not '<module>.<action>'` }];
|
|
118
|
+
}
|
|
119
|
+
const path = locate(moduleName, dirs.modules);
|
|
120
|
+
if (!existsSync(path))
|
|
121
|
+
return [...problems, { path: 'action', message: `no such module: ${path}` }];
|
|
122
|
+
const actions = read(path).actions ?? {};
|
|
123
|
+
const action = actions[actionName];
|
|
124
|
+
if (!action) {
|
|
125
|
+
const known = Object.keys(actions).sort().join(', ') || '(none)';
|
|
126
|
+
return [...problems, { path: 'action', message: `module '${moduleName}' has no action '${actionName}'; it has: ${known}` }];
|
|
127
|
+
}
|
|
128
|
+
const given = new Set(Object.keys(step.properties ?? {}));
|
|
129
|
+
for (const required of action.properties ?? []) {
|
|
130
|
+
if (!given.has(required)) {
|
|
131
|
+
problems.push({ path: 'properties', message: `action '${String(step.action)}' needs '${required}'` });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return problems;
|
|
135
|
+
},
|
|
136
|
+
async execute(exec, input) {
|
|
137
|
+
if (typeof input.fixture === 'string') {
|
|
138
|
+
const built = read(locate(input.fixture, dirs.fixtures)).fixture?.build ?? {};
|
|
139
|
+
// Top-level merge, and the override wins: a test that pins one field
|
|
140
|
+
// to assert on it should not have to restate the other five.
|
|
141
|
+
return exec.resolveDeep({ ...built, ...(input.overrides ?? {}) });
|
|
142
|
+
}
|
|
143
|
+
if (typeof input.ref === 'string') {
|
|
144
|
+
const block = read(locate(input.ref, dirs.shared));
|
|
145
|
+
return runBlock(exec, block, {}, input.ref);
|
|
146
|
+
}
|
|
147
|
+
const [moduleName, actionName] = String(input.action).split('.');
|
|
148
|
+
const action = (read(locate(moduleName, dirs.modules)).actions ?? {})[actionName];
|
|
149
|
+
const properties = input.properties ?? {};
|
|
150
|
+
return runBlock(exec, action, properties, String(input.action));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
/**
|
|
156
|
+
* Runs a block's steps in a child scope and hands back what it publishes.
|
|
157
|
+
*
|
|
158
|
+
* A block with `returns` publishes exactly that. Without one, the caller gets
|
|
159
|
+
* the block's steps by id — convenient for a shared setup that was written
|
|
160
|
+
* before anyone thought about its interface, and the reason `returns` is worth
|
|
161
|
+
* adding to one that outgrows it.
|
|
162
|
+
*/
|
|
163
|
+
async function runBlock(exec, block, vars, label) {
|
|
164
|
+
const steps = (block.steps ?? []);
|
|
165
|
+
const returns = block.returns;
|
|
166
|
+
const body = returns ? [...steps, { id: CAPTURE, type: CAPTURE, values: returns }] : steps;
|
|
167
|
+
const records = await exec.runSteps(body, { vars, label });
|
|
168
|
+
const broken = records.find((r) => r.status !== 'passed');
|
|
169
|
+
if (broken) {
|
|
170
|
+
// A step type has no way to report `failed`, so an inner failure surfaces
|
|
171
|
+
// as this step erroring. The message carries the inner one, which is what
|
|
172
|
+
// a reader needs; the distinction between "the system was wrong" and "we
|
|
173
|
+
// could not ask" is lost at this boundary, and that is written down rather
|
|
174
|
+
// than papered over.
|
|
175
|
+
throw new Error(`${label}: step ${broken.id ? `'${broken.id}' ` : ''}(${broken.type}) ${broken.status}` +
|
|
176
|
+
(broken.message ? ` — ${broken.message}` : ''));
|
|
177
|
+
}
|
|
178
|
+
if (returns)
|
|
179
|
+
return (records.at(-1)?.result ?? {});
|
|
180
|
+
const published = {};
|
|
181
|
+
for (const record of records) {
|
|
182
|
+
if (record.id)
|
|
183
|
+
published[record.id] = record.result;
|
|
184
|
+
}
|
|
185
|
+
return published;
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@speqkit/plugin-use",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Composition as a plugin: shared blocks, module actions and fixtures, called from a test with `use`.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Stepan Kaziatko",
|
|
7
|
+
"homepage": "https://github.com/speqkit/speqkit#readme",
|
|
8
|
+
"bugs": "https://github.com/speqkit/speqkit/issues",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/speqkit/speqkit.git",
|
|
12
|
+
"directory": "packages/plugin-use"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"speqkit-plugin"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"yaml": "^2.6.1"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@speqkit/plugin-api": "^0.9.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@speqkit/plugin-api": "0.9.0",
|
|
32
|
+
"@speqkit/test-kit": "0.1.0",
|
|
33
|
+
"speqkit": "0.2.0"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20.0.0"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, join } from 'node:path'
|
|
3
|
+
import { parse as parseYaml } from 'yaml'
|
|
4
|
+
import { definePlugin, type StepDef, type StepRecord, type ValidationProblem } from '@speqkit/plugin-api'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Composition: calling something declared somewhere else.
|
|
8
|
+
*
|
|
9
|
+
* It is the most used step of a real suite by a wide margin — in the corpus
|
|
10
|
+
* this plugin was written against, `use` outnumbers the HTTP step it composes
|
|
11
|
+
* — and it is one step type rather than three plugins because a shared block,
|
|
12
|
+
* a module action and a fixture differ only in what they hand back. Splitting
|
|
13
|
+
* them would mean explaining to a tester what an "action" is as opposed to a
|
|
14
|
+
* "block", which is our filing system, not their problem.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here needs the kernel to grow: nested steps go through
|
|
17
|
+
* `ctx.runSteps`, and the child scope it opens is exactly what keeps an
|
|
18
|
+
* action's internals from leaking into the test that called it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface UseConfig {
|
|
22
|
+
/** Where module files live, relative to the project root. */
|
|
23
|
+
modulesDir?: string
|
|
24
|
+
/** Where shared blocks live. */
|
|
25
|
+
sharedDir?: string
|
|
26
|
+
/** Where fixtures live. */
|
|
27
|
+
fixturesDir?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Block {
|
|
31
|
+
steps?: StepDef[]
|
|
32
|
+
returns?: Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Action extends Block {
|
|
36
|
+
properties?: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Module {
|
|
40
|
+
actions?: Record<string, Action>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Fixture {
|
|
44
|
+
fixture?: { build?: Record<string, unknown>; schemaRef?: string }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The step the plugin appends to a block to read its `returns` out. */
|
|
48
|
+
const CAPTURE = 'use.capture'
|
|
49
|
+
|
|
50
|
+
export default definePlugin({
|
|
51
|
+
name: '@speqkit/plugin-use',
|
|
52
|
+
configSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
modulesDir: { type: 'string' },
|
|
56
|
+
sharedDir: { type: 'string' },
|
|
57
|
+
fixturesDir: { type: 'string' }
|
|
58
|
+
},
|
|
59
|
+
additionalProperties: false
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
setup(ctx) {
|
|
63
|
+
const root = ctx.host.root
|
|
64
|
+
const config = ctx.config<UseConfig>()
|
|
65
|
+
const dirs = {
|
|
66
|
+
modules: config.modulesDir ?? 'modules',
|
|
67
|
+
shared: config.sharedDir ?? 'shared',
|
|
68
|
+
fixtures: config.fixturesDir ?? 'fixtures'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Read once per run: a file cannot change under a suite that is already
|
|
72
|
+
// executing, and a shared block pulled into fifty tests would otherwise be
|
|
73
|
+
// parsed fifty times.
|
|
74
|
+
const files = new Map<string, unknown>()
|
|
75
|
+
const read = (path: string): unknown => {
|
|
76
|
+
if (!files.has(path)) files.set(path, parseYaml(readFileSync(path, 'utf8')) ?? {})
|
|
77
|
+
return files.get(path)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const locate = (spec: string, dir: string): string => {
|
|
81
|
+
const named = spec.endsWith('.yaml') || spec.endsWith('.yml') ? spec : `${spec}.yaml`
|
|
82
|
+
if (isAbsolute(named)) return named
|
|
83
|
+
// Root-relative on purpose. A plugin is not told which file the step it
|
|
84
|
+
// is running came from, so `../../../shared/x.yaml` could only ever be
|
|
85
|
+
// resolved against the wrong directory — and the depth of a suite tree
|
|
86
|
+
// is not something a shared block should have an opinion about.
|
|
87
|
+
return named.includes('/') ? join(root, named) : join(root, dir, named)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `use.capture` exists so that `returns` is resolved by the kernel in the
|
|
92
|
+
* scope the block ran in, rather than by a second `${...}` implementation
|
|
93
|
+
* living in this plugin and drifting from the first.
|
|
94
|
+
*/
|
|
95
|
+
ctx.defineStepType(CAPTURE, {
|
|
96
|
+
schema: { type: 'object', properties: { values: {} }, additionalProperties: false },
|
|
97
|
+
execute: (_exec, input) => (input.values ?? {}) as Record<string, unknown>
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
ctx.defineStepType('use', {
|
|
101
|
+
schema: {
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: {
|
|
104
|
+
ref: { type: 'string' },
|
|
105
|
+
action: { type: 'string' },
|
|
106
|
+
fixture: { type: 'string' },
|
|
107
|
+
properties: { type: 'object' },
|
|
108
|
+
overrides: { type: 'object' }
|
|
109
|
+
},
|
|
110
|
+
additionalProperties: false
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Everything checkable without running anything: that exactly one of the
|
|
115
|
+
* three forms was written, that the file is on disk, that the action
|
|
116
|
+
* exists in the module and got the properties it declares. All of it
|
|
117
|
+
* used to be found out mid-run, from a step that could not name the file
|
|
118
|
+
* it was reading.
|
|
119
|
+
*/
|
|
120
|
+
validate(step) {
|
|
121
|
+
const problems: (string | ValidationProblem)[] = []
|
|
122
|
+
const forms = (['ref', 'action', 'fixture'] as const).filter((k) => typeof step[k] === 'string')
|
|
123
|
+
|
|
124
|
+
if (step.as !== undefined) {
|
|
125
|
+
problems.push({
|
|
126
|
+
path: 'as',
|
|
127
|
+
message: "'as' is the v1 spelling",
|
|
128
|
+
hint: "name a result with 'id', the way every other step does"
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
if (forms.length === 0) return [...problems, "a 'use' step needs one of 'ref', 'action' or 'fixture'"]
|
|
132
|
+
if (forms.length > 1) return [...problems, `'${forms.join("' and '")}' exclude each other`]
|
|
133
|
+
|
|
134
|
+
if (typeof step.ref === 'string') {
|
|
135
|
+
if (step.ref.startsWith('.')) {
|
|
136
|
+
problems.push({
|
|
137
|
+
path: 'ref',
|
|
138
|
+
message: `'${step.ref}' is relative to the test file`,
|
|
139
|
+
hint: `paths are relative to the project root, or bare inside ${dirs.shared}/`
|
|
140
|
+
})
|
|
141
|
+
return problems
|
|
142
|
+
}
|
|
143
|
+
const path = locate(step.ref, dirs.shared)
|
|
144
|
+
if (!existsSync(path)) return [...problems, { path: 'ref', message: `no such block: ${path}` }]
|
|
145
|
+
const block = read(path) as Block
|
|
146
|
+
if (!Array.isArray(block.steps) || block.steps.length === 0) {
|
|
147
|
+
problems.push({ path: 'ref', message: `${step.ref} has no steps` })
|
|
148
|
+
}
|
|
149
|
+
return problems
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (typeof step.fixture === 'string') {
|
|
153
|
+
const path = locate(step.fixture, dirs.fixtures)
|
|
154
|
+
if (!existsSync(path)) return [...problems, { path: 'fixture', message: `no such fixture: ${path}` }]
|
|
155
|
+
const built = (read(path) as Fixture).fixture
|
|
156
|
+
if (!built || typeof built.build !== 'object') {
|
|
157
|
+
problems.push({ path: 'fixture', message: `${step.fixture} has no 'fixture.build' block` })
|
|
158
|
+
}
|
|
159
|
+
return problems
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const [moduleName, actionName, ...rest] = String(step.action).split('.')
|
|
163
|
+
if (!moduleName || !actionName || rest.length > 0) {
|
|
164
|
+
return [...problems, { path: 'action', message: `'${String(step.action)}' is not '<module>.<action>'` }]
|
|
165
|
+
}
|
|
166
|
+
const path = locate(moduleName, dirs.modules)
|
|
167
|
+
if (!existsSync(path)) return [...problems, { path: 'action', message: `no such module: ${path}` }]
|
|
168
|
+
|
|
169
|
+
const actions = (read(path) as Module).actions ?? {}
|
|
170
|
+
const action = actions[actionName]
|
|
171
|
+
if (!action) {
|
|
172
|
+
const known = Object.keys(actions).sort().join(', ') || '(none)'
|
|
173
|
+
return [...problems, { path: 'action', message: `module '${moduleName}' has no action '${actionName}'; it has: ${known}` }]
|
|
174
|
+
}
|
|
175
|
+
const given = new Set(Object.keys((step.properties as Record<string, unknown>) ?? {}))
|
|
176
|
+
for (const required of action.properties ?? []) {
|
|
177
|
+
if (!given.has(required)) {
|
|
178
|
+
problems.push({ path: 'properties', message: `action '${String(step.action)}' needs '${required}'` })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return problems
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async execute(exec, input) {
|
|
185
|
+
if (typeof input.fixture === 'string') {
|
|
186
|
+
const built = (read(locate(input.fixture, dirs.fixtures)) as Fixture).fixture?.build ?? {}
|
|
187
|
+
// Top-level merge, and the override wins: a test that pins one field
|
|
188
|
+
// to assert on it should not have to restate the other five.
|
|
189
|
+
return exec.resolveDeep({ ...built, ...((input.overrides as Record<string, unknown>) ?? {}) })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (typeof input.ref === 'string') {
|
|
193
|
+
const block = read(locate(input.ref, dirs.shared)) as Block
|
|
194
|
+
return runBlock(exec, block, {}, input.ref)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const [moduleName, actionName] = String(input.action).split('.')
|
|
198
|
+
const action = ((read(locate(moduleName!, dirs.modules)) as Module).actions ?? {})[actionName!]!
|
|
199
|
+
const properties = (input.properties as Record<string, unknown>) ?? {}
|
|
200
|
+
return runBlock(exec, action, properties, String(input.action))
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Runs a block's steps in a child scope and hands back what it publishes.
|
|
208
|
+
*
|
|
209
|
+
* A block with `returns` publishes exactly that. Without one, the caller gets
|
|
210
|
+
* the block's steps by id — convenient for a shared setup that was written
|
|
211
|
+
* before anyone thought about its interface, and the reason `returns` is worth
|
|
212
|
+
* adding to one that outgrows it.
|
|
213
|
+
*/
|
|
214
|
+
async function runBlock(
|
|
215
|
+
exec: ExecLike,
|
|
216
|
+
block: Block,
|
|
217
|
+
vars: Record<string, unknown>,
|
|
218
|
+
label: string
|
|
219
|
+
): Promise<Record<string, unknown>> {
|
|
220
|
+
const steps = (block.steps ?? []) as StepDef[]
|
|
221
|
+
const returns = block.returns
|
|
222
|
+
const body = returns ? [...steps, { id: CAPTURE, type: CAPTURE, values: returns }] : steps
|
|
223
|
+
|
|
224
|
+
const records = await exec.runSteps(body, { vars, label })
|
|
225
|
+
const broken = records.find((r) => r.status !== 'passed')
|
|
226
|
+
if (broken) {
|
|
227
|
+
// A step type has no way to report `failed`, so an inner failure surfaces
|
|
228
|
+
// as this step erroring. The message carries the inner one, which is what
|
|
229
|
+
// a reader needs; the distinction between "the system was wrong" and "we
|
|
230
|
+
// could not ask" is lost at this boundary, and that is written down rather
|
|
231
|
+
// than papered over.
|
|
232
|
+
throw new Error(
|
|
233
|
+
`${label}: step ${broken.id ? `'${broken.id}' ` : ''}(${broken.type}) ${broken.status}` +
|
|
234
|
+
(broken.message ? ` — ${broken.message}` : '')
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (returns) return (records.at(-1)?.result ?? {}) as Record<string, unknown>
|
|
239
|
+
|
|
240
|
+
const published: Record<string, unknown> = {}
|
|
241
|
+
for (const record of records) {
|
|
242
|
+
if (record.id) published[record.id] = record.result
|
|
243
|
+
}
|
|
244
|
+
return published
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The slice of `ExecContext` a block needs. Kept local so the plugin depends on no import of the kernel. */
|
|
248
|
+
interface ExecLike {
|
|
249
|
+
runSteps(steps: StepDef[], options?: { vars?: Record<string, unknown>; label?: string }): Promise<StepRecord[]>
|
|
250
|
+
resolveDeep<T>(value: T): T
|
|
251
|
+
}
|