@sdods/db 0.2.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 +17 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/col.d.ts +32 -0
- package/dist/col.js +58 -0
- package/dist/create-db.d.ts +14 -0
- package/dist/create-db.js +58 -0
- package/dist/driver.d.ts +10 -0
- package/dist/driver.js +16 -0
- package/dist/ids.d.ts +2 -0
- package/dist/ids.js +2 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +22 -0
- package/dist/ingest/attachments.d.ts +40 -0
- package/dist/ingest/attachments.js +154 -0
- package/dist/ingest/cucumber-ingest.d.ts +40 -0
- package/dist/ingest/cucumber-ingest.js +543 -0
- package/dist/ingest/finalize.d.ts +28 -0
- package/dist/ingest/finalize.js +132 -0
- package/dist/ingest/index.d.ts +26 -0
- package/dist/ingest/index.js +193 -0
- package/dist/ingest/message-index.d.ts +64 -0
- package/dist/ingest/message-index.js +146 -0
- package/dist/ingest/parse-ndjson.d.ts +10 -0
- package/dist/ingest/parse-ndjson.js +35 -0
- package/dist/ingest/pw-json-ingest.d.ts +14 -0
- package/dist/ingest/pw-json-ingest.js +259 -0
- package/dist/ingest/runner-json-ingest.d.ts +14 -0
- package/dist/ingest/runner-json-ingest.js +259 -0
- package/dist/ingest/types.d.ts +62 -0
- package/dist/ingest/types.js +89 -0
- package/dist/insights.d.ts +62 -0
- package/dist/insights.js +184 -0
- package/dist/migrate.d.ts +30 -0
- package/dist/migrate.js +114 -0
- package/dist/migrations/0001_platform_init.d.ts +5 -0
- package/dist/migrations/0001_platform_init.js +48 -0
- package/dist/migrations/0002_results.d.ts +5 -0
- package/dist/migrations/0002_results.js +124 -0
- package/dist/migrations/0003_improvement.d.ts +5 -0
- package/dist/migrations/0003_improvement.js +56 -0
- package/dist/migrations/0004_test_data.d.ts +5 -0
- package/dist/migrations/0004_test_data.js +48 -0
- package/dist/migrations/0005_integrations_agents_audit.d.ts +5 -0
- package/dist/migrations/0005_integrations_agents_audit.js +100 -0
- package/dist/migrations/0006_schedules.d.ts +5 -0
- package/dist/migrations/0006_schedules.js +34 -0
- package/dist/migrations/0007_hierarchy.d.ts +5 -0
- package/dist/migrations/0007_hierarchy.js +76 -0
- package/dist/migrations/0008_runner_naming.d.ts +14 -0
- package/dist/migrations/0008_runner_naming.js +35 -0
- package/dist/migrations/helpers.d.ts +15 -0
- package/dist/migrations/helpers.js +30 -0
- package/dist/migrations/index.d.ts +6 -0
- package/dist/migrations/index.js +22 -0
- package/dist/repos/auth.d.ts +109 -0
- package/dist/repos/auth.js +258 -0
- package/dist/repos/hierarchy.d.ts +151 -0
- package/dist/repos/hierarchy.js +465 -0
- package/dist/repos/improvement.d.ts +84 -0
- package/dist/repos/improvement.js +242 -0
- package/dist/repos/index.d.ts +8 -0
- package/dist/repos/index.js +8 -0
- package/dist/repos/platform.d.ts +250 -0
- package/dist/repos/platform.js +429 -0
- package/dist/repos/projects.d.ts +87 -0
- package/dist/repos/projects.js +118 -0
- package/dist/repos/runs.d.ts +306 -0
- package/dist/repos/runs.js +321 -0
- package/dist/repos/test-data.d.ts +84 -0
- package/dist/repos/test-data.js +291 -0
- package/dist/schema.d.ts +514 -0
- package/dist/schema.js +37 -0
- package/dist/scopes.d.ts +2 -0
- package/dist/scopes.js +2 -0
- package/dist/switch/export.d.ts +15 -0
- package/dist/switch/export.js +67 -0
- package/dist/switch/import.d.ts +16 -0
- package/dist/switch/import.js +106 -0
- package/dist/switch/index.d.ts +5 -0
- package/dist/switch/index.js +5 -0
- package/dist/switch/prune.d.ts +19 -0
- package/dist/switch/prune.js +50 -0
- package/dist/switch/switch.d.ts +34 -0
- package/dist/switch/switch.js +133 -0
- package/dist/test-data.d.ts +22 -0
- package/dist/test-data.js +79 -0
- package/package.json +50 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative } from 'node:path';
|
|
3
|
+
import { fingerprint as makeFingerprint, newId, parseRunnerProjectName } from '@sdods/contracts/ids';
|
|
4
|
+
import { enc, nowIso } from '../col.js';
|
|
5
|
+
import { artifactKindFor, parseAttachmentName, storeFile, targetFileFor } from './attachments.js';
|
|
6
|
+
import { moduleFromUri, pwStatus, selectorFromError } from './types.js';
|
|
7
|
+
/** Ingest a Playwright `json` reporter file (recorded layer / plain specs) into the same tables. */
|
|
8
|
+
export async function ingestPwJson(ctx, file, locatorCounters) {
|
|
9
|
+
const report = JSON.parse(readFileSync(file, 'utf8'));
|
|
10
|
+
const counts = { scenarios: 0, attempts: 0, steps: 0, artifacts: 0 };
|
|
11
|
+
let startedAt = null;
|
|
12
|
+
let finishedAt = null;
|
|
13
|
+
const { db, driver } = ctx.adb;
|
|
14
|
+
const rootDir = report.config?.rootDir ?? dirname(file);
|
|
15
|
+
const walk = async (suite) => {
|
|
16
|
+
for (const s of suite.suites ?? [])
|
|
17
|
+
await walk(s);
|
|
18
|
+
for (const spec of suite.specs ?? []) {
|
|
19
|
+
for (const test of spec.tests ?? []) {
|
|
20
|
+
const pwProject = test.projectName ?? test.projectId ?? 'recorded';
|
|
21
|
+
const parts = parseRunnerProjectName(pwProject);
|
|
22
|
+
const slug = parts?.project ?? ctx.projectSlug;
|
|
23
|
+
const layer = parts?.layer ?? 'recorded';
|
|
24
|
+
const browser = parts?.browser ?? null;
|
|
25
|
+
const specFile = String(spec.file ?? suite.file ?? 'unknown.spec.ts').replace(/\\/g, '/');
|
|
26
|
+
const title = spec.title ?? test.title ?? 'untitled';
|
|
27
|
+
const fp = makeFingerprint({
|
|
28
|
+
project: slug,
|
|
29
|
+
featureUri: specFile,
|
|
30
|
+
scenarioName: title,
|
|
31
|
+
exampleIndex: null,
|
|
32
|
+
layer,
|
|
33
|
+
});
|
|
34
|
+
const naturalKey = `${pwProject}:${test.id ?? `${specFile}:${spec.line ?? 0}`}`;
|
|
35
|
+
const tags = (spec.tags ?? test.tags ?? []).map((t) => t.startsWith('@') ? t : `@${t}`);
|
|
36
|
+
let scenarioId;
|
|
37
|
+
const existing = await db
|
|
38
|
+
.selectFrom('scenarios')
|
|
39
|
+
.select(['id'])
|
|
40
|
+
.where('run_id', '=', ctx.runId)
|
|
41
|
+
.where('natural_key', '=', naturalKey)
|
|
42
|
+
.executeTakeFirst();
|
|
43
|
+
if (existing)
|
|
44
|
+
scenarioId = existing.id;
|
|
45
|
+
else {
|
|
46
|
+
scenarioId = newId();
|
|
47
|
+
await db
|
|
48
|
+
.insertInto('scenarios')
|
|
49
|
+
.values({
|
|
50
|
+
id: scenarioId,
|
|
51
|
+
run_id: ctx.runId,
|
|
52
|
+
project_id: ctx.projectId,
|
|
53
|
+
natural_key: naturalKey,
|
|
54
|
+
fingerprint: fp,
|
|
55
|
+
source: 'pw-json',
|
|
56
|
+
feature_uri: specFile,
|
|
57
|
+
feature_name: suite.title ?? basename(specFile),
|
|
58
|
+
scenario_name: title,
|
|
59
|
+
module: moduleFromUri(specFile),
|
|
60
|
+
examples_row: null,
|
|
61
|
+
pw_project: pwProject,
|
|
62
|
+
layer,
|
|
63
|
+
browser,
|
|
64
|
+
suite_tag: tags.find((t) => ['@smoke', '@regression', '@sanity'].includes(t)) ?? null,
|
|
65
|
+
tags_json: enc.json(tags),
|
|
66
|
+
jira_keys_json: enc.json(tags.filter((t) => t.startsWith('@jira:')).map((t) => t.slice(6))),
|
|
67
|
+
status: 'unknown',
|
|
68
|
+
attempts_count: 0,
|
|
69
|
+
flaky: enc.bool(driver, false),
|
|
70
|
+
duration_ms: null,
|
|
71
|
+
error_message: null,
|
|
72
|
+
error_stack: null,
|
|
73
|
+
started_at: null,
|
|
74
|
+
finished_at: null,
|
|
75
|
+
created_at: nowIso(),
|
|
76
|
+
updated_at: nowIso(),
|
|
77
|
+
})
|
|
78
|
+
.execute();
|
|
79
|
+
counts.scenarios++;
|
|
80
|
+
}
|
|
81
|
+
for (const result of test.results ?? []) {
|
|
82
|
+
const retry = Number(result.retry ?? 0);
|
|
83
|
+
const tcsId = `${test.id ?? naturalKey}#${retry}`;
|
|
84
|
+
const status = pwStatus(result.status);
|
|
85
|
+
const started = result.startTime ? new Date(result.startTime).toISOString() : null;
|
|
86
|
+
const finished = started && typeof result.duration === 'number'
|
|
87
|
+
? new Date(Date.parse(started) + result.duration).toISOString()
|
|
88
|
+
: null;
|
|
89
|
+
startedAt =
|
|
90
|
+
!startedAt || (started && started < startedAt) ? (started ?? startedAt) : startedAt;
|
|
91
|
+
finishedAt =
|
|
92
|
+
!finishedAt || (finished && finished > finishedAt)
|
|
93
|
+
? (finished ?? finishedAt)
|
|
94
|
+
: finishedAt;
|
|
95
|
+
const errorMessage = result.error?.message ?? result.errors?.[0]?.message ?? null;
|
|
96
|
+
const errorStack = result.error?.stack ?? result.errors?.[0]?.stack ?? null;
|
|
97
|
+
if (status === 'failed' || status === 'timedOut') {
|
|
98
|
+
const selector = selectorFromError(errorStack ?? errorMessage);
|
|
99
|
+
if (selector) {
|
|
100
|
+
const c = locatorCounters.get(selector) ?? { fails: 0, heals: 0, uses: 0 };
|
|
101
|
+
c.fails++;
|
|
102
|
+
c.uses++;
|
|
103
|
+
locatorCounters.set(selector, c);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
let attemptId;
|
|
107
|
+
const ex = await db
|
|
108
|
+
.selectFrom('scenario_attempts')
|
|
109
|
+
.select(['id'])
|
|
110
|
+
.where('run_id', '=', ctx.runId)
|
|
111
|
+
.where('test_case_started_id', '=', tcsId)
|
|
112
|
+
.executeTakeFirst();
|
|
113
|
+
const values = {
|
|
114
|
+
attempt: retry,
|
|
115
|
+
status,
|
|
116
|
+
duration_ms: typeof result.duration === 'number' ? result.duration : null,
|
|
117
|
+
error_message: errorMessage ? errorMessage.split('\n')[0].slice(0, 2000) : null,
|
|
118
|
+
error_stack: errorStack,
|
|
119
|
+
will_be_retried: enc.bool(driver, (status === 'failed' || status === 'timedOut') && retry < Number(test.retries ?? 0)),
|
|
120
|
+
worker_index: typeof result.workerIndex === 'number' ? result.workerIndex : null,
|
|
121
|
+
started_at: started,
|
|
122
|
+
finished_at: finished,
|
|
123
|
+
};
|
|
124
|
+
if (ex) {
|
|
125
|
+
attemptId = ex.id;
|
|
126
|
+
await db
|
|
127
|
+
.updateTable('scenario_attempts')
|
|
128
|
+
.set(values)
|
|
129
|
+
.where('id', '=', attemptId)
|
|
130
|
+
.execute();
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
attemptId = newId();
|
|
134
|
+
await db
|
|
135
|
+
.insertInto('scenario_attempts')
|
|
136
|
+
.values({
|
|
137
|
+
id: attemptId,
|
|
138
|
+
scenario_id: scenarioId,
|
|
139
|
+
run_id: ctx.runId,
|
|
140
|
+
test_case_started_id: tcsId,
|
|
141
|
+
created_at: nowIso(),
|
|
142
|
+
...values,
|
|
143
|
+
})
|
|
144
|
+
.execute();
|
|
145
|
+
counts.attempts++;
|
|
146
|
+
}
|
|
147
|
+
// steps (category test.step, top level)
|
|
148
|
+
const steps = (result.steps ?? []).filter((s) => !s.category || s.category === 'test.step');
|
|
149
|
+
let idx = 0;
|
|
150
|
+
for (const st of steps) {
|
|
151
|
+
const testStepId = `${tcsId}-step-${idx}`;
|
|
152
|
+
const stepStatus = st.error ? 'failed' : 'passed';
|
|
153
|
+
const sv = {
|
|
154
|
+
step_index: idx,
|
|
155
|
+
kind: 'step',
|
|
156
|
+
hook_type: null,
|
|
157
|
+
keyword: null,
|
|
158
|
+
text: String(st.title ?? ''),
|
|
159
|
+
argument_json: null,
|
|
160
|
+
status: stepStatus,
|
|
161
|
+
duration_ms: typeof st.duration === 'number' ? st.duration : null,
|
|
162
|
+
error_message: st.error?.message
|
|
163
|
+
? String(st.error.message).split('\n')[0].slice(0, 2000)
|
|
164
|
+
: null,
|
|
165
|
+
error_stack: st.error?.stack ?? null,
|
|
166
|
+
definition_location: st.location ? `${st.location.file}:${st.location.line}` : null,
|
|
167
|
+
pickle_step_id: null,
|
|
168
|
+
layer_hint: 'ui',
|
|
169
|
+
started_at: null,
|
|
170
|
+
finished_at: null,
|
|
171
|
+
};
|
|
172
|
+
const es = await db
|
|
173
|
+
.selectFrom('steps')
|
|
174
|
+
.select(['id'])
|
|
175
|
+
.where('attempt_id', '=', attemptId)
|
|
176
|
+
.where('test_step_id', '=', testStepId)
|
|
177
|
+
.executeTakeFirst();
|
|
178
|
+
if (es)
|
|
179
|
+
await db.updateTable('steps').set(sv).where('id', '=', es.id).execute();
|
|
180
|
+
else {
|
|
181
|
+
await db
|
|
182
|
+
.insertInto('steps')
|
|
183
|
+
.values({
|
|
184
|
+
id: newId(),
|
|
185
|
+
attempt_id: attemptId,
|
|
186
|
+
scenario_id: scenarioId,
|
|
187
|
+
run_id: ctx.runId,
|
|
188
|
+
test_step_id: testStepId,
|
|
189
|
+
api_snapshot_json: null,
|
|
190
|
+
perf_json: null,
|
|
191
|
+
created_at: nowIso(),
|
|
192
|
+
...sv,
|
|
193
|
+
})
|
|
194
|
+
.execute();
|
|
195
|
+
counts.steps++;
|
|
196
|
+
}
|
|
197
|
+
idx++;
|
|
198
|
+
}
|
|
199
|
+
// attachments
|
|
200
|
+
const dir = join(ctx.runDir, slug, fp, `r${retry}`);
|
|
201
|
+
for (const att of result.attachments ?? []) {
|
|
202
|
+
const name = att.name ?? 'attachment';
|
|
203
|
+
const mediaType = att.contentType ?? 'application/octet-stream';
|
|
204
|
+
const parsed = parseAttachmentName(name);
|
|
205
|
+
const filePath = att.path
|
|
206
|
+
? isAbsolute(att.path)
|
|
207
|
+
? att.path
|
|
208
|
+
: join(rootDir, att.path)
|
|
209
|
+
: undefined;
|
|
210
|
+
const bytes = att.body ? Buffer.from(att.body, 'base64') : null;
|
|
211
|
+
if (!bytes && !(filePath && existsSync(filePath)))
|
|
212
|
+
continue;
|
|
213
|
+
const stored = storeFile(ctx.artifactsRoot, dir, targetFileFor(parsed, name, mediaType), bytes, filePath);
|
|
214
|
+
if (!stored)
|
|
215
|
+
continue;
|
|
216
|
+
const { kind, phase, stepIndex } = artifactKindFor(parsed, mediaType);
|
|
217
|
+
const ea = await db
|
|
218
|
+
.selectFrom('artifacts')
|
|
219
|
+
.select(['id'])
|
|
220
|
+
.where('rel_path', '=', stored.relPath)
|
|
221
|
+
.executeTakeFirst();
|
|
222
|
+
const av = {
|
|
223
|
+
run_id: ctx.runId,
|
|
224
|
+
scenario_id: scenarioId,
|
|
225
|
+
attempt_id: attemptId,
|
|
226
|
+
step_id: null,
|
|
227
|
+
step_index: stepIndex,
|
|
228
|
+
kind,
|
|
229
|
+
phase,
|
|
230
|
+
media_type: mediaType,
|
|
231
|
+
file_name: name,
|
|
232
|
+
size_bytes: stored.sizeBytes,
|
|
233
|
+
sha256: stored.sha256,
|
|
234
|
+
width: stored.width,
|
|
235
|
+
height: stored.height,
|
|
236
|
+
meta_json: enc.json({
|
|
237
|
+
original: name,
|
|
238
|
+
source: filePath ? relative(rootDir, filePath) : 'inline',
|
|
239
|
+
}),
|
|
240
|
+
};
|
|
241
|
+
if (ea)
|
|
242
|
+
await db.updateTable('artifacts').set(av).where('id', '=', ea.id).execute();
|
|
243
|
+
else {
|
|
244
|
+
await db
|
|
245
|
+
.insertInto('artifacts')
|
|
246
|
+
.values({ id: newId(), rel_path: stored.relPath, created_at: nowIso(), ...av })
|
|
247
|
+
.execute();
|
|
248
|
+
counts.artifacts++;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
for (const s of report.suites ?? [])
|
|
256
|
+
await walk(s);
|
|
257
|
+
return { ...counts, startedAt, finishedAt };
|
|
258
|
+
}
|
|
259
|
+
//# sourceMappingURL=pw-json-ingest.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LocatorCounter } from './finalize.js';
|
|
2
|
+
import { type IngestContext } from './types.js';
|
|
3
|
+
export interface RunnerJsonCounts {
|
|
4
|
+
scenarios: number;
|
|
5
|
+
attempts: number;
|
|
6
|
+
steps: number;
|
|
7
|
+
artifacts: number;
|
|
8
|
+
}
|
|
9
|
+
/** Ingest a Playwright `json` reporter file (recorded layer / plain specs) into the same tables. */
|
|
10
|
+
export declare function ingestRunnerJson(ctx: IngestContext, file: string, locatorCounters: Map<string, LocatorCounter>): Promise<RunnerJsonCounts & {
|
|
11
|
+
startedAt: string | null;
|
|
12
|
+
finishedAt: string | null;
|
|
13
|
+
}>;
|
|
14
|
+
//# sourceMappingURL=runner-json-ingest.d.ts.map
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative } from 'node:path';
|
|
3
|
+
import { fingerprint as makeFingerprint, newId, parseRunnerProjectName, } from '@sdods/contracts/ids';
|
|
4
|
+
import { enc, nowIso } from '../col.js';
|
|
5
|
+
import { artifactKindFor, parseAttachmentName, storeFile, targetFileFor } from './attachments.js';
|
|
6
|
+
import { moduleFromUri, runnerStatus, selectorFromError } from './types.js';
|
|
7
|
+
/** Ingest a Playwright `json` reporter file (recorded layer / plain specs) into the same tables. */
|
|
8
|
+
export async function ingestRunnerJson(ctx, file, locatorCounters) {
|
|
9
|
+
const report = JSON.parse(readFileSync(file, 'utf8'));
|
|
10
|
+
const counts = { scenarios: 0, attempts: 0, steps: 0, artifacts: 0 };
|
|
11
|
+
let startedAt = null;
|
|
12
|
+
let finishedAt = null;
|
|
13
|
+
const { db, driver } = ctx.adb;
|
|
14
|
+
const rootDir = report.config?.rootDir ?? dirname(file);
|
|
15
|
+
const walk = async (suite) => {
|
|
16
|
+
for (const s of suite.suites ?? [])
|
|
17
|
+
await walk(s);
|
|
18
|
+
for (const spec of suite.specs ?? []) {
|
|
19
|
+
for (const test of spec.tests ?? []) {
|
|
20
|
+
const runnerProject = test.projectName ?? test.projectId ?? 'recorded';
|
|
21
|
+
const parts = parseRunnerProjectName(runnerProject);
|
|
22
|
+
const slug = parts?.project ?? ctx.projectSlug;
|
|
23
|
+
const layer = parts?.layer ?? 'recorded';
|
|
24
|
+
const browser = parts?.browser ?? null;
|
|
25
|
+
const specFile = String(spec.file ?? suite.file ?? 'unknown.spec.ts').replace(/\\/g, '/');
|
|
26
|
+
const title = spec.title ?? test.title ?? 'untitled';
|
|
27
|
+
const fp = makeFingerprint({
|
|
28
|
+
project: slug,
|
|
29
|
+
featureUri: specFile,
|
|
30
|
+
scenarioName: title,
|
|
31
|
+
exampleIndex: null,
|
|
32
|
+
layer,
|
|
33
|
+
});
|
|
34
|
+
const naturalKey = `${runnerProject}:${test.id ?? `${specFile}:${spec.line ?? 0}`}`;
|
|
35
|
+
const tags = (spec.tags ?? test.tags ?? []).map((t) => t.startsWith('@') ? t : `@${t}`);
|
|
36
|
+
let scenarioId;
|
|
37
|
+
const existing = await db
|
|
38
|
+
.selectFrom('scenarios')
|
|
39
|
+
.select(['id'])
|
|
40
|
+
.where('run_id', '=', ctx.runId)
|
|
41
|
+
.where('natural_key', '=', naturalKey)
|
|
42
|
+
.executeTakeFirst();
|
|
43
|
+
if (existing)
|
|
44
|
+
scenarioId = existing.id;
|
|
45
|
+
else {
|
|
46
|
+
scenarioId = newId();
|
|
47
|
+
await db
|
|
48
|
+
.insertInto('scenarios')
|
|
49
|
+
.values({
|
|
50
|
+
id: scenarioId,
|
|
51
|
+
run_id: ctx.runId,
|
|
52
|
+
project_id: ctx.projectId,
|
|
53
|
+
natural_key: naturalKey,
|
|
54
|
+
fingerprint: fp,
|
|
55
|
+
source: 'runner-json',
|
|
56
|
+
feature_uri: specFile,
|
|
57
|
+
feature_name: suite.title ?? basename(specFile),
|
|
58
|
+
scenario_name: title,
|
|
59
|
+
module: moduleFromUri(specFile),
|
|
60
|
+
examples_row: null,
|
|
61
|
+
runner_project: runnerProject,
|
|
62
|
+
layer,
|
|
63
|
+
browser,
|
|
64
|
+
suite_tag: tags.find((t) => ['@smoke', '@regression', '@sanity'].includes(t)) ?? null,
|
|
65
|
+
tags_json: enc.json(tags),
|
|
66
|
+
jira_keys_json: enc.json(tags.filter((t) => t.startsWith('@jira:')).map((t) => t.slice(6))),
|
|
67
|
+
status: 'unknown',
|
|
68
|
+
attempts_count: 0,
|
|
69
|
+
flaky: enc.bool(driver, false),
|
|
70
|
+
duration_ms: null,
|
|
71
|
+
error_message: null,
|
|
72
|
+
error_stack: null,
|
|
73
|
+
started_at: null,
|
|
74
|
+
finished_at: null,
|
|
75
|
+
created_at: nowIso(),
|
|
76
|
+
updated_at: nowIso(),
|
|
77
|
+
})
|
|
78
|
+
.execute();
|
|
79
|
+
counts.scenarios++;
|
|
80
|
+
}
|
|
81
|
+
for (const result of test.results ?? []) {
|
|
82
|
+
const retry = Number(result.retry ?? 0);
|
|
83
|
+
const tcsId = `${test.id ?? naturalKey}#${retry}`;
|
|
84
|
+
const status = runnerStatus(result.status);
|
|
85
|
+
const started = result.startTime ? new Date(result.startTime).toISOString() : null;
|
|
86
|
+
const finished = started && typeof result.duration === 'number'
|
|
87
|
+
? new Date(Date.parse(started) + result.duration).toISOString()
|
|
88
|
+
: null;
|
|
89
|
+
startedAt =
|
|
90
|
+
!startedAt || (started && started < startedAt) ? (started ?? startedAt) : startedAt;
|
|
91
|
+
finishedAt =
|
|
92
|
+
!finishedAt || (finished && finished > finishedAt)
|
|
93
|
+
? (finished ?? finishedAt)
|
|
94
|
+
: finishedAt;
|
|
95
|
+
const errorMessage = result.error?.message ?? result.errors?.[0]?.message ?? null;
|
|
96
|
+
const errorStack = result.error?.stack ?? result.errors?.[0]?.stack ?? null;
|
|
97
|
+
if (status === 'failed' || status === 'timedOut') {
|
|
98
|
+
const selector = selectorFromError(errorStack ?? errorMessage);
|
|
99
|
+
if (selector) {
|
|
100
|
+
const c = locatorCounters.get(selector) ?? { fails: 0, heals: 0, uses: 0 };
|
|
101
|
+
c.fails++;
|
|
102
|
+
c.uses++;
|
|
103
|
+
locatorCounters.set(selector, c);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
let attemptId;
|
|
107
|
+
const ex = await db
|
|
108
|
+
.selectFrom('scenario_attempts')
|
|
109
|
+
.select(['id'])
|
|
110
|
+
.where('run_id', '=', ctx.runId)
|
|
111
|
+
.where('test_case_started_id', '=', tcsId)
|
|
112
|
+
.executeTakeFirst();
|
|
113
|
+
const values = {
|
|
114
|
+
attempt: retry,
|
|
115
|
+
status,
|
|
116
|
+
duration_ms: typeof result.duration === 'number' ? result.duration : null,
|
|
117
|
+
error_message: errorMessage ? errorMessage.split('\n')[0].slice(0, 2000) : null,
|
|
118
|
+
error_stack: errorStack,
|
|
119
|
+
will_be_retried: enc.bool(driver, (status === 'failed' || status === 'timedOut') && retry < Number(test.retries ?? 0)),
|
|
120
|
+
worker_index: typeof result.workerIndex === 'number' ? result.workerIndex : null,
|
|
121
|
+
started_at: started,
|
|
122
|
+
finished_at: finished,
|
|
123
|
+
};
|
|
124
|
+
if (ex) {
|
|
125
|
+
attemptId = ex.id;
|
|
126
|
+
await db
|
|
127
|
+
.updateTable('scenario_attempts')
|
|
128
|
+
.set(values)
|
|
129
|
+
.where('id', '=', attemptId)
|
|
130
|
+
.execute();
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
attemptId = newId();
|
|
134
|
+
await db
|
|
135
|
+
.insertInto('scenario_attempts')
|
|
136
|
+
.values({
|
|
137
|
+
id: attemptId,
|
|
138
|
+
scenario_id: scenarioId,
|
|
139
|
+
run_id: ctx.runId,
|
|
140
|
+
test_case_started_id: tcsId,
|
|
141
|
+
created_at: nowIso(),
|
|
142
|
+
...values,
|
|
143
|
+
})
|
|
144
|
+
.execute();
|
|
145
|
+
counts.attempts++;
|
|
146
|
+
}
|
|
147
|
+
// steps (category test.step, top level)
|
|
148
|
+
const steps = (result.steps ?? []).filter((s) => !s.category || s.category === 'test.step');
|
|
149
|
+
let idx = 0;
|
|
150
|
+
for (const st of steps) {
|
|
151
|
+
const testStepId = `${tcsId}-step-${idx}`;
|
|
152
|
+
const stepStatus = st.error ? 'failed' : 'passed';
|
|
153
|
+
const sv = {
|
|
154
|
+
step_index: idx,
|
|
155
|
+
kind: 'step',
|
|
156
|
+
hook_type: null,
|
|
157
|
+
keyword: null,
|
|
158
|
+
text: String(st.title ?? ''),
|
|
159
|
+
argument_json: null,
|
|
160
|
+
status: stepStatus,
|
|
161
|
+
duration_ms: typeof st.duration === 'number' ? st.duration : null,
|
|
162
|
+
error_message: st.error?.message
|
|
163
|
+
? String(st.error.message).split('\n')[0].slice(0, 2000)
|
|
164
|
+
: null,
|
|
165
|
+
error_stack: st.error?.stack ?? null,
|
|
166
|
+
definition_location: st.location ? `${st.location.file}:${st.location.line}` : null,
|
|
167
|
+
pickle_step_id: null,
|
|
168
|
+
layer_hint: 'ui',
|
|
169
|
+
started_at: null,
|
|
170
|
+
finished_at: null,
|
|
171
|
+
};
|
|
172
|
+
const es = await db
|
|
173
|
+
.selectFrom('steps')
|
|
174
|
+
.select(['id'])
|
|
175
|
+
.where('attempt_id', '=', attemptId)
|
|
176
|
+
.where('test_step_id', '=', testStepId)
|
|
177
|
+
.executeTakeFirst();
|
|
178
|
+
if (es)
|
|
179
|
+
await db.updateTable('steps').set(sv).where('id', '=', es.id).execute();
|
|
180
|
+
else {
|
|
181
|
+
await db
|
|
182
|
+
.insertInto('steps')
|
|
183
|
+
.values({
|
|
184
|
+
id: newId(),
|
|
185
|
+
attempt_id: attemptId,
|
|
186
|
+
scenario_id: scenarioId,
|
|
187
|
+
run_id: ctx.runId,
|
|
188
|
+
test_step_id: testStepId,
|
|
189
|
+
api_snapshot_json: null,
|
|
190
|
+
perf_json: null,
|
|
191
|
+
created_at: nowIso(),
|
|
192
|
+
...sv,
|
|
193
|
+
})
|
|
194
|
+
.execute();
|
|
195
|
+
counts.steps++;
|
|
196
|
+
}
|
|
197
|
+
idx++;
|
|
198
|
+
}
|
|
199
|
+
// attachments
|
|
200
|
+
const dir = join(ctx.runDir, slug, fp, `r${retry}`);
|
|
201
|
+
for (const att of result.attachments ?? []) {
|
|
202
|
+
const name = att.name ?? 'attachment';
|
|
203
|
+
const mediaType = att.contentType ?? 'application/octet-stream';
|
|
204
|
+
const parsed = parseAttachmentName(name);
|
|
205
|
+
const filePath = att.path
|
|
206
|
+
? isAbsolute(att.path)
|
|
207
|
+
? att.path
|
|
208
|
+
: join(rootDir, att.path)
|
|
209
|
+
: undefined;
|
|
210
|
+
const bytes = att.body ? Buffer.from(att.body, 'base64') : null;
|
|
211
|
+
if (!bytes && !(filePath && existsSync(filePath)))
|
|
212
|
+
continue;
|
|
213
|
+
const stored = storeFile(ctx.artifactsRoot, dir, targetFileFor(parsed, name, mediaType), bytes, filePath);
|
|
214
|
+
if (!stored)
|
|
215
|
+
continue;
|
|
216
|
+
const { kind, phase, stepIndex } = artifactKindFor(parsed, mediaType);
|
|
217
|
+
const ea = await db
|
|
218
|
+
.selectFrom('artifacts')
|
|
219
|
+
.select(['id'])
|
|
220
|
+
.where('rel_path', '=', stored.relPath)
|
|
221
|
+
.executeTakeFirst();
|
|
222
|
+
const av = {
|
|
223
|
+
run_id: ctx.runId,
|
|
224
|
+
scenario_id: scenarioId,
|
|
225
|
+
attempt_id: attemptId,
|
|
226
|
+
step_id: null,
|
|
227
|
+
step_index: stepIndex,
|
|
228
|
+
kind,
|
|
229
|
+
phase,
|
|
230
|
+
media_type: mediaType,
|
|
231
|
+
file_name: name,
|
|
232
|
+
size_bytes: stored.sizeBytes,
|
|
233
|
+
sha256: stored.sha256,
|
|
234
|
+
width: stored.width,
|
|
235
|
+
height: stored.height,
|
|
236
|
+
meta_json: enc.json({
|
|
237
|
+
original: name,
|
|
238
|
+
source: filePath ? relative(rootDir, filePath) : 'inline',
|
|
239
|
+
}),
|
|
240
|
+
};
|
|
241
|
+
if (ea)
|
|
242
|
+
await db.updateTable('artifacts').set(av).where('id', '=', ea.id).execute();
|
|
243
|
+
else {
|
|
244
|
+
await db
|
|
245
|
+
.insertInto('artifacts')
|
|
246
|
+
.values({ id: newId(), rel_path: stored.relPath, created_at: nowIso(), ...av })
|
|
247
|
+
.execute();
|
|
248
|
+
counts.artifacts++;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
for (const s of report.suites ?? [])
|
|
256
|
+
await walk(s);
|
|
257
|
+
return { ...counts, startedAt, finishedAt };
|
|
258
|
+
}
|
|
259
|
+
//# sourceMappingURL=runner-json-ingest.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { RunManifest, RunTotals, SuiteStatus } from '@sdods/contracts/types';
|
|
2
|
+
import type { SdodsDb } from '../create-db.js';
|
|
3
|
+
export type { RunManifest, RunTotals, SuiteStatus };
|
|
4
|
+
export interface IngestRunOptions {
|
|
5
|
+
runId: string;
|
|
6
|
+
/** slug used when the manifest is absent and project names cannot be parsed */
|
|
7
|
+
projectSlug?: string;
|
|
8
|
+
manifest?: RunManifest | null;
|
|
9
|
+
ndjsonPaths?: string[];
|
|
10
|
+
runnerJsonPaths?: string[];
|
|
11
|
+
/** root that contains `<runId>/…` (default `.sdods/runs`) */
|
|
12
|
+
artifactsRoot: string;
|
|
13
|
+
replace?: boolean;
|
|
14
|
+
/** git/ci/trigger overrides when there is no manifest */
|
|
15
|
+
trigger?: string;
|
|
16
|
+
env?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface IngestResult {
|
|
19
|
+
runId: string;
|
|
20
|
+
projectSlug: string;
|
|
21
|
+
totals: RunTotals;
|
|
22
|
+
status: string;
|
|
23
|
+
scenarios: number;
|
|
24
|
+
attempts: number;
|
|
25
|
+
steps: number;
|
|
26
|
+
artifacts: number;
|
|
27
|
+
healEvents: number;
|
|
28
|
+
filesIngested: string[];
|
|
29
|
+
filesSkipped: string[];
|
|
30
|
+
parseErrors: number;
|
|
31
|
+
}
|
|
32
|
+
export interface IngestContext {
|
|
33
|
+
adb: SdodsDb;
|
|
34
|
+
runId: string;
|
|
35
|
+
projectId: string;
|
|
36
|
+
projectSlug: string;
|
|
37
|
+
artifactsRoot: string;
|
|
38
|
+
runDir: string;
|
|
39
|
+
workspaceId: string | null;
|
|
40
|
+
}
|
|
41
|
+
/** cucumber Timestamp/Duration → ms */
|
|
42
|
+
export declare function tsToMs(t?: {
|
|
43
|
+
seconds: number | string;
|
|
44
|
+
nanos: number;
|
|
45
|
+
} | null): number | null;
|
|
46
|
+
export declare function tsToIso(t?: {
|
|
47
|
+
seconds: number | string;
|
|
48
|
+
nanos: number;
|
|
49
|
+
} | null): string | null;
|
|
50
|
+
export declare function cucumberStatus(s: string | undefined): SuiteStatus;
|
|
51
|
+
export declare function runnerStatus(s: string | undefined): SuiteStatus;
|
|
52
|
+
/** Module = first directory after `features/`, else the first directory segment. */
|
|
53
|
+
export declare function moduleFromUri(uri: string): string | null;
|
|
54
|
+
/** Strip playwright-bdd's `[<project>]:` prefix from a feature uri. */
|
|
55
|
+
export declare function splitUri(uri: string): {
|
|
56
|
+
runnerProject: string | null;
|
|
57
|
+
uri: string;
|
|
58
|
+
};
|
|
59
|
+
/** Extract the selector from a Playwright locator error message, if any. */
|
|
60
|
+
export declare function selectorFromError(message: string | undefined | null): string | null;
|
|
61
|
+
export declare function emptyTotals(): RunTotals;
|
|
62
|
+
//# sourceMappingURL=types.d.ts.map
|