@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.
Files changed (87) hide show
  1. package/LICENSE +17 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/col.d.ts +32 -0
  4. package/dist/col.js +58 -0
  5. package/dist/create-db.d.ts +14 -0
  6. package/dist/create-db.js +58 -0
  7. package/dist/driver.d.ts +10 -0
  8. package/dist/driver.js +16 -0
  9. package/dist/ids.d.ts +2 -0
  10. package/dist/ids.js +2 -0
  11. package/dist/index.d.ts +17 -0
  12. package/dist/index.js +22 -0
  13. package/dist/ingest/attachments.d.ts +40 -0
  14. package/dist/ingest/attachments.js +154 -0
  15. package/dist/ingest/cucumber-ingest.d.ts +40 -0
  16. package/dist/ingest/cucumber-ingest.js +543 -0
  17. package/dist/ingest/finalize.d.ts +28 -0
  18. package/dist/ingest/finalize.js +132 -0
  19. package/dist/ingest/index.d.ts +26 -0
  20. package/dist/ingest/index.js +193 -0
  21. package/dist/ingest/message-index.d.ts +64 -0
  22. package/dist/ingest/message-index.js +146 -0
  23. package/dist/ingest/parse-ndjson.d.ts +10 -0
  24. package/dist/ingest/parse-ndjson.js +35 -0
  25. package/dist/ingest/pw-json-ingest.d.ts +14 -0
  26. package/dist/ingest/pw-json-ingest.js +259 -0
  27. package/dist/ingest/runner-json-ingest.d.ts +14 -0
  28. package/dist/ingest/runner-json-ingest.js +259 -0
  29. package/dist/ingest/types.d.ts +62 -0
  30. package/dist/ingest/types.js +89 -0
  31. package/dist/insights.d.ts +62 -0
  32. package/dist/insights.js +184 -0
  33. package/dist/migrate.d.ts +30 -0
  34. package/dist/migrate.js +114 -0
  35. package/dist/migrations/0001_platform_init.d.ts +5 -0
  36. package/dist/migrations/0001_platform_init.js +48 -0
  37. package/dist/migrations/0002_results.d.ts +5 -0
  38. package/dist/migrations/0002_results.js +124 -0
  39. package/dist/migrations/0003_improvement.d.ts +5 -0
  40. package/dist/migrations/0003_improvement.js +56 -0
  41. package/dist/migrations/0004_test_data.d.ts +5 -0
  42. package/dist/migrations/0004_test_data.js +48 -0
  43. package/dist/migrations/0005_integrations_agents_audit.d.ts +5 -0
  44. package/dist/migrations/0005_integrations_agents_audit.js +100 -0
  45. package/dist/migrations/0006_schedules.d.ts +5 -0
  46. package/dist/migrations/0006_schedules.js +34 -0
  47. package/dist/migrations/0007_hierarchy.d.ts +5 -0
  48. package/dist/migrations/0007_hierarchy.js +76 -0
  49. package/dist/migrations/0008_runner_naming.d.ts +14 -0
  50. package/dist/migrations/0008_runner_naming.js +35 -0
  51. package/dist/migrations/helpers.d.ts +15 -0
  52. package/dist/migrations/helpers.js +30 -0
  53. package/dist/migrations/index.d.ts +6 -0
  54. package/dist/migrations/index.js +22 -0
  55. package/dist/repos/auth.d.ts +109 -0
  56. package/dist/repos/auth.js +258 -0
  57. package/dist/repos/hierarchy.d.ts +151 -0
  58. package/dist/repos/hierarchy.js +465 -0
  59. package/dist/repos/improvement.d.ts +84 -0
  60. package/dist/repos/improvement.js +242 -0
  61. package/dist/repos/index.d.ts +8 -0
  62. package/dist/repos/index.js +8 -0
  63. package/dist/repos/platform.d.ts +250 -0
  64. package/dist/repos/platform.js +429 -0
  65. package/dist/repos/projects.d.ts +87 -0
  66. package/dist/repos/projects.js +118 -0
  67. package/dist/repos/runs.d.ts +306 -0
  68. package/dist/repos/runs.js +321 -0
  69. package/dist/repos/test-data.d.ts +84 -0
  70. package/dist/repos/test-data.js +291 -0
  71. package/dist/schema.d.ts +514 -0
  72. package/dist/schema.js +37 -0
  73. package/dist/scopes.d.ts +2 -0
  74. package/dist/scopes.js +2 -0
  75. package/dist/switch/export.d.ts +15 -0
  76. package/dist/switch/export.js +67 -0
  77. package/dist/switch/import.d.ts +16 -0
  78. package/dist/switch/import.js +106 -0
  79. package/dist/switch/index.d.ts +5 -0
  80. package/dist/switch/index.js +5 -0
  81. package/dist/switch/prune.d.ts +19 -0
  82. package/dist/switch/prune.js +50 -0
  83. package/dist/switch/switch.d.ts +34 -0
  84. package/dist/switch/switch.js +133 -0
  85. package/dist/test-data.d.ts +22 -0
  86. package/dist/test-data.js +79 -0
  87. 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