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
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Tool-parser registry for the check kind.
|
|
2
|
+
//
|
|
3
|
+
// A check target runs a tool that reports on the shape of an application
|
|
4
|
+
// (routes, scheduled jobs, environment) and snapshots the normalized result.
|
|
5
|
+
// Each parser turns one tool's JSON output into a stable **keyed record** —
|
|
6
|
+
// entries keyed by identity (e.g. "GET|HEAD /users") rather than array index —
|
|
7
|
+
// so the structural diff names exactly the added/removed/changed entry instead
|
|
8
|
+
// of reporting every index shift. Adding a tool is one parser object plus one
|
|
9
|
+
// registry line here.
|
|
10
|
+
import { isRecord } from "../guards.js";
|
|
11
|
+
import { parseJUnit } from "./junit.js";
|
|
12
|
+
import { parseSchemaDump } from "./schema.js";
|
|
13
|
+
// Tool output is attacker-controlled in CI (a hostile PR names a test class or
|
|
14
|
+
// source file). Reject the keys that would let `obj[key] ??= {}` reach back
|
|
15
|
+
// into Object.prototype and pollute it globally.
|
|
16
|
+
function isUnsafeKey(key) {
|
|
17
|
+
return key === "__proto__" || key === "constructor" || key === "prototype";
|
|
18
|
+
}
|
|
19
|
+
// Accumulator whose keys come from attacker-controlled tool output. A plain `{}`
|
|
20
|
+
// inherits Object.prototype members (toString, valueOf, hasOwnProperty, …), so a
|
|
21
|
+
// suite/file/message named `toString` makes `obj[key]` resolve to the inherited
|
|
22
|
+
// function — non-nullish — and `obj[key] ??= {}` then skips the assignment and
|
|
23
|
+
// writes onto the shared prototype instead of the snapshot, silently dropping the
|
|
24
|
+
// entry. A null-prototype object has no inherited keys, so every key behaves as an
|
|
25
|
+
// own key and `isUnsafeKey` above is pure defense-in-depth.
|
|
26
|
+
function emptyRecord() {
|
|
27
|
+
return Object.create(null);
|
|
28
|
+
}
|
|
29
|
+
function invalidOutput(tool, expected) {
|
|
30
|
+
return new Error(`Invalid ${tool} output: expected ${expected}.`);
|
|
31
|
+
}
|
|
32
|
+
// `route:list --json`: an array of {domain, method, uri, name, action,
|
|
33
|
+
// middleware, path}. Keyed by "method uri". The `path` line number is dropped
|
|
34
|
+
// (it churns on unrelated edits); the file is kept to localize a change.
|
|
35
|
+
const laravelRoutes = {
|
|
36
|
+
tool: "laravel-routes",
|
|
37
|
+
defaultCommand: "php artisan route:list --json",
|
|
38
|
+
normalize(raw) {
|
|
39
|
+
if (!Array.isArray(raw)) {
|
|
40
|
+
throw invalidOutput(this.tool, "a JSON array of routes");
|
|
41
|
+
}
|
|
42
|
+
const routes = {};
|
|
43
|
+
for (const entry of raw) {
|
|
44
|
+
if (!isRecord(entry)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const key = `${typeof entry.method === "string" ? entry.method : "?"} ${typeof entry.uri === "string" ? entry.uri : "?"}`;
|
|
48
|
+
routes[key] = {
|
|
49
|
+
name: entry.name ?? null,
|
|
50
|
+
action: entry.action ?? null,
|
|
51
|
+
middleware: entry.middleware ?? [],
|
|
52
|
+
...(entry.domain ? { domain: entry.domain } : {}),
|
|
53
|
+
...(typeof entry.path === "string" ? { path: entry.path.replace(/:\d+$/, "") } : {})
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return routes;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
// `about --json`: a record of sections. Values embed absolute project paths
|
|
60
|
+
// (sometimes case-folded, e.g. the storage-link status keys), so occurrences of
|
|
61
|
+
// the project directory are replaced with "<project>" to keep snapshots
|
|
62
|
+
// machine-portable. Version fields are kept on purpose — that drift is signal.
|
|
63
|
+
// `cache.views` is dropped: view compilation happens as a side effect of merely
|
|
64
|
+
// rendering (running the test suite compiles Blade views), so unlike the
|
|
65
|
+
// deliberate config/route/event caches it flips run-to-run and would make an
|
|
66
|
+
// `about` target flaky whenever a `tests` target runs beside it.
|
|
67
|
+
const laravelAbout = {
|
|
68
|
+
tool: "laravel-about",
|
|
69
|
+
defaultCommand: "php artisan about --json",
|
|
70
|
+
normalize(raw, { cwd }) {
|
|
71
|
+
if (!isRecord(raw)) {
|
|
72
|
+
throw invalidOutput(this.tool, "a JSON object of sections");
|
|
73
|
+
}
|
|
74
|
+
const data = stripProjectDir(raw, cwd);
|
|
75
|
+
const cache = data.cache;
|
|
76
|
+
if (isRecord(cache)) {
|
|
77
|
+
const { views: _views, ...stable } = cache;
|
|
78
|
+
data.cache = stable;
|
|
79
|
+
}
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
// `schedule:list --json` (Laravel ≥ 12): an array of scheduled entries. Keyed
|
|
84
|
+
// by "expression command" — a cron change reads as remove+add, which is the
|
|
85
|
+
// loudness a schedule change deserves. `next_due_date(_human)` vary with the
|
|
86
|
+
// wall clock and are dropped.
|
|
87
|
+
const laravelSchedule = {
|
|
88
|
+
tool: "laravel-schedule",
|
|
89
|
+
defaultCommand: "php artisan schedule:list --json",
|
|
90
|
+
normalize(raw) {
|
|
91
|
+
if (!Array.isArray(raw)) {
|
|
92
|
+
throw invalidOutput(this.tool, "a JSON array of schedule entries");
|
|
93
|
+
}
|
|
94
|
+
const entries = {};
|
|
95
|
+
for (const entry of raw) {
|
|
96
|
+
if (!isRecord(entry)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const key = `${typeof entry.expression === "string" ? entry.expression : "?"} ${typeof entry.command === "string" ? entry.command : "?"}`;
|
|
100
|
+
const { next_due_date: _due, next_due_date_human: _human, ...stable } = entry;
|
|
101
|
+
entries[key] = stable;
|
|
102
|
+
}
|
|
103
|
+
return entries;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
// PHPStan / Larastan `--error-format=json` (Larastan is PHPStan with Laravel
|
|
107
|
+
// extensions — same output). PHPStan emits two shapes: the classic
|
|
108
|
+
// `{totals, files: {path: {messages: […]}}}` and, when it detects an AI-agent
|
|
109
|
+
// environment, `{error_details: {path: […]}}`. Both normalize to the same
|
|
110
|
+
// keyed record so the snapshot doesn't depend on who ran the capture. Entries
|
|
111
|
+
// are keyed by message per file with a count — PHPStan's own baseline identity —
|
|
112
|
+
// and line numbers are dropped (they churn on unrelated edits).
|
|
113
|
+
const phpstan = {
|
|
114
|
+
tool: "phpstan",
|
|
115
|
+
defaultCommand: "vendor/bin/phpstan analyse --error-format=json --no-progress",
|
|
116
|
+
toleratesNonZeroExit: true,
|
|
117
|
+
normalize(raw, { cwd }) {
|
|
118
|
+
if (!isRecord(raw) || !(isRecord(raw.totals) || raw.tool === "phpstan")) {
|
|
119
|
+
throw invalidOutput(this.tool, "PHPStan JSON error output");
|
|
120
|
+
}
|
|
121
|
+
const data = emptyRecord();
|
|
122
|
+
const add = (file, message, identifier) => {
|
|
123
|
+
if (typeof message !== "string") {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const relative = file.startsWith(`${cwd}/`) ? file.slice(cwd.length + 1) : file;
|
|
127
|
+
if (isUnsafeKey(relative) || isUnsafeKey(message)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
data[relative] ??= emptyRecord();
|
|
131
|
+
const entries = data[relative];
|
|
132
|
+
const entry = entries[message] ?? {
|
|
133
|
+
identifier: typeof identifier === "string" ? identifier : null,
|
|
134
|
+
count: 0
|
|
135
|
+
};
|
|
136
|
+
entry.count += 1;
|
|
137
|
+
entries[message] = entry;
|
|
138
|
+
};
|
|
139
|
+
// Classic shape ({} or [] when empty — PHP arrays serialize both ways).
|
|
140
|
+
if (isRecord(raw.files)) {
|
|
141
|
+
for (const [file, info] of Object.entries(raw.files)) {
|
|
142
|
+
if (isRecord(info) && Array.isArray(info.messages)) {
|
|
143
|
+
for (const message of info.messages) {
|
|
144
|
+
if (isRecord(message)) {
|
|
145
|
+
add(file, message.message, message.identifier);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Agent shape.
|
|
152
|
+
if (isRecord(raw.error_details)) {
|
|
153
|
+
for (const [file, messages] of Object.entries(raw.error_details)) {
|
|
154
|
+
if (Array.isArray(messages)) {
|
|
155
|
+
for (const message of messages) {
|
|
156
|
+
if (isRecord(message)) {
|
|
157
|
+
add(file, message.message, message.identifier);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Non-file errors (config problems, unparsable files).
|
|
164
|
+
if (Array.isArray(raw.errors)) {
|
|
165
|
+
for (const message of raw.errors) {
|
|
166
|
+
add("(general)", message, null);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return data;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
// Pest / PHPUnit via the JUnit XML report (`--log-junit`) — one normalizer
|
|
173
|
+
// covers both runners (Pest runs on the PHPUnit core and emits the same
|
|
174
|
+
// report; only the default binary differs). Keyed suite → case → {status,
|
|
175
|
+
// type?, message?}; `time`/`assertions` are dropped (run-varying), messages
|
|
176
|
+
// keep the assertion text with project paths relativized.
|
|
177
|
+
const JUNIT_ARTIFACT = ".dungbeetle-junit.xml";
|
|
178
|
+
function normalizeJUnit(raw, { cwd }) {
|
|
179
|
+
const suites = emptyRecord();
|
|
180
|
+
for (const testCase of parseJUnit(String(raw))) {
|
|
181
|
+
if (isUnsafeKey(testCase.suite) || isUnsafeKey(testCase.name)) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
suites[testCase.suite] ??= emptyRecord();
|
|
185
|
+
const suite = suites[testCase.suite];
|
|
186
|
+
suite[testCase.name] = {
|
|
187
|
+
status: testCase.status,
|
|
188
|
+
...(testCase.type ? { type: testCase.type } : {}),
|
|
189
|
+
...(testCase.message ? { message: stripProjectDir(testCase.message, cwd) } : {})
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return suites;
|
|
193
|
+
}
|
|
194
|
+
const phpunit = {
|
|
195
|
+
tool: "phpunit",
|
|
196
|
+
defaultCommand: `vendor/bin/phpunit --log-junit ${JUNIT_ARTIFACT}`,
|
|
197
|
+
defaultOutput: JUNIT_ARTIFACT,
|
|
198
|
+
format: "text",
|
|
199
|
+
toleratesNonZeroExit: true,
|
|
200
|
+
normalize: normalizeJUnit
|
|
201
|
+
};
|
|
202
|
+
const pest = {
|
|
203
|
+
tool: "pest",
|
|
204
|
+
defaultCommand: `vendor/bin/pest --log-junit ${JUNIT_ARTIFACT}`,
|
|
205
|
+
defaultOutput: JUNIT_ARTIFACT,
|
|
206
|
+
format: "text",
|
|
207
|
+
toleratesNonZeroExit: true,
|
|
208
|
+
normalize: normalizeJUnit
|
|
209
|
+
};
|
|
210
|
+
// Pint `--test --format=json`. Like PHPStan, Pint emits two shapes: the
|
|
211
|
+
// classic PHP-CS-Fixer report `{about, files: [{name, appliedFixers}]}` and an
|
|
212
|
+
// AI-agent shape `{tool: "pint", result, files: [{path, fixers}]}` (where even
|
|
213
|
+
// a failing run exits 0). Both normalize to file → sorted fixer names — the
|
|
214
|
+
// one field the shapes agree on — so snapshots stay environment-independent.
|
|
215
|
+
// The classic shape's per-file diff text is deliberately dropped for the same
|
|
216
|
+
// reason; the review UI can regenerate diffs when it needs them.
|
|
217
|
+
const pint = {
|
|
218
|
+
tool: "pint",
|
|
219
|
+
defaultCommand: "vendor/bin/pint --test --format=json -v",
|
|
220
|
+
toleratesNonZeroExit: true,
|
|
221
|
+
normalize(raw) {
|
|
222
|
+
if (!isRecord(raw) || !(raw.tool === "pint" || typeof raw.about === "string")) {
|
|
223
|
+
throw invalidOutput(this.tool, "Pint JSON output");
|
|
224
|
+
}
|
|
225
|
+
const data = {};
|
|
226
|
+
for (const file of Array.isArray(raw.files) ? raw.files : []) {
|
|
227
|
+
if (!isRecord(file)) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const filePath = file.path ?? file.name;
|
|
231
|
+
if (typeof filePath !== "string" || isUnsafeKey(filePath)) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const fixers = file.fixers ?? file.appliedFixers;
|
|
235
|
+
data[filePath] = Array.isArray(fixers) ? [...fixers].sort() : true;
|
|
236
|
+
}
|
|
237
|
+
return data;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
// `schema:dump` writes DDL to database/schema/{connection}-schema.sql. The
|
|
241
|
+
// default output assumes sqlite (Laravel's default connection since 11); other
|
|
242
|
+
// connections override `output` with their dump path. Dropped columns and type
|
|
243
|
+
// changes diff as one named entry per table (see src/check/schema.ts).
|
|
244
|
+
const laravelSchema = {
|
|
245
|
+
tool: "laravel-schema",
|
|
246
|
+
defaultCommand: "php artisan schema:dump",
|
|
247
|
+
defaultOutput: "database/schema/sqlite-schema.sql",
|
|
248
|
+
format: "text",
|
|
249
|
+
normalize(raw) {
|
|
250
|
+
return parseSchemaDump(String(raw));
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
function stripProjectDir(value, cwd) {
|
|
254
|
+
const pattern = new RegExp(cwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
255
|
+
const strip = (node) => {
|
|
256
|
+
if (typeof node === "string") {
|
|
257
|
+
return node.replace(pattern, "<project>");
|
|
258
|
+
}
|
|
259
|
+
if (Array.isArray(node)) {
|
|
260
|
+
return node.map(strip);
|
|
261
|
+
}
|
|
262
|
+
if (isRecord(node)) {
|
|
263
|
+
return Object.fromEntries(Object.entries(node).map(([key, entry]) => [strip(key), strip(entry)]));
|
|
264
|
+
}
|
|
265
|
+
return node;
|
|
266
|
+
};
|
|
267
|
+
return strip(value);
|
|
268
|
+
}
|
|
269
|
+
export const checkParsers = {
|
|
270
|
+
"laravel-routes": laravelRoutes,
|
|
271
|
+
"laravel-about": laravelAbout,
|
|
272
|
+
"laravel-schedule": laravelSchedule,
|
|
273
|
+
"laravel-schema": laravelSchema,
|
|
274
|
+
phpstan,
|
|
275
|
+
phpunit,
|
|
276
|
+
pest,
|
|
277
|
+
pint
|
|
278
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SQL-dump extraction for the laravel-schema check parser.
|
|
2
|
+
//
|
|
3
|
+
// Not a SQL parser — it targets the DDL Laravel's `schema:dump` writes
|
|
4
|
+
// (sqlite today, mysqldump-style guardedly): CREATE TABLE bodies, CREATE INDEX
|
|
5
|
+
// statements, and the migrations-table INSERTs. Everything is normalized into
|
|
6
|
+
// the check kind's keyed-record shape:
|
|
7
|
+
//
|
|
8
|
+
// { users: { id: "integer primary key autoincrement not null",
|
|
9
|
+
// "(index) users_email_unique": "unique (email)",
|
|
10
|
+
// "(constraint) primary key(email)": true },
|
|
11
|
+
// "(migrations)": { "0001_01_01_000000_create_users_table": true } }
|
|
12
|
+
//
|
|
13
|
+
// so a dropped column or changed type diffs as one named entry per table.
|
|
14
|
+
const CONSTRAINT_KEYWORDS = /^(primary\s+key|foreign\s+key|unique|constraint|check|key)\b/i;
|
|
15
|
+
function unquote(identifier) {
|
|
16
|
+
return identifier.replace(/^[`"[]|[`"\]]$/g, "");
|
|
17
|
+
}
|
|
18
|
+
function collapse(text) {
|
|
19
|
+
return text.replace(/\s+/g, " ").trim();
|
|
20
|
+
}
|
|
21
|
+
// Split a CREATE TABLE body on top-level commas (paren-depth aware).
|
|
22
|
+
function splitColumns(body) {
|
|
23
|
+
const parts = [];
|
|
24
|
+
let depth = 0;
|
|
25
|
+
let current = "";
|
|
26
|
+
for (const char of body) {
|
|
27
|
+
if (char === "(") {
|
|
28
|
+
depth += 1;
|
|
29
|
+
}
|
|
30
|
+
else if (char === ")") {
|
|
31
|
+
depth -= 1;
|
|
32
|
+
}
|
|
33
|
+
if (char === "," && depth === 0) {
|
|
34
|
+
parts.push(current);
|
|
35
|
+
current = "";
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
current += char;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (current.trim()) {
|
|
42
|
+
parts.push(current);
|
|
43
|
+
}
|
|
44
|
+
return parts;
|
|
45
|
+
}
|
|
46
|
+
// Accumulate lines into statements ending with ";", dropping comment lines.
|
|
47
|
+
function statements(sql) {
|
|
48
|
+
const result = [];
|
|
49
|
+
let current = "";
|
|
50
|
+
for (const line of sql.split("\n")) {
|
|
51
|
+
const trimmed = line.trim();
|
|
52
|
+
if (trimmed.startsWith("--") || trimmed.startsWith("/*") || trimmed === "") {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
current += `${line}\n`;
|
|
56
|
+
if (trimmed.endsWith(";")) {
|
|
57
|
+
result.push(current.trim().replace(/;$/, ""));
|
|
58
|
+
current = "";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
export function parseSchemaDump(sql) {
|
|
64
|
+
const data = {};
|
|
65
|
+
const table = (name) => {
|
|
66
|
+
data[name] ??= {};
|
|
67
|
+
return data[name];
|
|
68
|
+
};
|
|
69
|
+
let sawDdl = false;
|
|
70
|
+
for (const statement of statements(sql)) {
|
|
71
|
+
const createTable = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([`"]?\w+[`"]?)\s*\(([\s\S]*)\)[^)]*$/i.exec(statement);
|
|
72
|
+
if (createTable) {
|
|
73
|
+
sawDdl = true;
|
|
74
|
+
const name = unquote(createTable[1]);
|
|
75
|
+
if (name === "sqlite_sequence") {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const entry = table(name);
|
|
79
|
+
for (const part of splitColumns(createTable[2])) {
|
|
80
|
+
const text = collapse(part);
|
|
81
|
+
if (!text) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (CONSTRAINT_KEYWORDS.test(text)) {
|
|
85
|
+
entry[`(constraint) ${text}`] = true;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const [identifier, ...definition] = text.split(" ");
|
|
89
|
+
entry[unquote(identifier)] = definition.join(" ");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const createIndex = /^CREATE\s+(UNIQUE\s+)?INDEX\s+([`"]?\w+[`"]?)\s+ON\s+([`"]?\w+[`"]?)\s*\(([\s\S]*)\)$/i.exec(statement);
|
|
95
|
+
if (createIndex) {
|
|
96
|
+
sawDdl = true;
|
|
97
|
+
const [, unique, indexName, tableName, columns] = createIndex;
|
|
98
|
+
table(unquote(tableName))[`(index) ${unquote(indexName)}`] =
|
|
99
|
+
`${unique ? "unique " : ""}(${collapse(columns).replace(/[`"]/g, "")})`;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// Migration rows: the name is schema signal, the id/batch churn.
|
|
103
|
+
if (/^INSERT\s+INTO\s+[`"]?migrations[`"]?/i.test(statement)) {
|
|
104
|
+
for (const row of statement.matchAll(/\(\s*\d+\s*,\s*'([^']+)'\s*,\s*\d+\s*\)/g)) {
|
|
105
|
+
table("(migrations)")[row[1]] = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Anything else (PRAGMA, SET, other inserts) carries no schema shape.
|
|
109
|
+
}
|
|
110
|
+
if (!sawDdl) {
|
|
111
|
+
throw new Error("Invalid schema dump: no CREATE TABLE statements found.");
|
|
112
|
+
}
|
|
113
|
+
return data;
|
|
114
|
+
}
|
package/dist/cloud.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type ClientCredentials = {
|
|
2
|
+
clientId: string;
|
|
3
|
+
clientSecret: string;
|
|
4
|
+
};
|
|
5
|
+
export type PushOptions = ClientCredentials & {
|
|
6
|
+
serverUrl: string;
|
|
7
|
+
reportPath: string;
|
|
8
|
+
branch?: string;
|
|
9
|
+
commit?: string;
|
|
10
|
+
};
|
|
11
|
+
export type PushedRun = {
|
|
12
|
+
id: string;
|
|
13
|
+
repositoryId: string;
|
|
14
|
+
status: string;
|
|
15
|
+
counts: Record<string, number>;
|
|
16
|
+
url: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function pushReport(options: PushOptions): Promise<PushedRun>;
|
|
19
|
+
export type BaselineSource = {
|
|
20
|
+
target: string;
|
|
21
|
+
kind: string;
|
|
22
|
+
snapshotPath: string;
|
|
23
|
+
screenshotPath?: string;
|
|
24
|
+
};
|
|
25
|
+
export type PushBaselinesOptions = ClientCredentials & {
|
|
26
|
+
serverUrl: string;
|
|
27
|
+
baselines: BaselineSource[];
|
|
28
|
+
};
|
|
29
|
+
export type UploadedBaseline = {
|
|
30
|
+
target: string;
|
|
31
|
+
version: number;
|
|
32
|
+
deduped: boolean;
|
|
33
|
+
};
|
|
34
|
+
export declare function pushBaselines(options: PushBaselinesOptions): Promise<UploadedBaseline[]>;
|
|
35
|
+
export type AnonPushResult = {
|
|
36
|
+
id: string;
|
|
37
|
+
url: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function pushAnonReport(options: {
|
|
40
|
+
serverUrl: string;
|
|
41
|
+
report: unknown;
|
|
42
|
+
}): Promise<AnonPushResult>;
|