dungbeetle 0.1.1
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 +105 -0
- package/NOTICE +19 -0
- package/README.md +139 -0
- package/dist/api/capture.d.ts +24 -0
- package/dist/api/capture.js +61 -0
- package/dist/baselines.d.ts +7 -0
- package/dist/baselines.js +38 -0
- package/dist/brand.d.ts +2 -0
- package/dist/brand.js +9 -0
- package/dist/capture.d.ts +15 -0
- package/dist/capture.js +7 -0
- package/dist/captures/api.d.ts +2 -0
- package/dist/captures/api.js +114 -0
- package/dist/captures/check.d.ts +2 -0
- package/dist/captures/check.js +116 -0
- package/dist/captures/desktop.d.ts +2 -0
- package/dist/captures/desktop.js +97 -0
- package/dist/captures/game.d.ts +4 -0
- package/dist/captures/game.js +266 -0
- package/dist/captures/performance.d.ts +2 -0
- package/dist/captures/performance.js +47 -0
- package/dist/captures/registry.d.ts +4 -0
- package/dist/captures/registry.js +23 -0
- package/dist/captures/terminal.d.ts +2 -0
- package/dist/captures/terminal.js +65 -0
- package/dist/captures/types.d.ts +18 -0
- package/dist/captures/types.js +1 -0
- package/dist/captures/web.d.ts +3 -0
- package/dist/captures/web.js +248 -0
- package/dist/check/capture.d.ts +15 -0
- package/dist/check/capture.js +76 -0
- package/dist/check/junit.d.ts +9 -0
- package/dist/check/junit.js +51 -0
- package/dist/check/laravel.d.ts +2 -0
- package/dist/check/laravel.js +44 -0
- package/dist/check/parsers.d.ts +12 -0
- package/dist/check/parsers.js +278 -0
- package/dist/check/schema.d.ts +2 -0
- package/dist/check/schema.js +114 -0
- package/dist/cloud.d.ts +42 -0
- package/dist/cloud.js +334 -0
- package/dist/compare/shared.d.ts +42 -0
- package/dist/compare/shared.js +115 -0
- package/dist/compare.d.ts +3 -0
- package/dist/compare.js +33 -0
- package/dist/config.d.ts +146 -0
- package/dist/config.js +382 -0
- package/dist/desktop/a11y.d.ts +18 -0
- package/dist/desktop/a11y.js +74 -0
- package/dist/desktop/capture.d.ts +13 -0
- package/dist/desktop/capture.js +80 -0
- package/dist/desktop/macos.d.ts +8 -0
- package/dist/desktop/macos.js +98 -0
- package/dist/desktop/ocr.d.ts +17 -0
- package/dist/desktop/ocr.js +99 -0
- package/dist/diff/lcs.d.ts +5 -0
- package/dist/diff/lcs.js +42 -0
- package/dist/diff/numeric.d.ts +6 -0
- package/dist/diff/numeric.js +24 -0
- package/dist/diff/pixel.d.ts +23 -0
- package/dist/diff/pixel.js +97 -0
- package/dist/diff/structural.d.ts +11 -0
- package/dist/diff/structural.js +38 -0
- package/dist/diff/text.d.ts +7 -0
- package/dist/diff/text.js +64 -0
- package/dist/diff/tree.d.ts +46 -0
- package/dist/diff/tree.js +188 -0
- package/dist/doctor.d.ts +18 -0
- package/dist/doctor.js +57 -0
- package/dist/game/capture.d.ts +24 -0
- package/dist/game/capture.js +51 -0
- package/dist/game/protocol.d.ts +30 -0
- package/dist/game/protocol.js +146 -0
- package/dist/game/walkthrough.d.ts +45 -0
- package/dist/game/walkthrough.js +85 -0
- package/dist/guards.d.ts +2 -0
- package/dist/guards.js +15 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +504 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +40 -0
- package/dist/lifecycle.d.ts +14 -0
- package/dist/lifecycle.js +190 -0
- package/dist/normalization.d.ts +4 -0
- package/dist/normalization.js +27 -0
- package/dist/perf/ab.d.ts +6 -0
- package/dist/perf/ab.js +89 -0
- package/dist/perf/autocannon.d.ts +6 -0
- package/dist/perf/autocannon.js +101 -0
- package/dist/perf/capture.d.ts +7 -0
- package/dist/perf/capture.js +6 -0
- package/dist/perf/k6.d.ts +9 -0
- package/dist/perf/k6.js +44 -0
- package/dist/perf/parsers.d.ts +15 -0
- package/dist/perf/parsers.js +69 -0
- package/dist/perf/run.d.ts +8 -0
- package/dist/perf/run.js +45 -0
- package/dist/perf/toolOutput.d.ts +3 -0
- package/dist/perf/toolOutput.js +24 -0
- package/dist/reporters.d.ts +11 -0
- package/dist/reporters.js +314 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +352 -0
- package/dist/snapshot.d.ts +48 -0
- package/dist/snapshot.js +37 -0
- package/dist/terminal/ansi.d.ts +21 -0
- package/dist/terminal/ansi.js +144 -0
- package/dist/terminal/capture.d.ts +30 -0
- package/dist/terminal/capture.js +91 -0
- package/dist/tty.d.ts +72 -0
- package/dist/tty.js +175 -0
- package/dist/web/domSnapshot.d.ts +27 -0
- package/dist/web/domSnapshot.js +55 -0
- package/dist/web/playwrightCapture.d.ts +16 -0
- package/dist/web/playwrightCapture.js +64 -0
- package/package.json +79 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Functional Source License, Version 1.1, ALv2 Future License
|
|
2
|
+
|
|
3
|
+
## Abbreviation
|
|
4
|
+
|
|
5
|
+
FSL-1.1-ALv2
|
|
6
|
+
|
|
7
|
+
## Notice
|
|
8
|
+
|
|
9
|
+
Copyright 2026 DungbeetleDev
|
|
10
|
+
|
|
11
|
+
## Terms and Conditions
|
|
12
|
+
|
|
13
|
+
### Licensor ("We")
|
|
14
|
+
|
|
15
|
+
The party offering the Software under these Terms and Conditions.
|
|
16
|
+
|
|
17
|
+
### The Software
|
|
18
|
+
|
|
19
|
+
The "Software" is each version of the software that we make available under
|
|
20
|
+
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
|
21
|
+
Conditions with the Software.
|
|
22
|
+
|
|
23
|
+
### License Grant
|
|
24
|
+
|
|
25
|
+
Subject to your compliance with this License Grant and the Patents,
|
|
26
|
+
Redistribution and Trademark clauses below, we hereby grant you the right to
|
|
27
|
+
use, copy, modify, create derivative works, publicly perform, publicly display
|
|
28
|
+
and redistribute the Software for any Permitted Purpose identified below.
|
|
29
|
+
|
|
30
|
+
### Permitted Purpose
|
|
31
|
+
|
|
32
|
+
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
|
33
|
+
means making the Software available to others in a commercial product or
|
|
34
|
+
service that:
|
|
35
|
+
|
|
36
|
+
1. substitutes for the Software;
|
|
37
|
+
|
|
38
|
+
2. substitutes for any other product or service we offer using the Software
|
|
39
|
+
that exists as of the date we make the Software available; or
|
|
40
|
+
|
|
41
|
+
3. offers the same or substantially similar functionality as the Software.
|
|
42
|
+
|
|
43
|
+
Permitted Purposes specifically include using the Software:
|
|
44
|
+
|
|
45
|
+
1. for your internal use and access;
|
|
46
|
+
|
|
47
|
+
2. for non-commercial education;
|
|
48
|
+
|
|
49
|
+
3. for non-commercial research; and
|
|
50
|
+
|
|
51
|
+
4. in connection with professional services that you provide to a licensee
|
|
52
|
+
using the Software in accordance with these Terms and Conditions.
|
|
53
|
+
|
|
54
|
+
### Patents
|
|
55
|
+
|
|
56
|
+
To the extent your use for a Permitted Purpose would necessarily infringe our
|
|
57
|
+
patents, the license grant above includes a license under our patents. If you
|
|
58
|
+
make a claim against any party that the Software infringes or contributes to
|
|
59
|
+
the infringement of any patent, then your patent license to the Software ends
|
|
60
|
+
immediately.
|
|
61
|
+
|
|
62
|
+
### Redistribution
|
|
63
|
+
|
|
64
|
+
The Terms and Conditions apply to all copies, modifications and derivatives of
|
|
65
|
+
the Software.
|
|
66
|
+
|
|
67
|
+
If you redistribute any copies, modifications or derivatives of the Software,
|
|
68
|
+
you must include a copy of or a link to these Terms and Conditions and not
|
|
69
|
+
remove any copyright notices provided in or with the Software.
|
|
70
|
+
|
|
71
|
+
### Disclaimer
|
|
72
|
+
|
|
73
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
|
74
|
+
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
|
75
|
+
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
|
76
|
+
|
|
77
|
+
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
|
78
|
+
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
|
79
|
+
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
|
80
|
+
|
|
81
|
+
### Trademarks
|
|
82
|
+
|
|
83
|
+
Except for displaying the License Details and identifying us as the origin of
|
|
84
|
+
the Software, you have no right under these Terms and Conditions to use our
|
|
85
|
+
trademarks, trade names, service marks or product names.
|
|
86
|
+
|
|
87
|
+
## Grant of Future License
|
|
88
|
+
|
|
89
|
+
We hereby irrevocably grant you an additional license to use the Software under
|
|
90
|
+
the Apache License, Version 2.0 that is effective on the second anniversary of
|
|
91
|
+
the date we make the Software available. On or after that date, you may use the
|
|
92
|
+
Software under the Apache License, Version 2.0, in which case the following
|
|
93
|
+
will apply:
|
|
94
|
+
|
|
95
|
+
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
|
96
|
+
this file except in compliance with the License.
|
|
97
|
+
|
|
98
|
+
You may obtain a copy of the License at
|
|
99
|
+
|
|
100
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
101
|
+
|
|
102
|
+
Unless required by applicable law or agreed to in writing, software distributed
|
|
103
|
+
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
104
|
+
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
|
105
|
+
specific language governing permissions and limitations under the License.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Dungbeetle
|
|
2
|
+
Copyright 2026 DungbeetleDev
|
|
3
|
+
|
|
4
|
+
This product includes software developed as part of the Dungbeetle project
|
|
5
|
+
(https://github.com/DungbeetleTech/client).
|
|
6
|
+
|
|
7
|
+
The command-line tool and core engine in this repository (the `dungbeetle`
|
|
8
|
+
package at the repository root) are licensed under the Functional Source License,
|
|
9
|
+
Version 1.1 (FSL-1.1-ALv2): source-available and free for any use except offering
|
|
10
|
+
a competing product or service, with an automatic conversion to the Apache License,
|
|
11
|
+
Version 2.0 two years after each release. See the LICENSE file for the full text.
|
|
12
|
+
|
|
13
|
+
The cloud server in the `server/` directory is licensed separately under the
|
|
14
|
+
Business Source License 1.1 (BUSL-1.1). See `server/LICENSE` and LICENSING.md for
|
|
15
|
+
details and the change-license terms.
|
|
16
|
+
|
|
17
|
+
"Dungbeetle" and the Dungbeetle logo are trademarks of DungbeetleDev. The licenses
|
|
18
|
+
above grant rights to the software but not to the marks; see TRADEMARK.md for the
|
|
19
|
+
brand-use policy.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="brand/logo.svg" alt="Dungbeetle" width="220">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Dungbeetle
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/dungbeetle"><img src="https://img.shields.io/npm/v/dungbeetle" alt="npm version"></a>
|
|
9
|
+
<a href="https://github.com/DungbeetleTech/client/actions/workflows/ci.yml"><img src="https://github.com/DungbeetleTech/client/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
10
|
+
<img src="https://img.shields.io/node/v/dungbeetle" alt="Node.js version">
|
|
11
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-FSL--1.1--ALv2-blue" alt="License: FSL-1.1-ALv2"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
> Web, desktop, terminal, and eventually anything — zero adoption cost and runs anywhere.
|
|
15
|
+
|
|
16
|
+
Dungbeetle captures your app's output as stable, reviewable JSON and produces
|
|
17
|
+
**semantic diffs** instead of brittle pixel comparisons. Output is normalized (timestamps,
|
|
18
|
+
UUIDs, and temp paths are masked) so baselines stay readable and survive cosmetic
|
|
19
|
+
churn.
|
|
20
|
+
|
|
21
|
+
- 🖥️ **Terminal** — capture with ANSI normalization
|
|
22
|
+
- 🌐 **Web** — structured DOM snapshots (`url`/`html` fetch by default, or a
|
|
23
|
+
`playwright` driver when a browser is available)
|
|
24
|
+
- ⚡ **Performance** — baselines from k6 metrics, compared with numeric tolerance
|
|
25
|
+
- 🪟 **Desktop** — accessibility-tree snapshots (role/name/state), with an
|
|
26
|
+
experimental native macOS driver (`driver: "macos-ax"`)
|
|
27
|
+
- 🔌 **API** — REST/GraphQL response snapshots (status, allow-listed headers,
|
|
28
|
+
parsed JSON body) with structural diffs
|
|
29
|
+
- 🎮 **Game** — scripted walkthroughs snapshotting semantic game state at named
|
|
30
|
+
markers, deterministic and headless (Godot 4.x first; see `adapters/godot`)
|
|
31
|
+
- 🎭 **Shared masking** for dynamic values, and stable JSON diffs you can review
|
|
32
|
+
in a pull request
|
|
33
|
+
|
|
34
|
+
Baselines are committed under `dungbeetle.snapshots/` so changes show up in code
|
|
35
|
+
review. An optional **Dungbeetle cloud** service (a separate product) can store runs
|
|
36
|
+
and baselines centrally when you'd rather not commit them.
|
|
37
|
+
|
|
38
|
+
## 📚 Documentation
|
|
39
|
+
|
|
40
|
+
Full documentation — guides, capture types, configuration, and the CLI
|
|
41
|
+
reference — lives at the Dungbeetle docs site:
|
|
42
|
+
|
|
43
|
+
**→ <https://dungbeetle.dev>**
|
|
44
|
+
|
|
45
|
+
<!-- The docs domain is a placeholder until the site goes live. When the
|
|
46
|
+
production URL is set, update this one link (and the matching pointers in
|
|
47
|
+
SUPPORT.md and CONTRIBUTING.md). -->
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- **Node.js 22.5.0 or newer.**
|
|
52
|
+
- No native build step and no external services for the CLI.
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm install -g dungbeetle # global `dungbeetle` binary
|
|
58
|
+
# or, per-project:
|
|
59
|
+
npm install --save-dev dungbeetle && npx dungbeetle --help
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick start
|
|
63
|
+
|
|
64
|
+
Scaffold a config, then add at least one **capture target** — `init` writes an
|
|
65
|
+
empty `lifecycle.capture` array (unless it detects a Laravel app), so you tell
|
|
66
|
+
Dungbeetle what to snapshot:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
dungbeetle init # scaffold dungbeetle.config.json
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Add a target to `lifecycle.capture` in `dungbeetle.config.json` — here a minimal
|
|
73
|
+
terminal snapshot (see the [docs](https://dungbeetle.dev) for web, desktop, game,
|
|
74
|
+
API, and check targets):
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"lifecycle": {
|
|
79
|
+
"capture": [{ "kind": "terminal", "name": "hello", "command": "echo hello world" }]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then capture and compare:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
dungbeetle update # run your capture targets and write the first baselines
|
|
88
|
+
dungbeetle test # compare current output against the baselines
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`update` writes baselines under `dungbeetle.snapshots/` (commit these). `test`
|
|
92
|
+
compares current output to them and exits non-zero on any difference, so it drops
|
|
93
|
+
straight into CI. Run `dungbeetle doctor` any time to check your config and
|
|
94
|
+
targets. For machine-readable CI output:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
dungbeetle ci --json report.json --html report.html
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Run `dungbeetle doctor` to validate your config, paths, targets, and optional browser
|
|
101
|
+
setup. See the [documentation](https://dungbeetle.dev) for the next steps.
|
|
102
|
+
|
|
103
|
+
## Dungbeetle cloud
|
|
104
|
+
|
|
105
|
+
An optional **Dungbeetle cloud** service stores runs and baselines centrally with a
|
|
106
|
+
review/approve UI — useful when you'd rather not commit baselines to your repo. It
|
|
107
|
+
is a **separate product** (not part of this repository); see
|
|
108
|
+
<https://dungbeetle.dev> for details. Production self-hosting is available to
|
|
109
|
+
enterprise customers on request.
|
|
110
|
+
|
|
111
|
+
## Contributing
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
npm install
|
|
115
|
+
npm run check # lint + typecheck + test (run before a PR)
|
|
116
|
+
git commit -s # sign off your commits (DCO — required)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) (it
|
|
120
|
+
covers the **DCO sign-off** every commit needs) and our
|
|
121
|
+
[Code of Conduct](CODE_OF_CONDUCT.md). Questions go to
|
|
122
|
+
[Discussions](https://github.com/DungbeetleTech/client/discussions); bugs and
|
|
123
|
+
features to [Issues](https://github.com/DungbeetleTech/client/issues/new/choose);
|
|
124
|
+
security reports to [SECURITY.md](SECURITY.md). See also [SUPPORT.md](SUPPORT.md)
|
|
125
|
+
and [GOVERNANCE.md](GOVERNANCE.md).
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
Dungbeetle's **CLI & core engine** (`dungbeetle`, this repository) is
|
|
130
|
+
**source-available** under the **Functional Source License 1.1**
|
|
131
|
+
([`LICENSE`](LICENSE)) — free for any use, including internal and commercial,
|
|
132
|
+
except offering it to others as a competing product or service; each release
|
|
133
|
+
converts to **Apache-2.0** two years later.
|
|
134
|
+
|
|
135
|
+
The **Dungbeetle cloud** server is a separate product under the **Business Source
|
|
136
|
+
License 1.1** and lives in its own repository.
|
|
137
|
+
|
|
138
|
+
See [LICENSING.md](LICENSING.md) for the rationale and [TRADEMARK.md](TRADEMARK.md)
|
|
139
|
+
for the brand-use policy. "Dungbeetle" and the Dungbeetle logo are trademarks of DungbeetleDev.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MaskRule } from "../config.js";
|
|
2
|
+
export type ApiSnapshot = {
|
|
3
|
+
kind: "api";
|
|
4
|
+
status: number;
|
|
5
|
+
headers: Record<string, string>;
|
|
6
|
+
bodyType: "json" | "text";
|
|
7
|
+
body: unknown;
|
|
8
|
+
};
|
|
9
|
+
export type ApiCaptureOptions = {
|
|
10
|
+
url: string;
|
|
11
|
+
method: string;
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
body?: string;
|
|
14
|
+
includeHeaders: string[];
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
maskRules: MaskRule[];
|
|
17
|
+
};
|
|
18
|
+
export declare function maskJsonValue(value: unknown, rules: MaskRule[]): unknown;
|
|
19
|
+
export declare function normalizeApiResponse(input: {
|
|
20
|
+
status: number;
|
|
21
|
+
headers: Headers;
|
|
22
|
+
text: string;
|
|
23
|
+
}, options: Pick<ApiCaptureOptions, "includeHeaders" | "maskRules">): ApiSnapshot;
|
|
24
|
+
export declare function captureApi(options: ApiCaptureOptions): Promise<ApiSnapshot>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isRecord } from "../guards.js";
|
|
2
|
+
import { applyMaskRules } from "../normalization.js";
|
|
3
|
+
// Apply mask rules to every string leaf of a parsed JSON value.
|
|
4
|
+
export function maskJsonValue(value, rules) {
|
|
5
|
+
if (typeof value === "string") {
|
|
6
|
+
return applyMaskRules(value, rules);
|
|
7
|
+
}
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
return value.map((entry) => maskJsonValue(entry, rules));
|
|
10
|
+
}
|
|
11
|
+
if (isRecord(value)) {
|
|
12
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, maskJsonValue(nested, rules)]));
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
// Turn a fetched response into the snapshot shape. Split from `captureApi` so
|
|
17
|
+
// tests can exercise the normalization without a live server.
|
|
18
|
+
export function normalizeApiResponse(input, options) {
|
|
19
|
+
const headers = {};
|
|
20
|
+
for (const name of options.includeHeaders) {
|
|
21
|
+
const value = input.headers.get(name);
|
|
22
|
+
if (value !== null) {
|
|
23
|
+
headers[name.toLowerCase()] = applyMaskRules(value, options.maskRules);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const contentType = input.headers.get("content-type") ?? "";
|
|
27
|
+
if (contentType.includes("json")) {
|
|
28
|
+
try {
|
|
29
|
+
return {
|
|
30
|
+
kind: "api",
|
|
31
|
+
status: input.status,
|
|
32
|
+
headers,
|
|
33
|
+
bodyType: "json",
|
|
34
|
+
body: maskJsonValue(JSON.parse(input.text), options.maskRules)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Declared JSON but unparseable — fall through and snapshot the raw text,
|
|
39
|
+
// which is itself the signal a reviewer needs to see.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
kind: "api",
|
|
44
|
+
status: input.status,
|
|
45
|
+
headers,
|
|
46
|
+
bodyType: "text",
|
|
47
|
+
body: applyMaskRules(input.text, options.maskRules)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Note: a non-2xx status is NOT an error here — the status is part of the
|
|
51
|
+
// snapshot, so an endpoint whose contract is `404` can be baselined and a
|
|
52
|
+
// drift to `200` shows up as a diff.
|
|
53
|
+
export async function captureApi(options) {
|
|
54
|
+
const response = await fetch(options.url, {
|
|
55
|
+
method: options.method,
|
|
56
|
+
headers: options.headers,
|
|
57
|
+
body: options.body,
|
|
58
|
+
signal: AbortSignal.timeout(options.timeoutMs)
|
|
59
|
+
});
|
|
60
|
+
return normalizeApiResponse({ status: response.status, headers: response.headers, text: await response.text() }, options);
|
|
61
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare class MissingBaselineError extends Error {
|
|
2
|
+
readonly baselinePath: string;
|
|
3
|
+
constructor(baselinePath: string);
|
|
4
|
+
}
|
|
5
|
+
export declare function baselinePathForTarget(baselinesDir: string, targetName: string, cwd?: string): string;
|
|
6
|
+
export declare function readBaseline(filePath: string): Promise<unknown>;
|
|
7
|
+
export declare function writeBaseline(filePath: string, value: unknown): Promise<void>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseJsonFile, stableStringify } from "./json.js";
|
|
4
|
+
import { isErrno } from "./guards.js";
|
|
5
|
+
export class MissingBaselineError extends Error {
|
|
6
|
+
baselinePath;
|
|
7
|
+
constructor(baselinePath) {
|
|
8
|
+
super(`Missing baseline at ${baselinePath}`);
|
|
9
|
+
this.baselinePath = baselinePath;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function baselinePathForTarget(baselinesDir, targetName, cwd = process.cwd()) {
|
|
13
|
+
return path.join(path.resolve(cwd, baselinesDir), `${slugifyTargetName(targetName)}.json`);
|
|
14
|
+
}
|
|
15
|
+
export async function readBaseline(filePath) {
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = await readFile(filePath, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (isErrno(error, "ENOENT")) {
|
|
22
|
+
throw new MissingBaselineError(filePath);
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
return parseJsonFile(raw, filePath);
|
|
27
|
+
}
|
|
28
|
+
export async function writeBaseline(filePath, value) {
|
|
29
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
30
|
+
await writeFile(filePath, `${stableStringify(value)}\n`, "utf8");
|
|
31
|
+
}
|
|
32
|
+
function slugifyTargetName(value) {
|
|
33
|
+
const slug = value
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
36
|
+
.replace(/^-+|-+$/g, "");
|
|
37
|
+
return slug || "snapshot";
|
|
38
|
+
}
|
package/dist/brand.d.ts
ADDED
package/dist/brand.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Display name for the CLI, overridable via env so the tool can be white-labelled
|
|
3
|
+
// when self-hosted without editing source. Structural identifiers (the `dungbeetle`
|
|
4
|
+
// binary name, `dungbeetle.config.json`) are intentionally NOT driven by this —
|
|
5
|
+
// they stay fixed regardless of the display brand.
|
|
6
|
+
export const BRAND_NAME = process.env.BRAND_NAME?.trim() || "Dungbeetle";
|
|
7
|
+
// Shared brand copy — one source, mirrored by the README/docs (which build
|
|
8
|
+
// separately and can't import this).
|
|
9
|
+
export const BRAND_SUBTITLE = "Web, desktop, terminal, and eventually anything — zero adoption cost and runs anywhere.";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ApiSnapshot } from "./api/capture.js";
|
|
2
|
+
import type { CheckSnapshot } from "./check/capture.js";
|
|
3
|
+
import type { CaptureTarget, DungbeetleConfig } from "./config.js";
|
|
4
|
+
import type { DesktopSnapshot } from "./desktop/a11y.js";
|
|
5
|
+
import type { GameSnapshot } from "./game/capture.js";
|
|
6
|
+
import type { PerformanceSnapshot } from "./perf/k6.js";
|
|
7
|
+
import type { TerminalCapture } from "./terminal/capture.js";
|
|
8
|
+
import type { DomSnapshot, WebFileSnapshot } from "./web/domSnapshot.js";
|
|
9
|
+
import type { PlaywrightWebSnapshot } from "./web/playwrightCapture.js";
|
|
10
|
+
export type SnapshotArtifact = ApiSnapshot | CheckSnapshot | TerminalCapture | DomSnapshot | WebFileSnapshot | PlaywrightWebSnapshot | PerformanceSnapshot | DesktopSnapshot | GameSnapshot;
|
|
11
|
+
export type CaptureContext = {
|
|
12
|
+
config: DungbeetleConfig;
|
|
13
|
+
cwd: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function captureTarget(target: CaptureTarget, options: CaptureContext): Promise<SnapshotArtifact>;
|
package/dist/capture.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { captureTypes } from "./captures/registry.js";
|
|
2
|
+
// Thin dispatcher: each capture kind owns its full lifecycle in its own
|
|
3
|
+
// `src/captures/<kind>.ts` module; this routes to the registered handler. The
|
|
4
|
+
// web sub-dispatch (playwright/html/url) lives inside the web module's capture.
|
|
5
|
+
export function captureTarget(target, options) {
|
|
6
|
+
return captureTypes[target.kind].capture(target, options);
|
|
7
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { captureApi } from "../api/capture.js";
|
|
2
|
+
import { coerceSnapshot, render } from "../compare/shared.js";
|
|
3
|
+
import { structuralChanges } from "../diff/structural.js";
|
|
4
|
+
import { canonicalizeSnapshot } from "../snapshot.js";
|
|
5
|
+
// Response headers kept in the snapshot unless the target says otherwise.
|
|
6
|
+
// Everything else (dates, request ids, rate-limit counters) is volatile.
|
|
7
|
+
const DEFAULT_INCLUDE_HEADERS = ["content-type"];
|
|
8
|
+
// GraphQL sugar: a `query` (+ optional `variables`) becomes the standard
|
|
9
|
+
// `{query, variables}` JSON POST. A raw `body` is sent verbatim with the
|
|
10
|
+
// user's own headers.
|
|
11
|
+
function requestBody(target) {
|
|
12
|
+
if (target.query) {
|
|
13
|
+
return JSON.stringify({ query: target.query, variables: target.variables ?? {} });
|
|
14
|
+
}
|
|
15
|
+
return target.body;
|
|
16
|
+
}
|
|
17
|
+
function requestHeaders(target) {
|
|
18
|
+
const headers = { ...(target.headers ?? {}) };
|
|
19
|
+
const hasContentType = Object.keys(headers).some((name) => name.toLowerCase() === "content-type");
|
|
20
|
+
if (target.query && !hasContentType) {
|
|
21
|
+
headers["content-type"] = "application/json";
|
|
22
|
+
}
|
|
23
|
+
return headers;
|
|
24
|
+
}
|
|
25
|
+
function requestMethod(target) {
|
|
26
|
+
return target.method ?? (target.query || target.body ? "POST" : "GET");
|
|
27
|
+
}
|
|
28
|
+
function compareApi(baseline, candidate, options) {
|
|
29
|
+
const sections = [];
|
|
30
|
+
if (baseline.status !== candidate.status) {
|
|
31
|
+
sections.push(`~ status: ${String(baseline.status)} → ${String(candidate.status)}`);
|
|
32
|
+
}
|
|
33
|
+
const changes = structuralChanges({ headers: baseline.headers, body: baseline.body }, { headers: candidate.headers, body: candidate.body }, { numericTolerance: options.comparison.numericTolerance });
|
|
34
|
+
for (const change of changes) {
|
|
35
|
+
sections.push(`~ ${change.path}: ${render(change.before)} → ${render(change.after)}`);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
equal: sections.length === 0,
|
|
39
|
+
rendered: sections.join("\n")
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function parsesAsUrl(value) {
|
|
43
|
+
try {
|
|
44
|
+
new URL(value);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function validateApiTarget(target) {
|
|
52
|
+
if (!target.url || !parsesAsUrl(target.url)) {
|
|
53
|
+
return {
|
|
54
|
+
name: "api-target",
|
|
55
|
+
severity: "fail",
|
|
56
|
+
target: target.name,
|
|
57
|
+
message: `API target "${target.name}" must have a valid "url".`
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (target.body && target.query) {
|
|
61
|
+
return {
|
|
62
|
+
name: "api-target",
|
|
63
|
+
severity: "fail",
|
|
64
|
+
target: target.name,
|
|
65
|
+
message: `API target "${target.name}" sets both "body" and "query" — use one.`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
name: "api-target",
|
|
70
|
+
severity: "pass",
|
|
71
|
+
target: target.name,
|
|
72
|
+
message: `API target "${target.name}" points at ${target.url}.`
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export const api = {
|
|
76
|
+
kind: "api",
|
|
77
|
+
parallelSafe: true,
|
|
78
|
+
capture: (target, { config }) => {
|
|
79
|
+
const apiTarget = target;
|
|
80
|
+
return captureApi({
|
|
81
|
+
url: apiTarget.url,
|
|
82
|
+
method: requestMethod(apiTarget),
|
|
83
|
+
headers: requestHeaders(apiTarget),
|
|
84
|
+
body: requestBody(apiTarget),
|
|
85
|
+
includeHeaders: apiTarget.includeHeaders ?? DEFAULT_INCLUDE_HEADERS,
|
|
86
|
+
timeoutMs: apiTarget.timeoutMs ?? config.lifecycle.wait.timeoutMs,
|
|
87
|
+
maskRules: config.normalization.masks
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
canonicalize: (value) => ({
|
|
91
|
+
kind: value.kind,
|
|
92
|
+
status: value.status,
|
|
93
|
+
headers: value.headers,
|
|
94
|
+
bodyType: value.bodyType,
|
|
95
|
+
body: canonicalizeSnapshot(value.body)
|
|
96
|
+
}),
|
|
97
|
+
compare: (baseline, candidate, options) => compareApi(coerceSnapshot(baseline), coerceSnapshot(candidate), options),
|
|
98
|
+
validateConfig: (target, { label, issues }) => {
|
|
99
|
+
const apiTarget = target;
|
|
100
|
+
if (!apiTarget.url || typeof apiTarget.url !== "string") {
|
|
101
|
+
issues.push(`${label} (api "${apiTarget.name}") must have a "url".`);
|
|
102
|
+
}
|
|
103
|
+
else if (!parsesAsUrl(apiTarget.url)) {
|
|
104
|
+
issues.push(`${label} (api "${apiTarget.name}") has an invalid "url".`);
|
|
105
|
+
}
|
|
106
|
+
if (apiTarget.body && apiTarget.query) {
|
|
107
|
+
issues.push(`${label} (api "${apiTarget.name}") sets both "body" and "query" — use one.`);
|
|
108
|
+
}
|
|
109
|
+
if (apiTarget.variables && !apiTarget.query) {
|
|
110
|
+
issues.push(`${label} (api "${apiTarget.name}") sets "variables" without a "query".`);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
doctorChecks: (target) => [validateApiTarget(target)]
|
|
114
|
+
};
|