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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -2
  3. package/dist/bin/spp-lang.d.ts +2 -0
  4. package/dist/bin/spp-lang.js +214 -0
  5. package/dist/src/backends/playwright/generate.d.ts +13 -0
  6. package/dist/src/backends/playwright/generate.js +238 -0
  7. package/dist/src/backends/playwright/runner/playwright.config.d.ts +2 -0
  8. package/dist/src/backends/playwright/runner/playwright.config.js +16 -0
  9. package/dist/src/backends/playwright/runtime.d.ts +14 -0
  10. package/dist/src/backends/playwright/runtime.js +158 -0
  11. package/dist/src/backends/playwright/words.d.ts +11 -0
  12. package/dist/src/backends/playwright/words.js +129 -0
  13. package/dist/src/config.d.ts +18 -0
  14. package/dist/src/config.js +63 -0
  15. package/dist/src/index.d.ts +22 -0
  16. package/dist/src/index.js +12 -0
  17. package/dist/src/language/check.d.ts +178 -0
  18. package/dist/src/language/check.js +709 -0
  19. package/dist/src/language/cognate.d.ts +137 -0
  20. package/dist/src/language/cognate.js +570 -0
  21. package/dist/src/language/load.d.ts +58 -0
  22. package/dist/src/language/load.js +200 -0
  23. package/dist/src/language/markdown.d.ts +9 -0
  24. package/dist/src/language/markdown.js +52 -0
  25. package/dist/src/language/sections.d.ts +19 -0
  26. package/dist/src/language/sections.js +119 -0
  27. package/dist/src/language/steps.d.ts +19 -0
  28. package/dist/src/language/steps.js +91 -0
  29. package/dist/src/language/types.d.ts +32 -0
  30. package/dist/src/language/types.js +51 -0
  31. package/dist/src/tools/dictionary.d.ts +3 -0
  32. package/dist/src/tools/dictionary.js +89 -0
  33. package/dist/src/tools/lsp.d.ts +1 -0
  34. package/dist/src/tools/lsp.js +479 -0
  35. package/dist/src/words/driver.d.ts +39 -0
  36. package/dist/src/words/driver.js +9 -0
  37. package/dist/src/words/shared.d.ts +56 -0
  38. package/dist/src/words/shared.js +272 -0
  39. 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
- # Temporary Holding Version
1
+ # 🥒 Spec++
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@playwright/test").PlaywrightTestConfig<{}, {}>;
2
+ export default _default;