spp-lang 0.0.0-stage → 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 +170 -2
- package/dist/bin/spp-lang.d.ts +2 -0
- package/dist/bin/spp-lang.js +214 -0
- package/dist/src/backends/playwright/generate.d.ts +13 -0
- package/dist/src/backends/playwright/generate.js +238 -0
- package/dist/src/backends/playwright/runner/playwright.config.d.ts +2 -0
- package/dist/src/backends/playwright/runner/playwright.config.js +16 -0
- package/dist/src/backends/playwright/runtime.d.ts +14 -0
- package/dist/src/backends/playwright/runtime.js +158 -0
- package/dist/src/backends/playwright/words.d.ts +11 -0
- package/dist/src/backends/playwright/words.js +129 -0
- package/dist/src/config.d.ts +18 -0
- package/dist/src/config.js +63 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.js +12 -0
- package/dist/src/language/check.d.ts +178 -0
- package/dist/src/language/check.js +709 -0
- package/dist/src/language/cognate.d.ts +137 -0
- package/dist/src/language/cognate.js +570 -0
- package/dist/src/language/load.d.ts +58 -0
- package/dist/src/language/load.js +200 -0
- package/dist/src/language/markdown.d.ts +9 -0
- package/dist/src/language/markdown.js +52 -0
- package/dist/src/language/sections.d.ts +19 -0
- package/dist/src/language/sections.js +119 -0
- package/dist/src/language/steps.d.ts +19 -0
- package/dist/src/language/steps.js +91 -0
- package/dist/src/language/types.d.ts +32 -0
- package/dist/src/language/types.js +51 -0
- package/dist/src/tools/dictionary.d.ts +3 -0
- package/dist/src/tools/dictionary.js +89 -0
- package/dist/src/tools/lsp.d.ts +1 -0
- package/dist/src/tools/lsp.js +479 -0
- package/dist/src/words/driver.d.ts +39 -0
- package/dist/src/words/driver.js +9 -0
- package/dist/src/words/shared.d.ts +56 -0
- package/dist/src/words/shared.js +272 -0
- package/package.json +65 -4
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// The words every backend shares, made from what its driver can do.
|
|
2
|
+
//
|
|
3
|
+
// Queries are Testing Library's, spelled as Cognate words: a role name
|
|
4
|
+
// (Button, Heading, Checkbox…) takes the accessible name to its right and
|
|
5
|
+
// leaves an Element. Spec++ asks for exact names, as Testing Library does,
|
|
6
|
+
// unless you say Containing.
|
|
7
|
+
//
|
|
8
|
+
// Assertions are deferred. "Then I should Not See the Button "Save"" runs See
|
|
9
|
+
// before Not, so See can't just fail on the spot; it leaves an Expectation on
|
|
10
|
+
// the stack for Not to flip, and every Expectation left when the step ends is
|
|
11
|
+
// checked then. Likewise "the Checkbox "Terms" should be Checked": Checked
|
|
12
|
+
// runs first and leaves a Matcher, the Checkbox query lands on top of it, and
|
|
13
|
+
// the step's end pairs them up.
|
|
14
|
+
//
|
|
15
|
+
// A backend builds its vocabulary from queries(), actions() and checks(),
|
|
16
|
+
// with its own words in between, like the Playwright backend's Visit.
|
|
17
|
+
import { describe, SppError, unwrap } from '../language/cognate.js';
|
|
18
|
+
import { called, formsFor, oneOf, TableType, TextType, type } from '../language/types.js';
|
|
19
|
+
// ---------------------------------------------------------------- values
|
|
20
|
+
export class Pattern {
|
|
21
|
+
text;
|
|
22
|
+
regex;
|
|
23
|
+
constructor(text) {
|
|
24
|
+
this.text = text;
|
|
25
|
+
this.regex = new RegExp(escapeRegExp(text), 'i');
|
|
26
|
+
}
|
|
27
|
+
describe() {
|
|
28
|
+
return `Containing ${JSON.stringify(this.text)}`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class Scope {
|
|
32
|
+
found;
|
|
33
|
+
constructor(found) {
|
|
34
|
+
this.found = found;
|
|
35
|
+
}
|
|
36
|
+
describe() {
|
|
37
|
+
return `Within ${this.found.describe()}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// A driver's handle that remembers the words that made it, for error messages.
|
|
41
|
+
export class Found {
|
|
42
|
+
handle;
|
|
43
|
+
label;
|
|
44
|
+
constructor(handle, label) {
|
|
45
|
+
this.handle = handle;
|
|
46
|
+
this.label = label;
|
|
47
|
+
}
|
|
48
|
+
describe() {
|
|
49
|
+
return this.label;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class Matcher {
|
|
53
|
+
label;
|
|
54
|
+
state;
|
|
55
|
+
negated;
|
|
56
|
+
constructor(label, state, negated = false) {
|
|
57
|
+
this.label = label;
|
|
58
|
+
this.state = state;
|
|
59
|
+
this.negated = negated;
|
|
60
|
+
}
|
|
61
|
+
negate() {
|
|
62
|
+
return new Matcher(this.label, this.state, !this.negated);
|
|
63
|
+
}
|
|
64
|
+
against(target, ctx, driver) {
|
|
65
|
+
return new Expectation(`${target.label} ${this.describe()}`, (not) => driver.expect(ctx, target.handle, this.state, this.negated !== not));
|
|
66
|
+
}
|
|
67
|
+
describe() {
|
|
68
|
+
return (this.negated ? 'Not ' : '') + this.label;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// check(not) runs the assertion, inverted when not is true.
|
|
72
|
+
export class Expectation {
|
|
73
|
+
label;
|
|
74
|
+
check;
|
|
75
|
+
constructor(label, check) {
|
|
76
|
+
this.label = label;
|
|
77
|
+
this.check = check;
|
|
78
|
+
}
|
|
79
|
+
negate() {
|
|
80
|
+
return new Expectation(`Not ${this.label}`, (not) => this.check(!not));
|
|
81
|
+
}
|
|
82
|
+
describe() {
|
|
83
|
+
return this.label;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------- types
|
|
87
|
+
export const ElementType = type('Element', 'something on the page', (v) => v instanceof Found);
|
|
88
|
+
export const PatternType = type('Pattern', 'Containing "some text"', (v) => v instanceof Pattern);
|
|
89
|
+
export const NameType = oneOf('some text', TextType, PatternType);
|
|
90
|
+
export const ScopeType = type('Scope', 'Within something', (v) => v instanceof Scope);
|
|
91
|
+
export const MatcherType = type('Matcher', 'a check', (v) => v instanceof Matcher);
|
|
92
|
+
export const CheckType = type('Check', 'a check', (v) => v instanceof Expectation);
|
|
93
|
+
const QueryName = called(NameType, 'a "name" to look for');
|
|
94
|
+
// The shared words run with whatever context their driver wants.
|
|
95
|
+
const form = formsFor();
|
|
96
|
+
// ---------------------------------------------------------------- helpers
|
|
97
|
+
export const nameOf = (v) => (v instanceof Pattern ? v.regex : v);
|
|
98
|
+
export const say = (v) => (v instanceof Pattern ? v.describe() : JSON.stringify(v));
|
|
99
|
+
export function escapeRegExp(text) {
|
|
100
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
101
|
+
}
|
|
102
|
+
const cap = (s) => s[0].toUpperCase() + s.slice(1);
|
|
103
|
+
// Verbs that take a thing and some text accept them in either order, so
|
|
104
|
+
// "Fill the Field "Email" with "a@b"" and "Fill "a@b" into the Field "Email""
|
|
105
|
+
// both work.
|
|
106
|
+
function eitherOrder(act) {
|
|
107
|
+
return [
|
|
108
|
+
form([ElementType, TextType], (ctx, found, text) => act(ctx, found, text)),
|
|
109
|
+
form([TextType, ElementType], (ctx, text, found) => act(ctx, found, text)),
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
// ---------------------------------------------------------------- queries
|
|
113
|
+
// The word for each kind of query that isn't by role.
|
|
114
|
+
const QUERY_WORDS = [
|
|
115
|
+
['label', 'Field', 'Label'],
|
|
116
|
+
['text', 'Text'],
|
|
117
|
+
['placeholder', 'Placeholder'],
|
|
118
|
+
['alt', 'Alt'],
|
|
119
|
+
['title', 'Title'],
|
|
120
|
+
['testId', 'TestId'],
|
|
121
|
+
];
|
|
122
|
+
// Finding things: a word for each role and query the driver has, then
|
|
123
|
+
// Containing and Within.
|
|
124
|
+
export function queries(env, driver) {
|
|
125
|
+
env.kind(ElementType);
|
|
126
|
+
// A query finds by name, within a Scope if one comes after it.
|
|
127
|
+
const query = (word, by) => {
|
|
128
|
+
const found = (ctx, root, name, within = '') => new Found(driver.find(ctx, root?.handle ?? null, by(nameOf(name))), `the ${word} ${say(name)}${within}`);
|
|
129
|
+
return [
|
|
130
|
+
form([QueryName, ScopeType], ElementType, (ctx, name, scope) => found(ctx, scope.found, name, ` within ${scope.found.label}`)),
|
|
131
|
+
form([QueryName], ElementType, (ctx, name) => found(ctx, null, name)),
|
|
132
|
+
];
|
|
133
|
+
};
|
|
134
|
+
for (const role of driver.roles)
|
|
135
|
+
env.word(cap(role), ...query(cap(role), (name) => ({ by: 'role', role, name })));
|
|
136
|
+
for (const [kind, ...words] of QUERY_WORDS) {
|
|
137
|
+
if (!driver.queries.includes(kind))
|
|
138
|
+
continue;
|
|
139
|
+
// Synonyms share their forms, and so describe what they found the same way.
|
|
140
|
+
const forms = query(words[0], (name) => ({ by: kind, name }));
|
|
141
|
+
for (const word of words)
|
|
142
|
+
env.word(word, ...forms);
|
|
143
|
+
}
|
|
144
|
+
env.word('Containing', form([TextType], PatternType, (_, text) => new Pattern(text)));
|
|
145
|
+
const within = form([called(ElementType, 'something on the page to look inside')], ScopeType, (_, found) => new Scope(found));
|
|
146
|
+
return env.word('Within', within).word('In', within);
|
|
147
|
+
}
|
|
148
|
+
const ACTION_WORDS = [
|
|
149
|
+
['click', 'Click'],
|
|
150
|
+
['double-click', 'Double-click'],
|
|
151
|
+
['hover', 'Hover'],
|
|
152
|
+
['focus', 'Focus'],
|
|
153
|
+
['check', 'Check'],
|
|
154
|
+
['uncheck', 'Uncheck'],
|
|
155
|
+
['clear', 'Clear'],
|
|
156
|
+
];
|
|
157
|
+
// Doing things, as far as the driver can.
|
|
158
|
+
export function actions(env, driver) {
|
|
159
|
+
const can = (a) => driver.actions.includes(a);
|
|
160
|
+
const act = (ctx, found, action) => driver.act(ctx, found.handle, action);
|
|
161
|
+
for (const [action, word] of ACTION_WORDS) {
|
|
162
|
+
if (can(action))
|
|
163
|
+
env.word(word, form([ElementType], (ctx, found) => act(ctx, found, { do: action })));
|
|
164
|
+
}
|
|
165
|
+
if (can('fill')) {
|
|
166
|
+
const fill = eitherOrder((ctx, found, text) => act(ctx, found, { do: 'fill', text }));
|
|
167
|
+
// a table of label | value rows
|
|
168
|
+
if (driver.queries.includes('label'))
|
|
169
|
+
fill.push(form([TableType], async (ctx, rows) => {
|
|
170
|
+
for (const [label, value] of rows)
|
|
171
|
+
await driver.act(ctx, driver.find(ctx, null, { by: 'label', name: label }), { do: 'fill', text: value });
|
|
172
|
+
}));
|
|
173
|
+
env.word('Fill', ...fill);
|
|
174
|
+
}
|
|
175
|
+
if (can('type'))
|
|
176
|
+
env.word('Type', ...eitherOrder((ctx, found, text) => act(ctx, found, { do: 'type', text })));
|
|
177
|
+
if (can('select'))
|
|
178
|
+
env.word('Select', ...eitherOrder((ctx, found, text) => act(ctx, found, { do: 'select', text })));
|
|
179
|
+
if (can('press')) {
|
|
180
|
+
const Key = called(TextType, 'a "Key" to press');
|
|
181
|
+
env.word('Press', form([Key, ElementType], (ctx, key, found) => act(ctx, found, { do: 'press', key })), form([Key], (ctx, key) => driver.press(ctx, key)));
|
|
182
|
+
}
|
|
183
|
+
return env;
|
|
184
|
+
}
|
|
185
|
+
const STATE_WORDS = [
|
|
186
|
+
['visible', 'Visible'],
|
|
187
|
+
['hidden', 'Hidden'],
|
|
188
|
+
['checked', 'Checked'],
|
|
189
|
+
['enabled', 'Enabled'],
|
|
190
|
+
['disabled', 'Disabled'],
|
|
191
|
+
['focused', 'Focused'],
|
|
192
|
+
['empty', 'Empty'],
|
|
193
|
+
];
|
|
194
|
+
// The states that take some text, like Value "eggs".
|
|
195
|
+
const TEXT_STATE_WORDS = [
|
|
196
|
+
['value', 'Value'],
|
|
197
|
+
['text', 'Read'],
|
|
198
|
+
['containing', 'Contain'],
|
|
199
|
+
];
|
|
200
|
+
// Checking things: See, a word for each state the driver can check, and Not.
|
|
201
|
+
export function checks(env, driver) {
|
|
202
|
+
const can = (s) => driver.states.includes(s);
|
|
203
|
+
if (can('visible')) {
|
|
204
|
+
const visible = new Matcher('Visible', { is: 'visible' });
|
|
205
|
+
const see = [form([ElementType], CheckType, (ctx, found) => visible.against(found, ctx, driver))];
|
|
206
|
+
if (driver.queries.includes('text'))
|
|
207
|
+
see.push(form([NameType], CheckType, (ctx, text) => {
|
|
208
|
+
const handle = driver.find(ctx, null, { by: 'text', name: nameOf(text), loose: true });
|
|
209
|
+
return visible.against(new Found(handle, `the text ${say(text)}`), ctx, driver);
|
|
210
|
+
}));
|
|
211
|
+
env.word('See', ...see);
|
|
212
|
+
}
|
|
213
|
+
for (const [is, word] of STATE_WORDS) {
|
|
214
|
+
if (can(is))
|
|
215
|
+
env.word(word, form([], MatcherType, () => new Matcher(word, { is })));
|
|
216
|
+
}
|
|
217
|
+
for (const [is, word] of TEXT_STATE_WORDS) {
|
|
218
|
+
if (can(is))
|
|
219
|
+
env.word(word, form([NameType], MatcherType, (_, v) => new Matcher(`${word} ${say(v)}`, { is, text: nameOf(v) })));
|
|
220
|
+
}
|
|
221
|
+
return env.word('Not', form([MatcherType], MatcherType, (_, m) => m.negate()), form([CheckType], CheckType, (_, c) => c.negate()));
|
|
222
|
+
}
|
|
223
|
+
// ---------------------------------------------------------------- the end of a step
|
|
224
|
+
// What's wrong with a step that leaves these behind, said the same way by
|
|
225
|
+
// leftovers and settle.
|
|
226
|
+
const unchecked = (matcher) => `Nothing to check is ${matcher}. Put what should be ${matcher} before it.`;
|
|
227
|
+
const unused = (values) => `Nothing used ${values.join(', ')}. Is the step missing a verb?`;
|
|
228
|
+
// settle's rule, for the checker: what settle would say about a step that
|
|
229
|
+
// leaves values of these types, or null if it would be happy.
|
|
230
|
+
export function leftovers(stack) {
|
|
231
|
+
const rest = [...stack];
|
|
232
|
+
for (let v = rest.pop(); v; v = rest.pop()) {
|
|
233
|
+
if (v.name === CheckType.name)
|
|
234
|
+
continue;
|
|
235
|
+
if (v.name === ElementType.name && rest.at(-1)?.name === MatcherType.name) {
|
|
236
|
+
rest.pop();
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (v.name === MatcherType.name)
|
|
240
|
+
return unchecked(v.shown);
|
|
241
|
+
return unused([v, ...rest.reverse()].map((x) => x.shown));
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
// A settle for a driver: it checks whatever assertions a step left behind.
|
|
246
|
+
// Anything else left over is a sentence with no verb, which is almost always
|
|
247
|
+
// a mistake worth saying so.
|
|
248
|
+
export function settler(driver) {
|
|
249
|
+
return async function settle(stack, ctx) {
|
|
250
|
+
const pending = [];
|
|
251
|
+
while (stack.length) {
|
|
252
|
+
// A noun's value is checked as what it is underneath.
|
|
253
|
+
const v = unwrap(stack.pop());
|
|
254
|
+
const under = unwrap(stack.at(-1));
|
|
255
|
+
if (v instanceof Expectation)
|
|
256
|
+
pending.push(v);
|
|
257
|
+
else if (v instanceof Found && under instanceof Matcher) {
|
|
258
|
+
stack.pop();
|
|
259
|
+
pending.push(under.against(v, ctx, driver));
|
|
260
|
+
}
|
|
261
|
+
else if (v instanceof Matcher) {
|
|
262
|
+
throw new SppError(unchecked(v.describe()));
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
throw new SppError(unused([v, ...stack.reverse()].map(describe)));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Checked in reading order: the stack holds them right to left.
|
|
269
|
+
for (const e of pending.reverse())
|
|
270
|
+
await e.check(false);
|
|
271
|
+
};
|
|
272
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spp-lang",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Specs that run. Written by agents, read by people.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/src/index.js",
|
|
7
|
+
"types": "dist/src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/src/index.d.ts",
|
|
11
|
+
"default": "./dist/src/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"spp-lang": "dist/bin/spp-lang.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.build.json",
|
|
23
|
+
"typecheck": "tsc",
|
|
24
|
+
"lint": "biome check",
|
|
25
|
+
"format": "biome check --write",
|
|
26
|
+
"test": "node --test 'test/**/*.test.ts'",
|
|
27
|
+
"test:e2e": "playwright test",
|
|
28
|
+
"demo": "node examples/demo/serve.ts",
|
|
29
|
+
"prepack": "npm run build",
|
|
30
|
+
"prepare": "husky"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"gherkin",
|
|
34
|
+
"cucumber",
|
|
35
|
+
"bdd",
|
|
36
|
+
"playwright",
|
|
37
|
+
"testing-library",
|
|
38
|
+
"cognate"
|
|
39
|
+
],
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22.13"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/willmartian/spp-lang.git"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@cucumber/gherkin": "^42.0.1",
|
|
50
|
+
"@cucumber/messages": "^34.2.1",
|
|
51
|
+
"commander": "^15.0.0",
|
|
52
|
+
"vscode-languageserver": "^10.1.2",
|
|
53
|
+
"vscode-languageserver-textdocument": "^1.0.15"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@playwright/test": "^1.63.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@biomejs/biome": "2.5.15",
|
|
60
|
+
"@commitlint/cli": "^21.2.3",
|
|
61
|
+
"@commitlint/config-conventional": "^21.2.3",
|
|
62
|
+
"@playwright/test": "^1.63.0",
|
|
63
|
+
"@types/node": "^26.6.3",
|
|
64
|
+
"husky": "^9.1.7",
|
|
65
|
+
"typescript": "^7.0.2"
|
|
66
|
+
}
|
|
67
|
+
}
|