@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,132 @@
|
|
|
1
|
+
import { enc, nowIso, readBool, readJson, readTs } from '../col.js';
|
|
2
|
+
import { bumpLocatorStats, recomputeFlakyStats } from '../repos/improvement.js';
|
|
3
|
+
import { emptyTotals } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Derive scenario outcomes from attempts (count scenarios, not attempts), run totals/status,
|
|
6
|
+
* flaky stats windows and locator stats. Safe to re-run.
|
|
7
|
+
*/
|
|
8
|
+
export async function finalizeRun(input) {
|
|
9
|
+
const { adb, runId, projectId } = input;
|
|
10
|
+
const { db, driver } = adb;
|
|
11
|
+
const scenarios = await db
|
|
12
|
+
.selectFrom('scenarios')
|
|
13
|
+
.selectAll()
|
|
14
|
+
.where('run_id', '=', runId)
|
|
15
|
+
.execute();
|
|
16
|
+
const attempts = await db
|
|
17
|
+
.selectFrom('scenario_attempts')
|
|
18
|
+
.selectAll()
|
|
19
|
+
.where('run_id', '=', runId)
|
|
20
|
+
.execute();
|
|
21
|
+
const heals = await db
|
|
22
|
+
.selectFrom('heal_events')
|
|
23
|
+
.select(['scenario_id'])
|
|
24
|
+
.where('run_id', '=', runId)
|
|
25
|
+
.execute();
|
|
26
|
+
const healedScenarios = new Set(heals.map((h) => h.scenario_id).filter(Boolean));
|
|
27
|
+
const totals = emptyTotals();
|
|
28
|
+
let minStart = null;
|
|
29
|
+
let maxEnd = null;
|
|
30
|
+
for (const s of scenarios) {
|
|
31
|
+
const mine = attempts
|
|
32
|
+
.filter((a) => a.scenario_id === s.id)
|
|
33
|
+
.sort((a, b) => a.attempt - b.attempt);
|
|
34
|
+
const last = mine[mine.length - 1];
|
|
35
|
+
const finalStatus = last?.status ?? s.status ?? 'unknown';
|
|
36
|
+
const earlierFailed = mine
|
|
37
|
+
.slice(0, -1)
|
|
38
|
+
.some((a) => a.status === 'failed' || a.status === 'timedOut');
|
|
39
|
+
const flaky = mine.length > 1 && finalStatus === 'passed' && earlierFailed;
|
|
40
|
+
const startedAt = mine[0] ? readTs(mine[0].started_at) : readTs(s.started_at);
|
|
41
|
+
const finishedAt = last ? readTs(last.finished_at) : readTs(s.finished_at);
|
|
42
|
+
await db
|
|
43
|
+
.updateTable('scenarios')
|
|
44
|
+
.set({
|
|
45
|
+
status: finalStatus,
|
|
46
|
+
attempts_count: mine.length,
|
|
47
|
+
flaky: enc.bool(driver, flaky),
|
|
48
|
+
duration_ms: last?.duration_ms ?? s.duration_ms ?? null,
|
|
49
|
+
error_message: finalStatus === 'passed' ? null : (last?.error_message ?? s.error_message ?? null),
|
|
50
|
+
error_stack: finalStatus === 'passed' ? null : (last?.error_stack ?? s.error_stack ?? null),
|
|
51
|
+
started_at: startedAt,
|
|
52
|
+
finished_at: finishedAt,
|
|
53
|
+
updated_at: nowIso(),
|
|
54
|
+
})
|
|
55
|
+
.where('id', '=', s.id)
|
|
56
|
+
.execute();
|
|
57
|
+
totals.total++;
|
|
58
|
+
if (flaky)
|
|
59
|
+
totals.flaky++;
|
|
60
|
+
else if (finalStatus === 'passed')
|
|
61
|
+
totals.passed++;
|
|
62
|
+
else if (finalStatus === 'failed')
|
|
63
|
+
totals.failed++;
|
|
64
|
+
else if (finalStatus === 'timedOut')
|
|
65
|
+
totals.timedOut++;
|
|
66
|
+
else
|
|
67
|
+
totals.skipped++;
|
|
68
|
+
if (healedScenarios.has(s.id))
|
|
69
|
+
totals.healed++;
|
|
70
|
+
if (startedAt)
|
|
71
|
+
minStart =
|
|
72
|
+
minStart === null ? Date.parse(startedAt) : Math.min(minStart, Date.parse(startedAt));
|
|
73
|
+
if (finishedAt)
|
|
74
|
+
maxEnd = maxEnd === null ? Date.parse(finishedAt) : Math.max(maxEnd, Date.parse(finishedAt));
|
|
75
|
+
}
|
|
76
|
+
const started = input.runStartedAt ? Date.parse(input.runStartedAt) : minStart;
|
|
77
|
+
const finished = input.runFinishedAt ? Date.parse(input.runFinishedAt) : maxEnd;
|
|
78
|
+
totals.durationMs =
|
|
79
|
+
started !== null && finished !== null && started !== undefined && finished !== undefined
|
|
80
|
+
? Math.max(0, finished - started)
|
|
81
|
+
: 0;
|
|
82
|
+
const status = totals.failed + totals.timedOut > 0 ? 'failed' : totals.total > 0 ? 'passed' : 'error';
|
|
83
|
+
const existing = await db
|
|
84
|
+
.selectFrom('runs')
|
|
85
|
+
.select(['totals_json'])
|
|
86
|
+
.where('id', '=', runId)
|
|
87
|
+
.executeTakeFirst();
|
|
88
|
+
const prev = readJson(existing?.totals_json) ?? {};
|
|
89
|
+
const files = new Set([
|
|
90
|
+
...(Array.isArray(prev.ingested_files) ? prev.ingested_files : []),
|
|
91
|
+
...input.ingestedFiles,
|
|
92
|
+
]);
|
|
93
|
+
await db
|
|
94
|
+
.updateTable('runs')
|
|
95
|
+
.set({
|
|
96
|
+
status,
|
|
97
|
+
totals_json: enc.json({
|
|
98
|
+
...prev,
|
|
99
|
+
...totals,
|
|
100
|
+
ingested_files: [...files],
|
|
101
|
+
ingestedAt: nowIso(),
|
|
102
|
+
}),
|
|
103
|
+
shards_ingested: files.size,
|
|
104
|
+
started_at: started !== null && started !== undefined ? new Date(started).toISOString() : null,
|
|
105
|
+
finished_at: finished !== null && finished !== undefined ? new Date(finished).toISOString() : null,
|
|
106
|
+
duration_ms: totals.durationMs,
|
|
107
|
+
exit_code: input.exitCode ?? (status === 'passed' ? 0 : 1),
|
|
108
|
+
updated_at: nowIso(),
|
|
109
|
+
})
|
|
110
|
+
.where('id', '=', runId)
|
|
111
|
+
.execute();
|
|
112
|
+
// flaky stats over the window for every scenario key touched by this run
|
|
113
|
+
const keys = new Set(scenarios.map((s) => `${s.fingerprint}|${s.runner_project}`));
|
|
114
|
+
for (const key of keys) {
|
|
115
|
+
const [fingerprint, runnerProject] = key.split('|');
|
|
116
|
+
await recomputeFlakyStats(db, driver, { projectId, fingerprint, runnerProject });
|
|
117
|
+
}
|
|
118
|
+
// locator stats
|
|
119
|
+
for (const [selector, c] of input.locatorCounters) {
|
|
120
|
+
await bumpLocatorStats(db, projectId, {
|
|
121
|
+
selector,
|
|
122
|
+
fails: c.fails,
|
|
123
|
+
heals: c.heals,
|
|
124
|
+
uses: c.uses,
|
|
125
|
+
lastStrategy: c.lastStrategy ?? null,
|
|
126
|
+
suggestedSelector: c.suggested ?? null,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
void readBool;
|
|
130
|
+
return { totals, status };
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=finalize.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RunManifest } from '@sdods/contracts/types';
|
|
2
|
+
import type { SdodsDb } from '../create-db.js';
|
|
3
|
+
import type { IngestResult, IngestRunOptions } from './types.js';
|
|
4
|
+
export * from './types.js';
|
|
5
|
+
export * from './parse-ndjson.js';
|
|
6
|
+
export * from './message-index.js';
|
|
7
|
+
export * from './attachments.js';
|
|
8
|
+
export * from './cucumber-ingest.js';
|
|
9
|
+
export * from './runner-json-ingest.js';
|
|
10
|
+
export * from './finalize.js';
|
|
11
|
+
/** Read `<runDir>/run.json` when present. */
|
|
12
|
+
export declare function readManifest(runDir: string): RunManifest | null;
|
|
13
|
+
/**
|
|
14
|
+
* Discover result files inside a run directory. Run directories outlive releases, so the
|
|
15
|
+
* pre-rename `pw-results*.json` name is still accepted alongside `runner-results*.json`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function discoverRunFiles(runDir: string): {
|
|
18
|
+
ndjson: string[];
|
|
19
|
+
runnerJson: string[];
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Ingest one run (all its NDJSON shards and runner JSON files) into the database.
|
|
23
|
+
* Idempotent: natural keys converge on re-run; `replace` wipes the run's children first.
|
|
24
|
+
*/
|
|
25
|
+
export declare function ingestRun(adb: SdodsDb, opts: IngestRunOptions): Promise<IngestResult>;
|
|
26
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, join, resolve } from 'node:path';
|
|
3
|
+
import { runFiles } from '@sdods/contracts/names';
|
|
4
|
+
import { readJson } from '../col.js';
|
|
5
|
+
import { ensureProject, getProjectBySlug } from '../repos/projects.js';
|
|
6
|
+
import { deleteRunChildren, upsertRun } from '../repos/runs.js';
|
|
7
|
+
import { IngestSession } from './cucumber-ingest.js';
|
|
8
|
+
import { finalizeRun } from './finalize.js';
|
|
9
|
+
import { fileSha256, parseNdjson } from './parse-ndjson.js';
|
|
10
|
+
import { ingestRunnerJson } from './runner-json-ingest.js';
|
|
11
|
+
export * from './types.js';
|
|
12
|
+
export * from './parse-ndjson.js';
|
|
13
|
+
export * from './message-index.js';
|
|
14
|
+
export * from './attachments.js';
|
|
15
|
+
export * from './cucumber-ingest.js';
|
|
16
|
+
export * from './runner-json-ingest.js';
|
|
17
|
+
export * from './finalize.js';
|
|
18
|
+
/** Read `<runDir>/run.json` when present. */
|
|
19
|
+
export function readManifest(runDir) {
|
|
20
|
+
const file = join(runDir, runFiles.manifest);
|
|
21
|
+
if (!existsSync(file))
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Discover result files inside a run directory. Run directories outlive releases, so the
|
|
32
|
+
* pre-rename `pw-results*.json` name is still accepted alongside `runner-results*.json`.
|
|
33
|
+
*/
|
|
34
|
+
export function discoverRunFiles(runDir) {
|
|
35
|
+
const ndjson = [];
|
|
36
|
+
const runnerJson = [];
|
|
37
|
+
if (!existsSync(runDir))
|
|
38
|
+
return { ndjson, runnerJson };
|
|
39
|
+
for (const f of readdirSync(runDir)) {
|
|
40
|
+
if (/^messages(\.shard-\d+)?\.ndjson$/.test(f))
|
|
41
|
+
ndjson.push(join(runDir, f));
|
|
42
|
+
if (/^(runner|pw)-results(\.shard-\d+)?\.json$/.test(f))
|
|
43
|
+
runnerJson.push(join(runDir, f));
|
|
44
|
+
}
|
|
45
|
+
return { ndjson: ndjson.sort(), runnerJson: runnerJson.sort() };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Ingest one run (all its NDJSON shards and runner JSON files) into the database.
|
|
49
|
+
* Idempotent: natural keys converge on re-run; `replace` wipes the run's children first.
|
|
50
|
+
*/
|
|
51
|
+
export async function ingestRun(adb, opts) {
|
|
52
|
+
const artifactsRoot = resolve(opts.artifactsRoot);
|
|
53
|
+
const runDir = join(artifactsRoot, opts.runId);
|
|
54
|
+
const manifest = opts.manifest === undefined ? readManifest(runDir) : opts.manifest;
|
|
55
|
+
const projectSlug = manifest?.projectSlug ?? opts.projectSlug;
|
|
56
|
+
if (!projectSlug)
|
|
57
|
+
throw new Error('ingestRun needs a project slug (manifest run.json or --project).');
|
|
58
|
+
const discovered = discoverRunFiles(runDir);
|
|
59
|
+
const ndjsonPaths = (opts.ndjsonPaths?.length ? opts.ndjsonPaths : discovered.ndjson).map((p) => resolve(p));
|
|
60
|
+
const runnerJsonPaths = (opts.runnerJsonPaths?.length ? opts.runnerJsonPaths : discovered.runnerJson).map((p) => resolve(p));
|
|
61
|
+
if (ndjsonPaths.length === 0 && runnerJsonPaths.length === 0)
|
|
62
|
+
throw new Error(`No messages*.ndjson or runner-results*.json found for run ${opts.runId} under ${runDir}.`);
|
|
63
|
+
const { db, driver } = adb;
|
|
64
|
+
const project = (await getProjectBySlug(db, projectSlug)) ?? {
|
|
65
|
+
id: await ensureProject(db, driver, projectSlug),
|
|
66
|
+
workspaceId: null,
|
|
67
|
+
};
|
|
68
|
+
const ctx = {
|
|
69
|
+
adb,
|
|
70
|
+
runId: opts.runId,
|
|
71
|
+
projectId: project.id,
|
|
72
|
+
projectSlug,
|
|
73
|
+
artifactsRoot,
|
|
74
|
+
runDir,
|
|
75
|
+
workspaceId: project.workspaceId ?? null,
|
|
76
|
+
};
|
|
77
|
+
const existingRun = await db
|
|
78
|
+
.selectFrom('runs')
|
|
79
|
+
.select(['totals_json'])
|
|
80
|
+
.where('id', '=', opts.runId)
|
|
81
|
+
.executeTakeFirst();
|
|
82
|
+
const prevTotals = readJson(existingRun?.totals_json) ?? {};
|
|
83
|
+
const alreadyIngested = new Set(Array.isArray(prevTotals.ingested_files) ? prevTotals.ingested_files : []);
|
|
84
|
+
await upsertRun(db, driver, {
|
|
85
|
+
id: opts.runId,
|
|
86
|
+
projectId: project.id,
|
|
87
|
+
workspaceId: ctx.workspaceId,
|
|
88
|
+
envName: manifest?.env ?? opts.env ?? 'unknown',
|
|
89
|
+
trigger: manifest?.trigger ?? opts.trigger ?? 'cli',
|
|
90
|
+
status: 'running',
|
|
91
|
+
suiteTag: manifest?.suiteTag ?? null,
|
|
92
|
+
tagsExpr: manifest?.tagsExpr ?? null,
|
|
93
|
+
layers: manifest?.layers ?? [],
|
|
94
|
+
browsers: manifest?.browsers ?? [],
|
|
95
|
+
shardTotal: manifest?.shardTotal ?? null,
|
|
96
|
+
gitSha: manifest?.git?.sha ?? null,
|
|
97
|
+
gitBranch: manifest?.git?.branch ?? null,
|
|
98
|
+
ciProvider: manifest?.ci?.provider ?? null,
|
|
99
|
+
ciRunId: manifest?.ci?.runId ?? null,
|
|
100
|
+
ciUrl: manifest?.ci?.url ?? null,
|
|
101
|
+
command: manifest?.command ?? null,
|
|
102
|
+
startedAt: manifest?.startedAt ?? null,
|
|
103
|
+
artifactsDir: runDir,
|
|
104
|
+
htmlReportRel: existsSync(join(runDir, runFiles.htmlReport)) ? runFiles.htmlReport : null,
|
|
105
|
+
exitCode: manifest?.exitCode ?? null,
|
|
106
|
+
process: manifest?.process ?? null,
|
|
107
|
+
});
|
|
108
|
+
if (opts.replace)
|
|
109
|
+
await deleteRunChildren(db, opts.runId);
|
|
110
|
+
const locatorCounters = new Map();
|
|
111
|
+
const filesIngested = [];
|
|
112
|
+
const filesSkipped = [];
|
|
113
|
+
let parseErrors = 0;
|
|
114
|
+
let runStartedAt = manifest?.startedAt ?? null;
|
|
115
|
+
let runFinishedAt = manifest?.finishedAt ?? null;
|
|
116
|
+
const counts = { scenarios: 0, attempts: 0, steps: 0, artifacts: 0, healEvents: 0 };
|
|
117
|
+
for (const file of ndjsonPaths) {
|
|
118
|
+
const sha = fileSha256(file);
|
|
119
|
+
const key = `${basename(file)}@${sha}`;
|
|
120
|
+
if (alreadyIngested.has(key) && !opts.replace) {
|
|
121
|
+
filesSkipped.push(file);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const session = new IngestSession(ctx);
|
|
125
|
+
const stats = { lines: 0, parsed: 0, skipped: 0 };
|
|
126
|
+
for await (const env of parseNdjson(file, stats))
|
|
127
|
+
await session.consume(env);
|
|
128
|
+
parseErrors += stats.skipped;
|
|
129
|
+
for (const [sel, c] of session.locatorCounters) {
|
|
130
|
+
const prev = locatorCounters.get(sel) ?? { fails: 0, heals: 0, uses: 0 };
|
|
131
|
+
locatorCounters.set(sel, {
|
|
132
|
+
fails: prev.fails + c.fails,
|
|
133
|
+
heals: prev.heals + c.heals,
|
|
134
|
+
uses: prev.uses + c.uses,
|
|
135
|
+
lastStrategy: c.lastStrategy ?? prev.lastStrategy,
|
|
136
|
+
suggested: c.suggested ?? prev.suggested,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
counts.scenarios += session.counts.scenarios;
|
|
140
|
+
counts.attempts += session.counts.attempts;
|
|
141
|
+
counts.steps += session.counts.steps;
|
|
142
|
+
counts.artifacts += session.counts.artifacts;
|
|
143
|
+
counts.healEvents += session.counts.healEvents;
|
|
144
|
+
if (session.runStartedAt && (!runStartedAt || session.runStartedAt < runStartedAt))
|
|
145
|
+
runStartedAt = session.runStartedAt;
|
|
146
|
+
if (session.runFinishedAt && (!runFinishedAt || session.runFinishedAt > runFinishedAt))
|
|
147
|
+
runFinishedAt = session.runFinishedAt;
|
|
148
|
+
filesIngested.push(key);
|
|
149
|
+
}
|
|
150
|
+
for (const file of runnerJsonPaths) {
|
|
151
|
+
const sha = fileSha256(file);
|
|
152
|
+
const key = `${basename(file)}@${sha}`;
|
|
153
|
+
if (alreadyIngested.has(key) && !opts.replace) {
|
|
154
|
+
filesSkipped.push(file);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const r = await ingestRunnerJson(ctx, file, locatorCounters);
|
|
158
|
+
counts.scenarios += r.scenarios;
|
|
159
|
+
counts.attempts += r.attempts;
|
|
160
|
+
counts.steps += r.steps;
|
|
161
|
+
counts.artifacts += r.artifacts;
|
|
162
|
+
if (r.startedAt && (!runStartedAt || r.startedAt < runStartedAt))
|
|
163
|
+
runStartedAt = r.startedAt;
|
|
164
|
+
if (r.finishedAt && (!runFinishedAt || r.finishedAt > runFinishedAt))
|
|
165
|
+
runFinishedAt = r.finishedAt;
|
|
166
|
+
filesIngested.push(key);
|
|
167
|
+
}
|
|
168
|
+
const { totals, status } = await finalizeRun({
|
|
169
|
+
adb,
|
|
170
|
+
runId: opts.runId,
|
|
171
|
+
projectId: project.id,
|
|
172
|
+
locatorCounters,
|
|
173
|
+
runStartedAt,
|
|
174
|
+
runFinishedAt,
|
|
175
|
+
ingestedFiles: filesIngested,
|
|
176
|
+
exitCode: manifest?.exitCode ?? null,
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
runId: opts.runId,
|
|
180
|
+
projectSlug,
|
|
181
|
+
totals,
|
|
182
|
+
status,
|
|
183
|
+
scenarios: counts.scenarios,
|
|
184
|
+
attempts: counts.attempts,
|
|
185
|
+
steps: counts.steps,
|
|
186
|
+
artifacts: counts.artifacts,
|
|
187
|
+
healEvents: counts.healEvents,
|
|
188
|
+
filesIngested,
|
|
189
|
+
filesSkipped,
|
|
190
|
+
parseErrors,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory index of the static cucumber envelopes (gherkinDocument, pickle, stepDefinition, hook,
|
|
3
|
+
* testCase) that the dynamic ones (testCaseStarted, testStepFinished, attachment …) refer to.
|
|
4
|
+
*/
|
|
5
|
+
export interface GherkinStepInfo {
|
|
6
|
+
keyword: string;
|
|
7
|
+
line: number;
|
|
8
|
+
}
|
|
9
|
+
export interface PickleInfo {
|
|
10
|
+
id: string;
|
|
11
|
+
uri: string;
|
|
12
|
+
name: string;
|
|
13
|
+
tags: string[];
|
|
14
|
+
steps: Array<{
|
|
15
|
+
id: string;
|
|
16
|
+
text: string;
|
|
17
|
+
type?: string;
|
|
18
|
+
astNodeIds: string[];
|
|
19
|
+
argument?: unknown;
|
|
20
|
+
}>;
|
|
21
|
+
astNodeIds: string[];
|
|
22
|
+
}
|
|
23
|
+
export interface TestCaseInfo {
|
|
24
|
+
id: string;
|
|
25
|
+
pickleId: string;
|
|
26
|
+
testSteps: Array<{
|
|
27
|
+
id: string;
|
|
28
|
+
pickleStepId?: string;
|
|
29
|
+
hookId?: string;
|
|
30
|
+
stepDefinitionIds?: string[];
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
export declare class MessageIndex {
|
|
34
|
+
readonly docs: Map<string, any>;
|
|
35
|
+
readonly pickles: Map<string, PickleInfo>;
|
|
36
|
+
readonly testCases: Map<string, TestCaseInfo>;
|
|
37
|
+
readonly stepDefs: Map<string, any>;
|
|
38
|
+
readonly hooks: Map<string, any>;
|
|
39
|
+
/** ast node id → step keyword/line (from gherkin documents) */
|
|
40
|
+
private readonly astSteps;
|
|
41
|
+
/** ast node id (examples table row) → { index (1-based), examplesTitle } */
|
|
42
|
+
private readonly astExampleRows;
|
|
43
|
+
/** ast node id (scenario) → { name, line } */
|
|
44
|
+
private readonly astScenarios;
|
|
45
|
+
private readonly featureNameByUri;
|
|
46
|
+
addDoc(doc: any): void;
|
|
47
|
+
addPickle(p: any): void;
|
|
48
|
+
addStepDef(sd: any): void;
|
|
49
|
+
addHook(h: any): void;
|
|
50
|
+
addTestCase(tc: any): void;
|
|
51
|
+
featureName(uri: string): string | null;
|
|
52
|
+
stepKeyword(pickleStep: {
|
|
53
|
+
astNodeIds: string[];
|
|
54
|
+
}): string | null;
|
|
55
|
+
/** 1-based Examples row index for an outline pickle, null for plain scenarios. */
|
|
56
|
+
exampleIndex(pickle: PickleInfo): number | null;
|
|
57
|
+
scenarioLine(pickle: PickleInfo): number | null;
|
|
58
|
+
/** Base scenario name (without example values substituted) */
|
|
59
|
+
scenarioTitle(pickle: PickleInfo): string;
|
|
60
|
+
hookType(hookId: string): 'before' | 'after' | null;
|
|
61
|
+
hookName(hookId: string): string;
|
|
62
|
+
definitionLocation(ids: string[] | undefined): string | null;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=message-index.d.ts.map
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
export class MessageIndex {
|
|
2
|
+
docs = new Map();
|
|
3
|
+
pickles = new Map();
|
|
4
|
+
testCases = new Map();
|
|
5
|
+
stepDefs = new Map();
|
|
6
|
+
hooks = new Map();
|
|
7
|
+
/** ast node id → step keyword/line (from gherkin documents) */
|
|
8
|
+
astSteps = new Map();
|
|
9
|
+
/** ast node id (examples table row) → { index (1-based), examplesTitle } */
|
|
10
|
+
astExampleRows = new Map();
|
|
11
|
+
/** ast node id (scenario) → { name, line } */
|
|
12
|
+
astScenarios = new Map();
|
|
13
|
+
featureNameByUri = new Map();
|
|
14
|
+
addDoc(doc) {
|
|
15
|
+
if (!doc?.uri)
|
|
16
|
+
return;
|
|
17
|
+
this.docs.set(doc.uri, doc);
|
|
18
|
+
const feature = doc.feature;
|
|
19
|
+
if (!feature)
|
|
20
|
+
return;
|
|
21
|
+
this.featureNameByUri.set(doc.uri, feature.name ?? '');
|
|
22
|
+
const walkChildren = (children) => {
|
|
23
|
+
for (const child of children ?? []) {
|
|
24
|
+
if (child.rule)
|
|
25
|
+
walkChildren(child.rule.children);
|
|
26
|
+
const node = child.background ?? child.scenario;
|
|
27
|
+
if (!node)
|
|
28
|
+
continue;
|
|
29
|
+
if (child.scenario) {
|
|
30
|
+
this.astScenarios.set(node.id, {
|
|
31
|
+
name: node.name ?? '',
|
|
32
|
+
line: node.location?.line ?? 0,
|
|
33
|
+
tags: (node.tags ?? []).map((t) => t.name),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
for (const step of node.steps ?? []) {
|
|
37
|
+
this.astSteps.set(step.id, {
|
|
38
|
+
keyword: String(step.keyword ?? '').trim(),
|
|
39
|
+
line: step.location?.line ?? 0,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
let rowIndex = 0;
|
|
43
|
+
for (const ex of node.examples ?? []) {
|
|
44
|
+
for (const row of ex.tableBody ?? []) {
|
|
45
|
+
rowIndex++;
|
|
46
|
+
this.astExampleRows.set(row.id, { index: rowIndex, line: row.location?.line ?? 0 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
walkChildren(feature.children);
|
|
52
|
+
}
|
|
53
|
+
addPickle(p) {
|
|
54
|
+
if (!p?.id)
|
|
55
|
+
return;
|
|
56
|
+
this.pickles.set(p.id, {
|
|
57
|
+
id: p.id,
|
|
58
|
+
uri: p.uri,
|
|
59
|
+
name: p.name ?? '',
|
|
60
|
+
tags: (p.tags ?? []).map((t) => t.name),
|
|
61
|
+
steps: (p.steps ?? []).map((s) => ({
|
|
62
|
+
id: s.id,
|
|
63
|
+
text: s.text ?? '',
|
|
64
|
+
type: s.type,
|
|
65
|
+
astNodeIds: s.astNodeIds ?? [],
|
|
66
|
+
argument: s.argument,
|
|
67
|
+
})),
|
|
68
|
+
astNodeIds: p.astNodeIds ?? [],
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
addStepDef(sd) {
|
|
72
|
+
if (sd?.id)
|
|
73
|
+
this.stepDefs.set(sd.id, sd);
|
|
74
|
+
}
|
|
75
|
+
addHook(h) {
|
|
76
|
+
if (h?.id)
|
|
77
|
+
this.hooks.set(h.id, h);
|
|
78
|
+
}
|
|
79
|
+
addTestCase(tc) {
|
|
80
|
+
if (!tc?.id)
|
|
81
|
+
return;
|
|
82
|
+
this.testCases.set(tc.id, { id: tc.id, pickleId: tc.pickleId, testSteps: tc.testSteps ?? [] });
|
|
83
|
+
}
|
|
84
|
+
featureName(uri) {
|
|
85
|
+
return this.featureNameByUri.get(uri) ?? null;
|
|
86
|
+
}
|
|
87
|
+
stepKeyword(pickleStep) {
|
|
88
|
+
for (const id of pickleStep.astNodeIds) {
|
|
89
|
+
const s = this.astSteps.get(id);
|
|
90
|
+
if (s)
|
|
91
|
+
return s.keyword;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/** 1-based Examples row index for an outline pickle, null for plain scenarios. */
|
|
96
|
+
exampleIndex(pickle) {
|
|
97
|
+
for (const id of pickle.astNodeIds) {
|
|
98
|
+
const row = this.astExampleRows.get(id);
|
|
99
|
+
if (row)
|
|
100
|
+
return row.index;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
scenarioLine(pickle) {
|
|
105
|
+
for (const id of pickle.astNodeIds) {
|
|
106
|
+
const sc = this.astScenarios.get(id);
|
|
107
|
+
if (sc)
|
|
108
|
+
return sc.line;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
/** Base scenario name (without example values substituted) */
|
|
113
|
+
scenarioTitle(pickle) {
|
|
114
|
+
for (const id of pickle.astNodeIds) {
|
|
115
|
+
const sc = this.astScenarios.get(id);
|
|
116
|
+
if (sc)
|
|
117
|
+
return sc.name || pickle.name;
|
|
118
|
+
}
|
|
119
|
+
return pickle.name;
|
|
120
|
+
}
|
|
121
|
+
hookType(hookId) {
|
|
122
|
+
const h = this.hooks.get(hookId);
|
|
123
|
+
if (!h)
|
|
124
|
+
return null;
|
|
125
|
+
const t = String(h.type ?? h.name ?? '').toLowerCase();
|
|
126
|
+
if (t.includes('after'))
|
|
127
|
+
return 'after';
|
|
128
|
+
if (t.includes('before'))
|
|
129
|
+
return 'before';
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
hookName(hookId) {
|
|
133
|
+
const h = this.hooks.get(hookId);
|
|
134
|
+
return h?.name ?? h?.sourceReference?.uri ?? 'hook';
|
|
135
|
+
}
|
|
136
|
+
definitionLocation(ids) {
|
|
137
|
+
for (const id of ids ?? []) {
|
|
138
|
+
const sd = this.stepDefs.get(id);
|
|
139
|
+
const ref = sd?.sourceReference;
|
|
140
|
+
if (ref?.uri)
|
|
141
|
+
return `${ref.uri}:${ref.location?.line ?? 0}`;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=message-index.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type Envelope = Record<string, any>;
|
|
2
|
+
export interface ParseStats {
|
|
3
|
+
lines: number;
|
|
4
|
+
parsed: number;
|
|
5
|
+
skipped: number;
|
|
6
|
+
}
|
|
7
|
+
/** Tolerant NDJSON reader: blank and corrupt lines are counted and skipped, never fatal. */
|
|
8
|
+
export declare function parseNdjson(file: string, stats?: ParseStats): AsyncGenerator<Envelope>;
|
|
9
|
+
export declare function fileSha256(file: string): string;
|
|
10
|
+
//# sourceMappingURL=parse-ndjson.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream, readFileSync } from 'node:fs';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
/** Tolerant NDJSON reader: blank and corrupt lines are counted and skipped, never fatal. */
|
|
5
|
+
export async function* parseNdjson(file, stats) {
|
|
6
|
+
const rl = createInterface({
|
|
7
|
+
input: createReadStream(file, { encoding: 'utf8' }),
|
|
8
|
+
crlfDelay: Infinity,
|
|
9
|
+
});
|
|
10
|
+
for await (const raw of rl) {
|
|
11
|
+
if (stats)
|
|
12
|
+
stats.lines++;
|
|
13
|
+
const line = raw.trim();
|
|
14
|
+
if (!line)
|
|
15
|
+
continue;
|
|
16
|
+
try {
|
|
17
|
+
const env = JSON.parse(line);
|
|
18
|
+
if (env && typeof env === 'object') {
|
|
19
|
+
if (stats)
|
|
20
|
+
stats.parsed++;
|
|
21
|
+
yield env;
|
|
22
|
+
}
|
|
23
|
+
else if (stats)
|
|
24
|
+
stats.skipped++;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
if (stats)
|
|
28
|
+
stats.skipped++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function fileSha256(file) {
|
|
33
|
+
return createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=parse-ndjson.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LocatorCounter } from './finalize.js';
|
|
2
|
+
import { type IngestContext } from './types.js';
|
|
3
|
+
export interface PwJsonCounts {
|
|
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 ingestPwJson(ctx: IngestContext, file: string, locatorCounters: Map<string, LocatorCounter>): Promise<PwJsonCounts & {
|
|
11
|
+
startedAt: string | null;
|
|
12
|
+
finishedAt: string | null;
|
|
13
|
+
}>;
|
|
14
|
+
//# sourceMappingURL=pw-json-ingest.d.ts.map
|