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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Will Martin
|
|
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
CHANGED
|
@@ -1,3 +1,171 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 🥒 Spec++
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Specs that run. Written by agents, read by people.
|
|
4
|
+
|
|
5
|
+
```gherkin
|
|
6
|
+
Feature: Signing in
|
|
7
|
+
|
|
8
|
+
Scenario: The right password
|
|
9
|
+
Given I Visit "/"
|
|
10
|
+
When I Fill the Field "Email" with "will@example.com"
|
|
11
|
+
And I Fill the Field "Password" with "hunter2"
|
|
12
|
+
And I Click the Button "Sign in"
|
|
13
|
+
Then I should See the Heading "Welcome back"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Spec++ is Gherkin without step definitions. Capitalised words are code and lowercase words
|
|
17
|
+
are prose, so a step runs as written. Every scenario runs as a Playwright test.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
Agents writing tests reach for sleeps, conditionals and CSS selectors until a test passes.
|
|
22
|
+
Spec++ doesn't have them. Steps find elements by role, label and visible text, as a user
|
|
23
|
+
would, and a scenario does the same thing every run. A reviewer only has to ask whether the
|
|
24
|
+
spec tests the right thing. When nothing else works, `TestId` finds an element by test id,
|
|
25
|
+
and it stands out in review.
|
|
26
|
+
|
|
27
|
+
## Getting started
|
|
28
|
+
|
|
29
|
+
Spec++ needs Node.js 22.13 or later.
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm install --save-dev spp-lang @playwright/test
|
|
33
|
+
npx playwright install chromium
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// playwright.config.ts
|
|
38
|
+
import { defineConfig } from '@playwright/test'
|
|
39
|
+
import { defineSppConfig } from 'spp-lang'
|
|
40
|
+
|
|
41
|
+
export default defineConfig({
|
|
42
|
+
...defineSppConfig({ features: ['features'] }),
|
|
43
|
+
use: { baseURL: 'http://localhost:3000' },
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
npx playwright test
|
|
49
|
+
npx playwright test features/sign-in.spp:12 # the scenario on line 12
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`defineSppConfig` generates a small test file per `.spp` file in `.spp-tests/`, which you
|
|
53
|
+
should add to `.gitignore`. The rest of Playwright works as usual: projects, retries,
|
|
54
|
+
sharding, UI mode, the trace viewer and
|
|
55
|
+
[its VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright).
|
|
56
|
+
Failures point at the word in the `.spp` file that failed.
|
|
57
|
+
|
|
58
|
+
Without a Playwright config, `npx spp-lang test --base-url http://localhost:3000` runs every
|
|
59
|
+
`.spp` file under the current directory.
|
|
60
|
+
|
|
61
|
+
## The language
|
|
62
|
+
|
|
63
|
+
Capitalised words run right to left. In `Click the Button "Sign in"`, the text comes first,
|
|
64
|
+
`Button` turns it into the button with that name, and `Click` clicks it. Lowercase words
|
|
65
|
+
are skipped. Under the hood it's a small stack-based language, borrowed from
|
|
66
|
+
[Cognate](https://cognate-lang.github.io/). The [reference](docs/reference.md) covers
|
|
67
|
+
everything below in more detail, along with config options and multi-project setups.
|
|
68
|
+
|
|
69
|
+
Finding things follows [Testing Library](https://testing-library.com/docs/queries/about):
|
|
70
|
+
|
|
71
|
+
| Word | Finds |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `Button`, `Heading`, `Link`, `Checkbox`, `Row`, `Dialog`… | an element by ARIA role and name |
|
|
74
|
+
| `Field`, `Label` | a form control by its label |
|
|
75
|
+
| `Text` | an element by its text |
|
|
76
|
+
| `Placeholder`, `Alt`, `Title`, `TestId` | an element by that attribute |
|
|
77
|
+
|
|
78
|
+
Names match exactly. `Containing "Kale"` matches part of a name, and `Within` searches
|
|
79
|
+
inside another element.
|
|
80
|
+
|
|
81
|
+
Actions: `Visit`, `Reload`, `Back`, `Click`, `Double-click`, `Hover`, `Focus`, `Check`,
|
|
82
|
+
`Uncheck`, `Clear`, `Fill`, `Type`, `Select`, `Press`.
|
|
83
|
+
|
|
84
|
+
Checks: `See`, `Visible`, `Hidden`, `Checked`, `Enabled`, `Disabled`, `Focused`, `Empty`,
|
|
85
|
+
`Value`, `Read`, `Contain`, `At` and `Titled`. `Not` negates any of them, and they retry
|
|
86
|
+
like Playwright's `expect`.
|
|
87
|
+
|
|
88
|
+
```gherkin
|
|
89
|
+
When I Click the Button "Remove" Within the Row Containing "Kale"
|
|
90
|
+
Then I should Not See the Cell "Kale"
|
|
91
|
+
And I should be At "/list"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Backgrounds, Scenario Outlines, Rules and data tables work as in Cucumber. Tags become
|
|
95
|
+
Playwright tags, so `@skip`, `@only` and `--grep @smoke` work.
|
|
96
|
+
|
|
97
|
+
### Your own words
|
|
98
|
+
|
|
99
|
+
Project vocabulary is written in Spec++ too, with `Define`:
|
|
100
|
+
|
|
101
|
+
```gherkin
|
|
102
|
+
Define: Add Text (Item)
|
|
103
|
+
Fill the Field "Item" with Item
|
|
104
|
+
Click the Button "Add"
|
|
105
|
+
|
|
106
|
+
Feature: The grocery list
|
|
107
|
+
|
|
108
|
+
Scenario: Adding things
|
|
109
|
+
When I Add "Milk" to the list
|
|
110
|
+
Then I should See the Cell "Milk"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
After its name, a Define lists what it takes: `Text`, `Number`, `Element`, `Table` or
|
|
114
|
+
`Any`, with an optional name in brackets. Defines can sit above a Feature or in a file of
|
|
115
|
+
their own, and every file in the project can use them. A Define that leaves one value,
|
|
116
|
+
like `Define: Cart` leaving a region of the page, becomes a type that other Defines can take.
|
|
117
|
+
|
|
118
|
+
### Checking
|
|
119
|
+
|
|
120
|
+
Every word has a type, so steps are checked before a browser starts. The types of your own
|
|
121
|
+
Defines are inferred from how they're used.
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
SppError: Click expected something on the page, but got "Add"
|
|
125
|
+
|
|
126
|
+
> 4 | When I Click "Add"
|
|
127
|
+
| ^
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
npx spp-lang check # check without running
|
|
132
|
+
npx spp-lang dictionary # list every word you can use, with its type
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## In Markdown
|
|
136
|
+
|
|
137
|
+
` ```spp ` blocks in Markdown files run as tests too, like Rust's doctests, so the examples
|
|
138
|
+
in your docs stay true. Mark a block ` ```spp ignore ` to leave it out. See
|
|
139
|
+
[`examples/demo/features/README.md`](examples/demo/features/README.md).
|
|
140
|
+
|
|
141
|
+
## Editors
|
|
142
|
+
|
|
143
|
+
`spp-lang lsp` is a language server with diagnostics, hover, go to definition, find
|
|
144
|
+
references and completion.
|
|
145
|
+
|
|
146
|
+
- **VS Code:** install
|
|
147
|
+
[the Spec++ extension](https://marketplace.visualstudio.com/items?itemName=spp-lang.spp-vscode).
|
|
148
|
+
It uses your project's `spp-lang` if there is one.
|
|
149
|
+
- **Claude Code:** install the plugin, which teaches Claude the language and runs the
|
|
150
|
+
language server.
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
claude plugin marketplace add willmartian/spp-lang
|
|
154
|
+
claude plugin install spp-lang@spp-lang
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- **Neovim** (0.11 or later):
|
|
158
|
+
|
|
159
|
+
```lua
|
|
160
|
+
vim.filetype.add({ extension = { spp = 'spp' } })
|
|
161
|
+
vim.lsp.config('spp', {
|
|
162
|
+
cmd = { 'npx', 'spp-lang', 'lsp' },
|
|
163
|
+
filetypes = { 'spp' },
|
|
164
|
+
root_markers = { 'package.json', '.git' },
|
|
165
|
+
})
|
|
166
|
+
vim.lsp.enable('spp')
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Contributing
|
|
170
|
+
|
|
171
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// spp-lang test [paths...] [--base-url <url>] [playwright test options...]
|
|
3
|
+
// spp-lang check [paths...]
|
|
4
|
+
// spp-lang dictionary
|
|
5
|
+
// spp-lang lsp
|
|
6
|
+
//
|
|
7
|
+
// Runs the scenarios with Playwright, or just checks them, without a browser,
|
|
8
|
+
// or lists the words they can use. The project's Playwright config says where
|
|
9
|
+
// the .spp files are; without one, every .spp file, and every Markdown file's
|
|
10
|
+
// ```spp blocks, under the current directory. Everything else, like --headed,
|
|
11
|
+
// --ui or --grep, goes to Playwright.
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { styleText } from 'node:util';
|
|
17
|
+
import { Command, Option } from 'commander';
|
|
18
|
+
import { browser } from '../src/backends/playwright/words.js';
|
|
19
|
+
import { findConfig, loadProjects } from '../src/config.js';
|
|
20
|
+
import { analyze, check, format } from '../src/language/check.js';
|
|
21
|
+
import { core } from '../src/language/cognate.js';
|
|
22
|
+
import { discover, LoadError, load } from '../src/language/load.js';
|
|
23
|
+
import { dictionary } from '../src/tools/dictionary.js';
|
|
24
|
+
import { leftovers } from '../src/words/shared.js';
|
|
25
|
+
// Commander leaves the colour out when the output isn't a terminal, or $NO_COLOR is set.
|
|
26
|
+
const style = (format, text) => styleText(format, text, { validateStream: false });
|
|
27
|
+
// From the source, package.json is one directory up; once built, two.
|
|
28
|
+
const packageJson = ['../package.json', '../../package.json']
|
|
29
|
+
.map((p) => path.join(import.meta.dirname, p))
|
|
30
|
+
.find((p) => fs.existsSync(p));
|
|
31
|
+
if (!packageJson)
|
|
32
|
+
throw new Error(`No package.json above ${import.meta.dirname}`);
|
|
33
|
+
const { version } = JSON.parse(fs.readFileSync(packageJson, 'utf8'));
|
|
34
|
+
const program = new Command('spp-lang')
|
|
35
|
+
.usage('<command> [options]')
|
|
36
|
+
.version(version, '-V, --version', 'Show the version')
|
|
37
|
+
.helpOption('-h, --help', 'Show help')
|
|
38
|
+
.helpCommand('help [command]', 'Show help for a command')
|
|
39
|
+
// Set before the subcommands, which inherit it.
|
|
40
|
+
.configureHelp({
|
|
41
|
+
styleTitle: (s) => style('bold', s),
|
|
42
|
+
styleCommandText: (s) => style('cyan', s),
|
|
43
|
+
styleSubcommandText: (s) => style('cyan', s),
|
|
44
|
+
styleOptionText: (s) => style('cyan', s),
|
|
45
|
+
})
|
|
46
|
+
.addHelpText('beforeAll', `${style(['green', 'bold'], 'Spec++')} ${style('dim', version)}\n`)
|
|
47
|
+
.addHelpText('before', 'Specs that run. Written by agents, read by people.\n')
|
|
48
|
+
.addHelpText('after', `
|
|
49
|
+
${style('bold', 'Examples:')}
|
|
50
|
+
${style('dim', '$')} spp-lang test features --base-url http://localhost:3000
|
|
51
|
+
${style('dim', '$')} spp-lang test features/cart.spp:12 --headed
|
|
52
|
+
${style('dim', '$')} spp-lang check
|
|
53
|
+
|
|
54
|
+
The project is what the defineSppConfig calls in the Playwright config above
|
|
55
|
+
the current directory say, or, without one, every .spp file, and Markdown file
|
|
56
|
+
with a \`\`\`spp block, under it. Every Define in a project can be used by all
|
|
57
|
+
of its files.`);
|
|
58
|
+
program
|
|
59
|
+
.command('test')
|
|
60
|
+
.usage('[paths...] [--base-url <url>] [playwright test options...]')
|
|
61
|
+
.description('Run the scenarios as Playwright tests')
|
|
62
|
+
.argument('[paths...]')
|
|
63
|
+
.addOption(new Option('-u, --base-url <url>', 'Where "Visit "/"" goes').env('SPP_BASE_URL'))
|
|
64
|
+
.allowUnknownOption()
|
|
65
|
+
.addHelpText('after', `
|
|
66
|
+
Runs "playwright test" with the project's Playwright config, or, without one,
|
|
67
|
+
on every .spp file under the current directory. A path can be a directory, a
|
|
68
|
+
file, or a file and a line, like features/cart.spp:12, for the scenario on that
|
|
69
|
+
line, or the one a step on that line is in. The \`\`\`spp blocks in Markdown
|
|
70
|
+
files are scenarios too, so README.md:40 works the same.
|
|
71
|
+
|
|
72
|
+
Everything else, like --headed, --ui, --grep or --project, is passed to
|
|
73
|
+
"playwright test" as it is.`)
|
|
74
|
+
.action((args, options) => process.exit(runTests(args, options.baseUrl)));
|
|
75
|
+
program
|
|
76
|
+
.command('check')
|
|
77
|
+
.description('Check the files without a browser')
|
|
78
|
+
.argument('[paths...]')
|
|
79
|
+
.addHelpText('after', `
|
|
80
|
+
Checks every file under each path, or the whole project, and exits non-zero if
|
|
81
|
+
it finds a mistake. The rest of the project is still loaded, for its Defines.`)
|
|
82
|
+
.action(async (paths) => process.exit(checkOnly(await loadProjects(), paths)));
|
|
83
|
+
program
|
|
84
|
+
.command('dictionary')
|
|
85
|
+
.description('List every word the project can use')
|
|
86
|
+
.addHelpText('after', `
|
|
87
|
+
Lists the built-in words and the project's own Defines, with what each takes
|
|
88
|
+
and leaves.`)
|
|
89
|
+
.action(async () => process.exit(printDictionary(await loadProjects())));
|
|
90
|
+
program
|
|
91
|
+
.command('lsp')
|
|
92
|
+
.description('Language server for editors')
|
|
93
|
+
.addHelpText('after', `
|
|
94
|
+
A language server over stdio: mistakes as you type, signatures on hover,
|
|
95
|
+
completion and go to definition.`)
|
|
96
|
+
// What editors' language clients pass. Stdio is the only way it talks, and
|
|
97
|
+
// the server reads the client's process id itself, to exit when it does.
|
|
98
|
+
.addOption(new Option('--stdio').hideHelp())
|
|
99
|
+
.addOption(new Option('--clientProcessId <pid>').hideHelp())
|
|
100
|
+
.action(async () => {
|
|
101
|
+
// Loaded only here, so running tests doesn't pay for it.
|
|
102
|
+
const { startServer } = await import('../src/tools/lsp.js');
|
|
103
|
+
startServer();
|
|
104
|
+
});
|
|
105
|
+
await program.parseAsync();
|
|
106
|
+
// Whether a file is one of those the paths name; with none, every file is.
|
|
107
|
+
function named(paths, cwd) {
|
|
108
|
+
const targets = paths.map((p) => path.resolve(cwd, p.replace(/:\d+$/, '')));
|
|
109
|
+
return (file) => !targets.length || targets.some((t) => file === t || file.startsWith(t + path.sep));
|
|
110
|
+
}
|
|
111
|
+
// A project's files, and why those that don't load don't.
|
|
112
|
+
function loadAll(project) {
|
|
113
|
+
const files = [];
|
|
114
|
+
const broken = [];
|
|
115
|
+
for (const file of discover(project.features, project.root, project.ignore)) {
|
|
116
|
+
try {
|
|
117
|
+
files.push(load(file));
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (!(err instanceof LoadError))
|
|
121
|
+
throw err;
|
|
122
|
+
broken.push(err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { files, broken };
|
|
126
|
+
}
|
|
127
|
+
function checkOnly(projects, paths) {
|
|
128
|
+
const cwd = process.cwd();
|
|
129
|
+
const shown = named(paths, cwd);
|
|
130
|
+
// Each project is checked whole, since the named files may use Defines from
|
|
131
|
+
// the rest of it. A file in more than one is reported once.
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
const checked = new Set();
|
|
134
|
+
let problems = 0;
|
|
135
|
+
for (const project of projects) {
|
|
136
|
+
const { files, broken } = loadAll(project);
|
|
137
|
+
for (const err of broken) {
|
|
138
|
+
if (!shown(err.file) || seen.has(err.file))
|
|
139
|
+
continue;
|
|
140
|
+
seen.add(err.file);
|
|
141
|
+
checked.add(err.file);
|
|
142
|
+
// A file that doesn't parse: its message already says where.
|
|
143
|
+
console.log(`${path.relative(cwd, err.file)}\n ${err.message}\n`);
|
|
144
|
+
problems++;
|
|
145
|
+
}
|
|
146
|
+
const report = check(files, { words: browser(core()), settle: leftovers, cwd });
|
|
147
|
+
// An Outline's rows share their steps, and so their mistakes.
|
|
148
|
+
const diagnostics = [...report.vocabulary, ...report.scenarios.values()].filter((d) => {
|
|
149
|
+
const key = `${d.file}:${d.line}:${d.column}:${d.message}`;
|
|
150
|
+
return shown(d.file) && !seen.has(key) && seen.add(key);
|
|
151
|
+
});
|
|
152
|
+
for (const d of diagnostics)
|
|
153
|
+
console.log(`${format(d, fs.readFileSync(d.file, 'utf8'), cwd)}\n`);
|
|
154
|
+
problems += diagnostics.length;
|
|
155
|
+
for (const f of files)
|
|
156
|
+
if (shown(f.file))
|
|
157
|
+
checked.add(f.file);
|
|
158
|
+
}
|
|
159
|
+
const count = checked.size;
|
|
160
|
+
const files = `${count} file${count === 1 ? '' : 's'}`;
|
|
161
|
+
console.log(problems ? `${problems} mistake${problems === 1 ? '' : 's'} in ${files}` : `No mistakes in ${files}`);
|
|
162
|
+
return problems ? 1 : 0;
|
|
163
|
+
}
|
|
164
|
+
function printDictionary(projects) {
|
|
165
|
+
const cwd = process.cwd();
|
|
166
|
+
const words = browser(core());
|
|
167
|
+
for (const [i, project] of projects.entries()) {
|
|
168
|
+
// With more than one project, each has words of its own.
|
|
169
|
+
if (projects.length > 1) {
|
|
170
|
+
const where = project.features.map((f) => path.relative(cwd, f) || '.').join(', ');
|
|
171
|
+
console.log(`${i ? '\n' : ''}${style('bold', `Project: ${where}`)}\n`);
|
|
172
|
+
}
|
|
173
|
+
// A file that doesn't parse has no Defines to list; check says why.
|
|
174
|
+
const { files } = loadAll(project);
|
|
175
|
+
console.log(dictionary(words, analyze(files, { words, settle: leftovers, cwd }), cwd));
|
|
176
|
+
}
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
// Runs Playwright with the project's config, or Spec++'s own without one.
|
|
180
|
+
// Every argument goes to Playwright, but a .spp or Markdown file and a step's
|
|
181
|
+
// line becomes its scenario's line, which is what Playwright knows the test by.
|
|
182
|
+
function runTests(args, baseURL) {
|
|
183
|
+
const require = createRequire(import.meta.url);
|
|
184
|
+
// .ts when run from source, .js once built
|
|
185
|
+
const config = findConfig(process.cwd()) ??
|
|
186
|
+
path.join(import.meta.dirname, `../src/backends/playwright/runner/playwright.config${path.extname(import.meta.filename)}`);
|
|
187
|
+
const result = spawnSync(process.execPath, [require.resolve('@playwright/test/cli'), 'test', '--config', config, ...args.map(scenarioLine)], {
|
|
188
|
+
stdio: 'inherit',
|
|
189
|
+
env: { ...process.env, SPP_CWD: process.cwd(), ...(baseURL ? { SPP_BASE_URL: baseURL } : {}) },
|
|
190
|
+
});
|
|
191
|
+
return result.status ?? 1;
|
|
192
|
+
}
|
|
193
|
+
// "features/cart.spp:14", where line 14 is a step, as "features/cart.spp:12",
|
|
194
|
+
// the line of its scenario. Anything else is left as it is.
|
|
195
|
+
function scenarioLine(arg) {
|
|
196
|
+
const m = /^(.+\.(?:spp|md)):(\d+)$/.exec(arg);
|
|
197
|
+
if (!m || !fs.existsSync(m[1]))
|
|
198
|
+
return arg;
|
|
199
|
+
const [, file, line] = m;
|
|
200
|
+
let loaded;
|
|
201
|
+
try {
|
|
202
|
+
loaded = load(path.resolve(file));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return arg;
|
|
206
|
+
}
|
|
207
|
+
const n = Number(line);
|
|
208
|
+
const scenario = loaded.features.flatMap((f) => f.scenarios).find((s) => s.lines.includes(n));
|
|
209
|
+
if (!scenario)
|
|
210
|
+
return arg;
|
|
211
|
+
// A step of an Outline is in every row, so it names the Outline, and all of them.
|
|
212
|
+
const target = scenario.outline && n !== scenario.line ? scenario.outline.line : scenario.line;
|
|
213
|
+
return target === n ? arg : `${file}:${target}`;
|
|
214
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Project } from '../../config.ts';
|
|
2
|
+
export interface SppConfig {
|
|
3
|
+
features?: string[];
|
|
4
|
+
ignore?: string[];
|
|
5
|
+
outputDir?: string;
|
|
6
|
+
root?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SppTests {
|
|
9
|
+
testDir: string;
|
|
10
|
+
testMatch: RegExp;
|
|
11
|
+
}
|
|
12
|
+
export declare function defineSppConfig(config?: SppConfig): SppTests;
|
|
13
|
+
export declare function generate(project: Project): string[];
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Spec++ in a Playwright config: scenarios become ordinary Playwright tests,
|
|
2
|
+
// so Playwright's command line, UI mode, reports and editor extensions work on
|
|
3
|
+
// them as they do on any test.
|
|
4
|
+
//
|
|
5
|
+
// // playwright.config.ts
|
|
6
|
+
// import { defineConfig } from '@playwright/test'
|
|
7
|
+
// import { defineSppConfig } from 'spp-lang'
|
|
8
|
+
//
|
|
9
|
+
// export default defineConfig({
|
|
10
|
+
// ...defineSppConfig({ features: ['features'] }),
|
|
11
|
+
// use: { baseURL: 'http://localhost:3000' },
|
|
12
|
+
// })
|
|
13
|
+
//
|
|
14
|
+
// defineSppConfig writes a test file for each .spp file, and each Markdown
|
|
15
|
+
// file with an ```spp block, into its outputDir, and returns the testDir and
|
|
16
|
+
// testMatch that find them. The testDir is the project's root, not the
|
|
17
|
+
// outputDir, so Playwright says where tests are relative to the root:
|
|
18
|
+
// features/cart.spp, not ../features/cart.spp.
|
|
19
|
+
//
|
|
20
|
+
// The files are written every time Playwright loads the config, so they're
|
|
21
|
+
// never out of date, and they're no more than a list of the scenarios: what
|
|
22
|
+
// each one does is read from the .spp file when it runs (runtime.ts). Each has
|
|
23
|
+
// a source map back to its .spp file, so Playwright puts every test, and every
|
|
24
|
+
// failure, on its line in the .spp file.
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
28
|
+
import { registry } from '../../config.js';
|
|
29
|
+
import { check } from '../../language/check.js';
|
|
30
|
+
import { core } from '../../language/cognate.js';
|
|
31
|
+
import { discover, LoadError, load } from '../../language/load.js';
|
|
32
|
+
import { escapeRegExp, leftovers } from '../../words/shared.js';
|
|
33
|
+
import { browser } from './words.js';
|
|
34
|
+
export function defineSppConfig(config = {}) {
|
|
35
|
+
const root = path.resolve(config.root ?? callerDir());
|
|
36
|
+
const strings = (paths, what) => {
|
|
37
|
+
if (paths !== undefined && !(Array.isArray(paths) && paths.every((p) => typeof p === 'string'))) {
|
|
38
|
+
throw new Error(`defineSppConfig: ${what} should be a list of paths`);
|
|
39
|
+
}
|
|
40
|
+
return (paths ?? []).map((p) => path.resolve(root, p));
|
|
41
|
+
};
|
|
42
|
+
const features = strings(config.features, 'features');
|
|
43
|
+
const project = {
|
|
44
|
+
root,
|
|
45
|
+
features: features.length ? features : [root],
|
|
46
|
+
ignore: strings(config.ignore, 'ignore'),
|
|
47
|
+
outputDir: path.resolve(root, config.outputDir ?? '.spp-tests'),
|
|
48
|
+
};
|
|
49
|
+
registry.projects.push(project);
|
|
50
|
+
// Playwright loads the config in every worker too, but only its first load,
|
|
51
|
+
// in the main process, should write anything.
|
|
52
|
+
if (!registry.collecting && process.env.TEST_WORKER_INDEX === undefined)
|
|
53
|
+
generate(project);
|
|
54
|
+
return { testDir: root, testMatch: new RegExp(`^${escapeRegExp(project.outputDir + path.sep)}.*\\.spec\\.js$`) };
|
|
55
|
+
}
|
|
56
|
+
// The directory of the file that called defineSppConfig: the config. Read
|
|
57
|
+
// from the stack, as Playwright reads where each test is declared.
|
|
58
|
+
function callerDir() {
|
|
59
|
+
const prepare = Error.prepareStackTrace;
|
|
60
|
+
let caller;
|
|
61
|
+
try {
|
|
62
|
+
Error.prepareStackTrace = (_err, frames) => {
|
|
63
|
+
caller = frames[0]?.getFileName();
|
|
64
|
+
return '';
|
|
65
|
+
};
|
|
66
|
+
const holder = {};
|
|
67
|
+
Error.captureStackTrace(holder, defineSppConfig);
|
|
68
|
+
// Reading the stack is what runs prepareStackTrace.
|
|
69
|
+
if (holder.stack === undefined)
|
|
70
|
+
caller = undefined;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
Error.prepareStackTrace = prepare;
|
|
74
|
+
}
|
|
75
|
+
if (!caller)
|
|
76
|
+
throw new Error("defineSppConfig couldn't tell which file called it. Give it a root.");
|
|
77
|
+
return path.dirname(caller.startsWith('file:') ? fileURLToPath(caller) : caller);
|
|
78
|
+
}
|
|
79
|
+
// ---------------------------------------------------------------- generating
|
|
80
|
+
const HEADER = '// Generated by Spec++';
|
|
81
|
+
// .ts when run from source, .js once built.
|
|
82
|
+
const RUNTIME = path.join(import.meta.dirname, `runtime${path.extname(import.meta.filename)}`);
|
|
83
|
+
// Writes a test file for each file in the project that has scenarios or
|
|
84
|
+
// mistakes, and removes those for files that no longer do. A file is only
|
|
85
|
+
// written when it changes, so watchers don't see a change on every run.
|
|
86
|
+
export function generate(project) {
|
|
87
|
+
const files = [];
|
|
88
|
+
const broken = new Map();
|
|
89
|
+
for (const file of discover(project.features, project.root, project.ignore)) {
|
|
90
|
+
try {
|
|
91
|
+
files.push(load(file));
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (!(err instanceof LoadError))
|
|
95
|
+
throw err;
|
|
96
|
+
broken.set(file, err);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const report = check(files, { words: browser(core()), settle: leftovers, cwd: project.root });
|
|
100
|
+
const written = new Set();
|
|
101
|
+
const write = (source, test) => {
|
|
102
|
+
const out = path.join(project.outputDir, `${path.relative(project.root, source)}.spec.js`);
|
|
103
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
104
|
+
const { code, map } = render(project, source, out, test);
|
|
105
|
+
for (const [file, text] of [
|
|
106
|
+
[out, code],
|
|
107
|
+
[`${out}.map`, map],
|
|
108
|
+
]) {
|
|
109
|
+
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== text)
|
|
110
|
+
fs.writeFileSync(file, text);
|
|
111
|
+
written.add(file);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
for (const { file, features } of files) {
|
|
115
|
+
const defines = report.vocabulary.filter((d) => d.file === file);
|
|
116
|
+
const mistakes = new Set(features.flatMap((f) => f.scenarios).filter((s) => report.scenarios.has(s)));
|
|
117
|
+
if (features.some((f) => f.scenarios.length) || defines.length)
|
|
118
|
+
write(file, { features, defines, mistakes });
|
|
119
|
+
}
|
|
120
|
+
for (const [file, err] of broken)
|
|
121
|
+
write(file, { features: [], defines: [], broken: err });
|
|
122
|
+
// ESM whatever the project's own package.json says.
|
|
123
|
+
const pkg = path.join(project.outputDir, 'package.json');
|
|
124
|
+
fs.mkdirSync(project.outputDir, { recursive: true });
|
|
125
|
+
if (!fs.existsSync(pkg))
|
|
126
|
+
fs.writeFileSync(pkg, '{ "type": "module" }\n');
|
|
127
|
+
removeStale(project.outputDir, written);
|
|
128
|
+
return [...written];
|
|
129
|
+
}
|
|
130
|
+
// The file, and its source map, which is a file of its own: Playwright reads
|
|
131
|
+
// either kind, but its VS Code extension only reads a map in a file, and needs
|
|
132
|
+
// one to know that the tests are the .spp file's.
|
|
133
|
+
function render(project, source, out, { features, defines, mistakes, broken }) {
|
|
134
|
+
const s = (value) => JSON.stringify(value);
|
|
135
|
+
const lines = [
|
|
136
|
+
[
|
|
137
|
+
`${HEADER} from ${path.relative(project.root, source)}, each time Playwright loads its config. Don't edit it.`,
|
|
138
|
+
undefined,
|
|
139
|
+
],
|
|
140
|
+
[`import { project } from ${s(pathToFileURL(RUNTIME).href)}`, undefined],
|
|
141
|
+
[`const { test, scenario, mistake, define, broken } = project(${s(project)})`, undefined],
|
|
142
|
+
[`const file = ${s(source)}`, undefined],
|
|
143
|
+
];
|
|
144
|
+
if (broken) {
|
|
145
|
+
lines.push([`test(${s(path.basename(source))}, broken(file))`, broken.problems[0]?.line ?? 1]);
|
|
146
|
+
}
|
|
147
|
+
// One test per broken Define, as there's one per scenario.
|
|
148
|
+
const seen = new Set();
|
|
149
|
+
for (const d of defines) {
|
|
150
|
+
if (seen.has(d.line))
|
|
151
|
+
continue;
|
|
152
|
+
seen.add(d.line);
|
|
153
|
+
lines.push([`test(${s(d.define ? `Define ${d.define}` : 'Defines')}, define(file, ${d.line}))`, d.line]);
|
|
154
|
+
}
|
|
155
|
+
for (const feature of features) {
|
|
156
|
+
if (!feature.scenarios.length)
|
|
157
|
+
continue;
|
|
158
|
+
lines.push([`test.describe(${s(feature.name || path.basename(source))}, () => {`, feature.line]);
|
|
159
|
+
// An Outline's rows go together, under it. open is the line of the
|
|
160
|
+
// Outline whose describe is open.
|
|
161
|
+
let open;
|
|
162
|
+
for (const scenario of feature.scenarios) {
|
|
163
|
+
const { outline } = scenario;
|
|
164
|
+
if (open !== undefined && outline?.line !== open) {
|
|
165
|
+
lines.push([' })', undefined]);
|
|
166
|
+
open = undefined;
|
|
167
|
+
}
|
|
168
|
+
if (outline && open === undefined) {
|
|
169
|
+
lines.push([` test.describe(${s(outline.name)}, () => {`, outline.line]);
|
|
170
|
+
open = outline.line;
|
|
171
|
+
}
|
|
172
|
+
const indent = outline ? ' ' : ' ';
|
|
173
|
+
lines.push([`${indent}${declaration(scenario, mistakes?.has(scenario) ?? false)}`, scenario.line]);
|
|
174
|
+
}
|
|
175
|
+
if (open !== undefined)
|
|
176
|
+
lines.push([' })', undefined]);
|
|
177
|
+
lines.push(['})', undefined]);
|
|
178
|
+
}
|
|
179
|
+
const code = `${lines.map(([line]) => line).join('\n')}\n//# sourceMappingURL=${path.basename(out)}.map\n`;
|
|
180
|
+
return { code, map: sourceMap(path.relative(path.dirname(out), source), lines) };
|
|
181
|
+
}
|
|
182
|
+
function declaration(scenario, mistaken) {
|
|
183
|
+
const s = (value) => JSON.stringify(value);
|
|
184
|
+
const title = scenario.outline ? scenario.outline.row : scenario.name;
|
|
185
|
+
const declare = scenario.tags.includes('@only') ? 'test.only' : 'test';
|
|
186
|
+
const details = scenario.tags.length ? `{ tag: ${s(scenario.tags)} }, ` : '';
|
|
187
|
+
const body = mistaken ? 'mistake' : 'scenario';
|
|
188
|
+
return `${declare}(${s(title)}, ${details}${body}(file, ${scenario.line}))`;
|
|
189
|
+
}
|
|
190
|
+
function removeStale(dir, keep) {
|
|
191
|
+
if (!fs.existsSync(dir))
|
|
192
|
+
return;
|
|
193
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
194
|
+
const full = path.join(dir, entry.name);
|
|
195
|
+
if (entry.isDirectory()) {
|
|
196
|
+
removeStale(full, keep);
|
|
197
|
+
if (!fs.readdirSync(full).length)
|
|
198
|
+
fs.rmdirSync(full);
|
|
199
|
+
}
|
|
200
|
+
else if (entry.name.endsWith('.spec.js') && !keep.has(full) && isGenerated(full)) {
|
|
201
|
+
fs.rmSync(full);
|
|
202
|
+
fs.rmSync(`${full}.map`, { force: true });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Only files Spec++ wrote are removed, whatever else is there.
|
|
207
|
+
const isGenerated = (file) => fs.readFileSync(file, 'utf8').startsWith(HEADER);
|
|
208
|
+
// ---------------------------------------------------------------- source maps
|
|
209
|
+
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
210
|
+
// A number as a source map writes it: base64 digits of five bits each, least
|
|
211
|
+
// significant first, with the sign in the lowest bit of the first.
|
|
212
|
+
function vlq(n) {
|
|
213
|
+
let rest = n < 0 ? (-n << 1) | 1 : n << 1;
|
|
214
|
+
let out = '';
|
|
215
|
+
do {
|
|
216
|
+
let digit = rest & 31;
|
|
217
|
+
rest >>>= 5;
|
|
218
|
+
if (rest)
|
|
219
|
+
digit |= 32;
|
|
220
|
+
out += BASE64[digit];
|
|
221
|
+
} while (rest);
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
// A version 3 source map, from each generated line that stands for
|
|
225
|
+
// a line of the source to the start of that line. Each mapped line has one
|
|
226
|
+
// segment: generated column 0, the only source, its line, column 0, each but
|
|
227
|
+
// the generated column relative to the segment before.
|
|
228
|
+
function sourceMap(source, lines) {
|
|
229
|
+
let previous = 0;
|
|
230
|
+
const mappings = lines.map(([, line]) => {
|
|
231
|
+
if (line === undefined)
|
|
232
|
+
return '';
|
|
233
|
+
const segment = `A${vlq(0)}${vlq(line - 1 - previous)}${vlq(0)}`;
|
|
234
|
+
previous = line - 1;
|
|
235
|
+
return segment;
|
|
236
|
+
});
|
|
237
|
+
return `${JSON.stringify({ version: 3, sources: [source], names: [], mappings: mappings.join(';') })}\n`;
|
|
238
|
+
}
|