@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,543 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { fingerprint as makeFingerprint, newId, parseRunnerProjectName, } from '@sdods/contracts/ids';
|
|
3
|
+
import { enc, nowIso, readJson } from '../col.js';
|
|
4
|
+
import { insertHealEvent } from '../repos/improvement.js';
|
|
5
|
+
import { artifactKindFor, attachmentBytes, attachmentText, parseAttachmentName, storeFile, targetFileFor, } from './attachments.js';
|
|
6
|
+
import { MessageIndex } from './message-index.js';
|
|
7
|
+
import { cucumberStatus, moduleFromUri, selectorFromError, splitUri, tsToIso, tsToMs, } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* State machine over cucumber message envelopes. Static messages are indexed; dynamic ones are
|
|
10
|
+
* upserted immediately with natural keys so re-ingest converges.
|
|
11
|
+
*/
|
|
12
|
+
export class IngestSession {
|
|
13
|
+
ctx;
|
|
14
|
+
idx = new MessageIndex();
|
|
15
|
+
locatorCounters = new Map();
|
|
16
|
+
counts = {
|
|
17
|
+
scenarios: 0,
|
|
18
|
+
attempts: 0,
|
|
19
|
+
steps: 0,
|
|
20
|
+
artifacts: 0,
|
|
21
|
+
healEvents: 0,
|
|
22
|
+
};
|
|
23
|
+
runStartedAt = null;
|
|
24
|
+
runFinishedAt = null;
|
|
25
|
+
attempts = new Map();
|
|
26
|
+
pendingStepStart = new Map();
|
|
27
|
+
seenScenarios = new Set();
|
|
28
|
+
pendingTestCases = [];
|
|
29
|
+
constructor(ctx) {
|
|
30
|
+
this.ctx = ctx;
|
|
31
|
+
}
|
|
32
|
+
async consume(env) {
|
|
33
|
+
if (env.gherkinDocument)
|
|
34
|
+
this.idx.addDoc(env.gherkinDocument);
|
|
35
|
+
else if (env.pickle)
|
|
36
|
+
this.idx.addPickle(env.pickle);
|
|
37
|
+
else if (env.stepDefinition)
|
|
38
|
+
this.idx.addStepDef(env.stepDefinition);
|
|
39
|
+
else if (env.hook)
|
|
40
|
+
this.idx.addHook(env.hook);
|
|
41
|
+
else if (env.testCase)
|
|
42
|
+
this.idx.addTestCase(env.testCase);
|
|
43
|
+
else if (env.testRunStarted)
|
|
44
|
+
this.runStartedAt = minIso(this.runStartedAt, tsToIso(env.testRunStarted.timestamp));
|
|
45
|
+
else if (env.testCaseStarted)
|
|
46
|
+
await this.onAttemptStarted(env.testCaseStarted);
|
|
47
|
+
else if (env.testStepStarted)
|
|
48
|
+
this.pendingStepStart.set(`${env.testStepStarted.testCaseStartedId}|${env.testStepStarted.testStepId}`, tsToIso(env.testStepStarted.timestamp));
|
|
49
|
+
else if (env.testStepFinished)
|
|
50
|
+
await this.onStepFinished(env.testStepFinished);
|
|
51
|
+
else if (env.attachment)
|
|
52
|
+
await this.onAttachment(env.attachment);
|
|
53
|
+
else if (env.testCaseFinished)
|
|
54
|
+
await this.onAttemptFinished(env.testCaseFinished);
|
|
55
|
+
else if (env.testRunFinished)
|
|
56
|
+
this.runFinishedAt = maxIso(this.runFinishedAt, tsToIso(env.testRunFinished.timestamp));
|
|
57
|
+
}
|
|
58
|
+
async onAttemptStarted(tcs) {
|
|
59
|
+
const testCase = this.idx.testCases.get(tcs.testCaseId);
|
|
60
|
+
if (!testCase)
|
|
61
|
+
return;
|
|
62
|
+
const pickle = this.idx.pickles.get(testCase.pickleId);
|
|
63
|
+
if (!pickle)
|
|
64
|
+
return;
|
|
65
|
+
const { runnerProject, uri } = splitUri(pickle.uri);
|
|
66
|
+
const parts = runnerProject ? parseRunnerProjectName(runnerProject) : null;
|
|
67
|
+
const slug = parts?.project ?? this.ctx.projectSlug;
|
|
68
|
+
const layer = parts?.layer ?? layerFromTags(pickle.tags) ?? 'ui';
|
|
69
|
+
const browser = parts?.browser ?? null;
|
|
70
|
+
const exampleIndex = this.idx.exampleIndex(pickle);
|
|
71
|
+
const scenarioName = this.idx.scenarioTitle(pickle);
|
|
72
|
+
const fp = makeFingerprint({
|
|
73
|
+
project: slug,
|
|
74
|
+
featureUri: uri,
|
|
75
|
+
scenarioName,
|
|
76
|
+
exampleIndex,
|
|
77
|
+
layer,
|
|
78
|
+
});
|
|
79
|
+
const naturalKey = `${runnerProject ?? layer}:${pickle.id}`;
|
|
80
|
+
const attempt = Number(tcs.attempt ?? 0);
|
|
81
|
+
const { db, driver } = this.ctx.adb;
|
|
82
|
+
let scenarioId;
|
|
83
|
+
const existing = await db
|
|
84
|
+
.selectFrom('scenarios')
|
|
85
|
+
.select(['id'])
|
|
86
|
+
.where('run_id', '=', this.ctx.runId)
|
|
87
|
+
.where('natural_key', '=', naturalKey)
|
|
88
|
+
.executeTakeFirst();
|
|
89
|
+
if (existing) {
|
|
90
|
+
scenarioId = existing.id;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
scenarioId = newId();
|
|
94
|
+
await db
|
|
95
|
+
.insertInto('scenarios')
|
|
96
|
+
.values({
|
|
97
|
+
id: scenarioId,
|
|
98
|
+
run_id: this.ctx.runId,
|
|
99
|
+
project_id: this.ctx.projectId,
|
|
100
|
+
natural_key: naturalKey,
|
|
101
|
+
fingerprint: fp,
|
|
102
|
+
source: 'gherkin',
|
|
103
|
+
feature_uri: uri,
|
|
104
|
+
feature_name: this.idx.featureName(pickle.uri) ?? this.idx.featureName(uri),
|
|
105
|
+
scenario_name: exampleIndex ? pickle.name : scenarioName,
|
|
106
|
+
module: moduleFromUri(uri),
|
|
107
|
+
examples_row: exampleIndex,
|
|
108
|
+
runner_project: runnerProject ?? `${slug}--${layer}`,
|
|
109
|
+
layer,
|
|
110
|
+
browser,
|
|
111
|
+
suite_tag: suiteFromTags(pickle.tags),
|
|
112
|
+
tags_json: enc.json(pickle.tags),
|
|
113
|
+
jira_keys_json: enc.json(pickle.tags.filter((t) => t.startsWith('@jira:')).map((t) => t.slice(6))),
|
|
114
|
+
status: 'unknown',
|
|
115
|
+
attempts_count: 0,
|
|
116
|
+
flaky: enc.bool(driver, false),
|
|
117
|
+
duration_ms: null,
|
|
118
|
+
error_message: null,
|
|
119
|
+
error_stack: null,
|
|
120
|
+
started_at: tsToIso(tcs.timestamp),
|
|
121
|
+
finished_at: null,
|
|
122
|
+
created_at: nowIso(),
|
|
123
|
+
updated_at: nowIso(),
|
|
124
|
+
})
|
|
125
|
+
.execute();
|
|
126
|
+
if (!this.seenScenarios.has(naturalKey))
|
|
127
|
+
this.counts.scenarios++;
|
|
128
|
+
}
|
|
129
|
+
this.seenScenarios.add(naturalKey);
|
|
130
|
+
let attemptId;
|
|
131
|
+
const existingAttempt = await db
|
|
132
|
+
.selectFrom('scenario_attempts')
|
|
133
|
+
.select(['id'])
|
|
134
|
+
.where('run_id', '=', this.ctx.runId)
|
|
135
|
+
.where('test_case_started_id', '=', tcs.id)
|
|
136
|
+
.executeTakeFirst();
|
|
137
|
+
if (existingAttempt) {
|
|
138
|
+
attemptId = existingAttempt.id;
|
|
139
|
+
await db
|
|
140
|
+
.updateTable('scenario_attempts')
|
|
141
|
+
.set({ started_at: tsToIso(tcs.timestamp), attempt })
|
|
142
|
+
.where('id', '=', attemptId)
|
|
143
|
+
.execute();
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
attemptId = newId();
|
|
147
|
+
await db
|
|
148
|
+
.insertInto('scenario_attempts')
|
|
149
|
+
.values({
|
|
150
|
+
id: attemptId,
|
|
151
|
+
scenario_id: scenarioId,
|
|
152
|
+
run_id: this.ctx.runId,
|
|
153
|
+
attempt,
|
|
154
|
+
test_case_started_id: tcs.id,
|
|
155
|
+
status: 'unknown',
|
|
156
|
+
duration_ms: null,
|
|
157
|
+
error_message: null,
|
|
158
|
+
error_stack: null,
|
|
159
|
+
will_be_retried: enc.bool(driver, false),
|
|
160
|
+
worker_index: tcs.workerId != null ? Number(tcs.workerId) || null : null,
|
|
161
|
+
started_at: tsToIso(tcs.timestamp),
|
|
162
|
+
finished_at: null,
|
|
163
|
+
created_at: nowIso(),
|
|
164
|
+
})
|
|
165
|
+
.execute();
|
|
166
|
+
this.counts.attempts++;
|
|
167
|
+
}
|
|
168
|
+
const stepIndexByTestStepId = new Map();
|
|
169
|
+
for (const ts of testCase.testSteps) {
|
|
170
|
+
if (ts.pickleStepId) {
|
|
171
|
+
const i = pickle.steps.findIndex((s) => s.id === ts.pickleStepId);
|
|
172
|
+
stepIndexByTestStepId.set(ts.id, i);
|
|
173
|
+
}
|
|
174
|
+
else
|
|
175
|
+
stepIndexByTestStepId.set(ts.id, -1);
|
|
176
|
+
}
|
|
177
|
+
this.attempts.set(tcs.id, {
|
|
178
|
+
scenarioId,
|
|
179
|
+
attemptId,
|
|
180
|
+
attempt,
|
|
181
|
+
fingerprint: fp,
|
|
182
|
+
slug,
|
|
183
|
+
dir: join(this.ctx.runDir, slug, fp, `r${attempt}`),
|
|
184
|
+
pickle,
|
|
185
|
+
testCaseId: testCase.id,
|
|
186
|
+
stepIndexByTestStepId,
|
|
187
|
+
stepIdByTestStepId: new Map(),
|
|
188
|
+
statuses: [],
|
|
189
|
+
startedAtIso: tsToIso(tcs.timestamp),
|
|
190
|
+
lastStepEndIso: null,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
async onStepFinished(tsf) {
|
|
194
|
+
const a = this.attempts.get(tsf.testCaseStartedId);
|
|
195
|
+
if (!a)
|
|
196
|
+
return;
|
|
197
|
+
const testCase = this.idx.testCases.get(a.testCaseId);
|
|
198
|
+
const testStep = testCase?.testSteps.find((s) => s.id === tsf.testStepId);
|
|
199
|
+
if (!testStep)
|
|
200
|
+
return;
|
|
201
|
+
const { db } = this.ctx.adb;
|
|
202
|
+
const result = tsf.testStepResult ?? {};
|
|
203
|
+
const status = cucumberStatus(result.status);
|
|
204
|
+
const message = result.exception?.message ?? firstLine(result.message) ?? null;
|
|
205
|
+
const stack = result.exception?.stackTrace ?? result.message ?? null;
|
|
206
|
+
a.statuses.push(status);
|
|
207
|
+
if (status === 'failed' && !a.firstError)
|
|
208
|
+
a.firstError = { message, stack };
|
|
209
|
+
const startedAt = this.pendingStepStart.get(`${tsf.testCaseStartedId}|${tsf.testStepId}`) ?? null;
|
|
210
|
+
const finishedAt = tsToIso(tsf.timestamp);
|
|
211
|
+
a.lastStepEndIso = maxIso(a.lastStepEndIso, finishedAt);
|
|
212
|
+
const durationMs = tsToMs(result.duration);
|
|
213
|
+
let stepIndex = -1;
|
|
214
|
+
let kind = 'hook';
|
|
215
|
+
let hookType = null;
|
|
216
|
+
let keyword = null;
|
|
217
|
+
let text = '';
|
|
218
|
+
let argument = null;
|
|
219
|
+
let layerHint = null;
|
|
220
|
+
if (testStep.pickleStepId) {
|
|
221
|
+
kind = 'step';
|
|
222
|
+
stepIndex = a.stepIndexByTestStepId.get(testStep.id) ?? -1;
|
|
223
|
+
const ps = a.pickle.steps.find((s) => s.id === testStep.pickleStepId);
|
|
224
|
+
if (ps) {
|
|
225
|
+
keyword = this.idx.stepKeyword(ps);
|
|
226
|
+
text = ps.text;
|
|
227
|
+
argument = ps.argument ?? null;
|
|
228
|
+
layerHint = /\b(request|response|API|endpoint|JSON)\b/i.test(ps.text)
|
|
229
|
+
? 'api'
|
|
230
|
+
: /\b(page|click|navigate|see|fill|visible|URL|button|field)\b/i.test(ps.text)
|
|
231
|
+
? 'ui'
|
|
232
|
+
: null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else if (testStep.hookId) {
|
|
236
|
+
hookType = this.idx.hookType(testStep.hookId);
|
|
237
|
+
text = this.idx.hookName(testStep.hookId);
|
|
238
|
+
}
|
|
239
|
+
if (status === 'failed') {
|
|
240
|
+
const selector = selectorFromError(stack ?? message);
|
|
241
|
+
if (selector) {
|
|
242
|
+
const c = this.locatorCounters.get(selector) ?? { fails: 0, heals: 0, uses: 0 };
|
|
243
|
+
c.fails++;
|
|
244
|
+
c.uses++;
|
|
245
|
+
this.locatorCounters.set(selector, c);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const existing = await db
|
|
249
|
+
.selectFrom('steps')
|
|
250
|
+
.select(['id'])
|
|
251
|
+
.where('attempt_id', '=', a.attemptId)
|
|
252
|
+
.where('test_step_id', '=', testStep.id)
|
|
253
|
+
.executeTakeFirst();
|
|
254
|
+
const values = {
|
|
255
|
+
step_index: stepIndex,
|
|
256
|
+
kind,
|
|
257
|
+
hook_type: hookType,
|
|
258
|
+
keyword,
|
|
259
|
+
text,
|
|
260
|
+
argument_json: argument ? enc.json(argument) : null,
|
|
261
|
+
status,
|
|
262
|
+
duration_ms: durationMs,
|
|
263
|
+
error_message: message,
|
|
264
|
+
error_stack: stack,
|
|
265
|
+
definition_location: this.idx.definitionLocation(testStep.stepDefinitionIds),
|
|
266
|
+
pickle_step_id: testStep.pickleStepId ?? null,
|
|
267
|
+
layer_hint: layerHint,
|
|
268
|
+
started_at: startedAt,
|
|
269
|
+
finished_at: finishedAt,
|
|
270
|
+
};
|
|
271
|
+
let stepId;
|
|
272
|
+
if (existing) {
|
|
273
|
+
stepId = existing.id;
|
|
274
|
+
await db.updateTable('steps').set(values).where('id', '=', stepId).execute();
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
stepId = newId();
|
|
278
|
+
await db
|
|
279
|
+
.insertInto('steps')
|
|
280
|
+
.values({
|
|
281
|
+
id: stepId,
|
|
282
|
+
attempt_id: a.attemptId,
|
|
283
|
+
scenario_id: a.scenarioId,
|
|
284
|
+
run_id: this.ctx.runId,
|
|
285
|
+
test_step_id: testStep.id,
|
|
286
|
+
api_snapshot_json: null,
|
|
287
|
+
perf_json: null,
|
|
288
|
+
created_at: nowIso(),
|
|
289
|
+
...values,
|
|
290
|
+
})
|
|
291
|
+
.execute();
|
|
292
|
+
this.counts.steps++;
|
|
293
|
+
}
|
|
294
|
+
a.stepIdByTestStepId.set(testStep.id, stepId);
|
|
295
|
+
// attachments for this step may have arrived before testStepFinished (playwright-bdd emits them in between)
|
|
296
|
+
const key = `${a.attemptId}|${stepIndex}`;
|
|
297
|
+
const pending = this.pendingPayloads.get(key);
|
|
298
|
+
if (pending) {
|
|
299
|
+
this.pendingPayloads.delete(key);
|
|
300
|
+
for (const p of pending)
|
|
301
|
+
await this.applyStructured(a, stepId, p.parsed, p.json);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
pendingPayloads = new Map();
|
|
305
|
+
/** Apply a structured (JSON) attachment to its step row / heal table. */
|
|
306
|
+
async applyStructured(a, stepId, parsed, json) {
|
|
307
|
+
const { db, driver } = this.ctx.adb;
|
|
308
|
+
if (parsed.kind === 'api' && stepId) {
|
|
309
|
+
const existing = await db
|
|
310
|
+
.selectFrom('steps')
|
|
311
|
+
.select(['api_snapshot_json'])
|
|
312
|
+
.where('id', '=', stepId)
|
|
313
|
+
.executeTakeFirst();
|
|
314
|
+
const prev = readJson(existing?.api_snapshot_json);
|
|
315
|
+
await db
|
|
316
|
+
.updateTable('steps')
|
|
317
|
+
.set({
|
|
318
|
+
api_snapshot_json: enc.json(mergeSnapshot(prev, json, parsed.part)),
|
|
319
|
+
layer_hint: 'api',
|
|
320
|
+
})
|
|
321
|
+
.where('id', '=', stepId)
|
|
322
|
+
.execute();
|
|
323
|
+
}
|
|
324
|
+
else if (parsed.kind === 'perf' && stepId) {
|
|
325
|
+
await db
|
|
326
|
+
.updateTable('steps')
|
|
327
|
+
.set({ perf_json: enc.json(json) })
|
|
328
|
+
.where('id', '=', stepId)
|
|
329
|
+
.execute();
|
|
330
|
+
}
|
|
331
|
+
else if (parsed.kind === 'heal') {
|
|
332
|
+
const ev = json;
|
|
333
|
+
await insertHealEvent(db, driver, {
|
|
334
|
+
projectId: this.ctx.projectId,
|
|
335
|
+
runId: this.ctx.runId,
|
|
336
|
+
scenarioId: a.scenarioId,
|
|
337
|
+
attemptId: a.attemptId,
|
|
338
|
+
stepId,
|
|
339
|
+
event: { ...ev, fingerprint: ev.fingerprint ?? a.fingerprint, runId: this.ctx.runId },
|
|
340
|
+
});
|
|
341
|
+
this.counts.healEvents++;
|
|
342
|
+
const key = ev.originalSelector ?? 'unknown';
|
|
343
|
+
const c = this.locatorCounters.get(key) ?? { fails: 0, heals: 0, uses: 0 };
|
|
344
|
+
c.heals++;
|
|
345
|
+
c.uses++;
|
|
346
|
+
c.lastStrategy = ev.strategyUsed ?? null;
|
|
347
|
+
c.suggested = ev.healedSelector ?? null;
|
|
348
|
+
this.locatorCounters.set(key, c);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async onAttachment(att) {
|
|
352
|
+
const a = this.attempts.get(att.testCaseStartedId);
|
|
353
|
+
if (!a)
|
|
354
|
+
return;
|
|
355
|
+
const { db, driver } = this.ctx.adb;
|
|
356
|
+
const input = {
|
|
357
|
+
fileName: att.fileName ?? 'attachment',
|
|
358
|
+
mediaType: att.mediaType ?? 'application/octet-stream',
|
|
359
|
+
body: att.body,
|
|
360
|
+
contentEncoding: att.contentEncoding,
|
|
361
|
+
url: att.url,
|
|
362
|
+
};
|
|
363
|
+
const parsed = parseAttachmentName(input.fileName);
|
|
364
|
+
const stepId = att.testStepId ? (a.stepIdByTestStepId.get(att.testStepId) ?? null) : null;
|
|
365
|
+
const stepIndexFromMsg = att.testStepId
|
|
366
|
+
? (a.stepIndexByTestStepId.get(att.testStepId) ?? null)
|
|
367
|
+
: null;
|
|
368
|
+
// structured JSON attachments (applied now, or buffered until the step row exists)
|
|
369
|
+
if (parsed.kind === 'api' ||
|
|
370
|
+
parsed.kind === 'perf' ||
|
|
371
|
+
parsed.kind === 'heal' ||
|
|
372
|
+
parsed.kind === 'meta') {
|
|
373
|
+
const json = safeJson(attachmentText(input));
|
|
374
|
+
if (parsed.kind === 'meta' && json) {
|
|
375
|
+
const meta = json;
|
|
376
|
+
if (meta.dir)
|
|
377
|
+
a.dir = meta.dir;
|
|
378
|
+
// The runtime resolved the module from the project config (moduleForFeature), which is
|
|
379
|
+
// authoritative; the scenario row was inserted earlier with moduleFromUri(), a directory
|
|
380
|
+
// guess that is wrong whenever a module's `path` differs from its `name` (demo-shop's
|
|
381
|
+
// `posts-api` lives in features/api/, so the guess yields "api" and never joins to
|
|
382
|
+
// modules.name). Correct it now that the meta attachment has arrived.
|
|
383
|
+
if (meta.module) {
|
|
384
|
+
await this.ctx.adb.db
|
|
385
|
+
.updateTable('scenarios')
|
|
386
|
+
.set({ module: meta.module })
|
|
387
|
+
.where('id', '=', a.scenarioId)
|
|
388
|
+
.execute();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else if (json) {
|
|
392
|
+
const stepIndex = parsed.stepIndex;
|
|
393
|
+
const targetStep = await this.stepRowByIndex(a, stepIndex);
|
|
394
|
+
if (targetStep)
|
|
395
|
+
await this.applyStructured(a, targetStep, parsed, json);
|
|
396
|
+
else {
|
|
397
|
+
const key = `${a.attemptId}|${stepIndex}`;
|
|
398
|
+
const list = this.pendingPayloads.get(key) ?? [];
|
|
399
|
+
list.push({ parsed, json });
|
|
400
|
+
this.pendingPayloads.set(key, list);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
void driver;
|
|
405
|
+
// file artifact (also for json kinds so they are downloadable)
|
|
406
|
+
const file = targetFileFor(parsed, input.fileName, input.mediaType);
|
|
407
|
+
const stored = storeFile(this.ctx.artifactsRoot, a.dir, file, attachmentBytes(input), input.url);
|
|
408
|
+
if (!stored)
|
|
409
|
+
return;
|
|
410
|
+
const { kind, phase, stepIndex } = artifactKindFor(parsed, input.mediaType);
|
|
411
|
+
const existing = await db
|
|
412
|
+
.selectFrom('artifacts')
|
|
413
|
+
.select(['id'])
|
|
414
|
+
.where('rel_path', '=', stored.relPath)
|
|
415
|
+
.executeTakeFirst();
|
|
416
|
+
const values = {
|
|
417
|
+
run_id: this.ctx.runId,
|
|
418
|
+
scenario_id: a.scenarioId,
|
|
419
|
+
attempt_id: a.attemptId,
|
|
420
|
+
step_id: stepId,
|
|
421
|
+
step_index: stepIndex ?? stepIndexFromMsg,
|
|
422
|
+
kind,
|
|
423
|
+
phase,
|
|
424
|
+
media_type: input.mediaType,
|
|
425
|
+
file_name: input.fileName,
|
|
426
|
+
size_bytes: stored.sizeBytes,
|
|
427
|
+
sha256: stored.sha256,
|
|
428
|
+
width: stored.width,
|
|
429
|
+
height: stored.height,
|
|
430
|
+
meta_json: enc.json({ original: input.fileName }),
|
|
431
|
+
};
|
|
432
|
+
if (existing) {
|
|
433
|
+
await db.updateTable('artifacts').set(values).where('id', '=', existing.id).execute();
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
await db
|
|
437
|
+
.insertInto('artifacts')
|
|
438
|
+
.values({ id: newId(), rel_path: stored.relPath, created_at: nowIso(), ...values })
|
|
439
|
+
.execute();
|
|
440
|
+
this.counts.artifacts++;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async onAttemptFinished(tcf) {
|
|
444
|
+
const a = this.attempts.get(tcf.testCaseStartedId);
|
|
445
|
+
if (!a)
|
|
446
|
+
return;
|
|
447
|
+
const { db, driver } = this.ctx.adb;
|
|
448
|
+
const status = a.statuses.includes('failed')
|
|
449
|
+
? 'failed'
|
|
450
|
+
: a.statuses.some((s) => s === 'passed')
|
|
451
|
+
? 'passed'
|
|
452
|
+
: a.statuses.length
|
|
453
|
+
? 'skipped'
|
|
454
|
+
: 'unknown';
|
|
455
|
+
const finishedAt = tsToIso(tcf.timestamp) ?? a.lastStepEndIso;
|
|
456
|
+
const durationMs = a.startedAtIso && finishedAt
|
|
457
|
+
? Math.max(0, Date.parse(finishedAt) - Date.parse(a.startedAtIso))
|
|
458
|
+
: null;
|
|
459
|
+
await db
|
|
460
|
+
.updateTable('scenario_attempts')
|
|
461
|
+
.set({
|
|
462
|
+
status,
|
|
463
|
+
duration_ms: durationMs,
|
|
464
|
+
error_message: a.firstError?.message ?? null,
|
|
465
|
+
error_stack: a.firstError?.stack ?? null,
|
|
466
|
+
will_be_retried: enc.bool(driver, Boolean(tcf.willBeRetried)),
|
|
467
|
+
finished_at: finishedAt,
|
|
468
|
+
})
|
|
469
|
+
.where('id', '=', a.attemptId)
|
|
470
|
+
.execute();
|
|
471
|
+
this.runFinishedAt = maxIso(this.runFinishedAt, finishedAt);
|
|
472
|
+
this.runStartedAt = minIso(this.runStartedAt, a.startedAtIso);
|
|
473
|
+
}
|
|
474
|
+
async stepRowByIndex(a, stepIndex) {
|
|
475
|
+
for (const [testStepId, idx] of a.stepIndexByTestStepId) {
|
|
476
|
+
if (idx === stepIndex) {
|
|
477
|
+
const known = a.stepIdByTestStepId.get(testStepId);
|
|
478
|
+
if (known)
|
|
479
|
+
return known;
|
|
480
|
+
const row = await this.ctx.adb.db
|
|
481
|
+
.selectFrom('steps')
|
|
482
|
+
.select(['id'])
|
|
483
|
+
.where('attempt_id', '=', a.attemptId)
|
|
484
|
+
.where('test_step_id', '=', testStepId)
|
|
485
|
+
.executeTakeFirst();
|
|
486
|
+
return row?.id ?? null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function mergeSnapshot(prev, part, which) {
|
|
493
|
+
// An attachment may carry the whole snapshot or just one half.
|
|
494
|
+
if (part && typeof part === 'object' && 'request' in part && 'response' in part)
|
|
495
|
+
return part;
|
|
496
|
+
return {
|
|
497
|
+
...(prev ?? {}),
|
|
498
|
+
[which]: part,
|
|
499
|
+
startedAt: prev?.startedAt ?? new Date().toISOString(),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function safeJson(text) {
|
|
503
|
+
if (!text)
|
|
504
|
+
return null;
|
|
505
|
+
try {
|
|
506
|
+
return JSON.parse(text);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function firstLine(s) {
|
|
513
|
+
if (typeof s !== 'string' || !s)
|
|
514
|
+
return null;
|
|
515
|
+
return s.split('\n')[0].slice(0, 2000);
|
|
516
|
+
}
|
|
517
|
+
function minIso(a, b) {
|
|
518
|
+
if (!a)
|
|
519
|
+
return b;
|
|
520
|
+
if (!b)
|
|
521
|
+
return a;
|
|
522
|
+
return Date.parse(a) <= Date.parse(b) ? a : b;
|
|
523
|
+
}
|
|
524
|
+
function maxIso(a, b) {
|
|
525
|
+
if (!a)
|
|
526
|
+
return b;
|
|
527
|
+
if (!b)
|
|
528
|
+
return a;
|
|
529
|
+
return Date.parse(a) >= Date.parse(b) ? a : b;
|
|
530
|
+
}
|
|
531
|
+
export function layerFromTags(tags) {
|
|
532
|
+
if (tags.includes('@api'))
|
|
533
|
+
return 'api';
|
|
534
|
+
if (tags.includes('@hybrid'))
|
|
535
|
+
return 'hybrid';
|
|
536
|
+
if (tags.includes('@ui'))
|
|
537
|
+
return 'ui';
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
export function suiteFromTags(tags) {
|
|
541
|
+
return tags.find((t) => ['@smoke', '@regression', '@sanity'].includes(t)) ?? null;
|
|
542
|
+
}
|
|
543
|
+
//# sourceMappingURL=cucumber-ingest.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { RunTotals } from '@sdods/contracts/types';
|
|
2
|
+
import type { SdodsDb } from '../create-db.js';
|
|
3
|
+
export interface LocatorCounter {
|
|
4
|
+
fails: number;
|
|
5
|
+
heals: number;
|
|
6
|
+
uses: number;
|
|
7
|
+
lastStrategy?: string | null;
|
|
8
|
+
suggested?: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface FinalizeInput {
|
|
11
|
+
adb: SdodsDb;
|
|
12
|
+
runId: string;
|
|
13
|
+
projectId: string;
|
|
14
|
+
locatorCounters: Map<string, LocatorCounter>;
|
|
15
|
+
runStartedAt?: string | null;
|
|
16
|
+
runFinishedAt?: string | null;
|
|
17
|
+
ingestedFiles: string[];
|
|
18
|
+
exitCode?: number | null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Derive scenario outcomes from attempts (count scenarios, not attempts), run totals/status,
|
|
22
|
+
* flaky stats windows and locator stats. Safe to re-run.
|
|
23
|
+
*/
|
|
24
|
+
export declare function finalizeRun(input: FinalizeInput): Promise<{
|
|
25
|
+
totals: RunTotals;
|
|
26
|
+
status: string;
|
|
27
|
+
}>;
|
|
28
|
+
//# sourceMappingURL=finalize.d.ts.map
|