@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,89 @@
|
|
|
1
|
+
/** cucumber Timestamp/Duration → ms */
|
|
2
|
+
export function tsToMs(t) {
|
|
3
|
+
if (!t)
|
|
4
|
+
return null;
|
|
5
|
+
return Number(t.seconds) * 1000 + Math.round((t.nanos ?? 0) / 1e6);
|
|
6
|
+
}
|
|
7
|
+
export function tsToIso(t) {
|
|
8
|
+
const ms = tsToMs(t);
|
|
9
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
10
|
+
}
|
|
11
|
+
export function cucumberStatus(s) {
|
|
12
|
+
switch ((s ?? '').toUpperCase()) {
|
|
13
|
+
case 'PASSED':
|
|
14
|
+
return 'passed';
|
|
15
|
+
case 'FAILED':
|
|
16
|
+
return 'failed';
|
|
17
|
+
case 'SKIPPED':
|
|
18
|
+
case 'PENDING':
|
|
19
|
+
case 'UNDEFINED':
|
|
20
|
+
case 'AMBIGUOUS':
|
|
21
|
+
return 'skipped';
|
|
22
|
+
default:
|
|
23
|
+
return 'unknown';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function runnerStatus(s) {
|
|
27
|
+
switch (s) {
|
|
28
|
+
case 'passed':
|
|
29
|
+
return 'passed';
|
|
30
|
+
case 'failed':
|
|
31
|
+
return 'failed';
|
|
32
|
+
case 'timedOut':
|
|
33
|
+
return 'timedOut';
|
|
34
|
+
case 'skipped':
|
|
35
|
+
return 'skipped';
|
|
36
|
+
case 'interrupted':
|
|
37
|
+
return 'interrupted';
|
|
38
|
+
default:
|
|
39
|
+
return 'unknown';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Module = first directory after `features/`, else the first directory segment. */
|
|
43
|
+
export function moduleFromUri(uri) {
|
|
44
|
+
const segs = uri.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
45
|
+
if (segs.length < 2)
|
|
46
|
+
return null;
|
|
47
|
+
const fi = segs.lastIndexOf('features');
|
|
48
|
+
if (fi >= 0 && fi < segs.length - 2)
|
|
49
|
+
return segs[fi + 1];
|
|
50
|
+
if (fi >= 0)
|
|
51
|
+
return null;
|
|
52
|
+
// non-feature files (recorded specs): the directory that holds the file
|
|
53
|
+
return segs[segs.length - 2];
|
|
54
|
+
}
|
|
55
|
+
/** Strip playwright-bdd's `[<project>]:` prefix from a feature uri. */
|
|
56
|
+
export function splitUri(uri) {
|
|
57
|
+
const m = /^\[([^\]]+)\]:(.*)$/.exec(uri);
|
|
58
|
+
return m ? { runnerProject: m[1], uri: m[2] } : { runnerProject: null, uri };
|
|
59
|
+
}
|
|
60
|
+
const LOCATOR_PATTERNS = [
|
|
61
|
+
/locator\.\w+: Timeout[\s\S]*?waiting for (locator\([^)]*\)|getBy\w+\([^)]*\)|[^\n]+)/i,
|
|
62
|
+
/waiting for (locator\([^\n]*?\)|getBy\w+\([^\n]*?\))/i,
|
|
63
|
+
/strict mode violation: (locator\([^\n]*?\)|getBy\w+\([^\n]*?\))/i,
|
|
64
|
+
/expect\((locator\([^\n]*?\)|getBy\w+\([^\n]*?\))\)/i,
|
|
65
|
+
];
|
|
66
|
+
/** Extract the selector from a Playwright locator error message, if any. */
|
|
67
|
+
export function selectorFromError(message) {
|
|
68
|
+
if (!message)
|
|
69
|
+
return null;
|
|
70
|
+
for (const re of LOCATOR_PATTERNS) {
|
|
71
|
+
const m = re.exec(message);
|
|
72
|
+
if (m?.[1])
|
|
73
|
+
return m[1].trim().slice(0, 300);
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
export function emptyTotals() {
|
|
78
|
+
return {
|
|
79
|
+
total: 0,
|
|
80
|
+
passed: 0,
|
|
81
|
+
failed: 0,
|
|
82
|
+
skipped: 0,
|
|
83
|
+
timedOut: 0,
|
|
84
|
+
flaky: 0,
|
|
85
|
+
healed: 0,
|
|
86
|
+
durationMs: 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Kysely } from 'kysely';
|
|
2
|
+
import type { Database } from './schema.js';
|
|
3
|
+
export interface InsightsOptions {
|
|
4
|
+
projectSlug: string;
|
|
5
|
+
/** number of most recent runs to consider (default 30) */
|
|
6
|
+
window?: number;
|
|
7
|
+
/** thresholds */
|
|
8
|
+
flakyThreshold?: number;
|
|
9
|
+
fragilityThreshold?: number;
|
|
10
|
+
minRuns?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ScenarioInsight {
|
|
13
|
+
fingerprint: string;
|
|
14
|
+
runnerProject: string;
|
|
15
|
+
featureUri: string;
|
|
16
|
+
scenarioName: string;
|
|
17
|
+
module: string | null;
|
|
18
|
+
runs: number;
|
|
19
|
+
passed: number;
|
|
20
|
+
failed: number;
|
|
21
|
+
flaky: number;
|
|
22
|
+
/** (passed_on_retry + outcome flips) / runs */
|
|
23
|
+
flakinessScore: number;
|
|
24
|
+
p95DurationMs: number | null;
|
|
25
|
+
medianDurationMs: number | null;
|
|
26
|
+
instability: number | null;
|
|
27
|
+
quarantineCandidate: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface LocatorInsight {
|
|
30
|
+
selector: string;
|
|
31
|
+
pageHint: string | null;
|
|
32
|
+
fails: number;
|
|
33
|
+
heals: number;
|
|
34
|
+
uses: number;
|
|
35
|
+
fragility: number;
|
|
36
|
+
hot: boolean;
|
|
37
|
+
suggestedSelector: string | null;
|
|
38
|
+
}
|
|
39
|
+
export interface EnvInsight {
|
|
40
|
+
env: string;
|
|
41
|
+
runs: number;
|
|
42
|
+
envAttributedFailures: number;
|
|
43
|
+
stability: number;
|
|
44
|
+
}
|
|
45
|
+
export interface Insights {
|
|
46
|
+
projectSlug: string;
|
|
47
|
+
window: number;
|
|
48
|
+
runsConsidered: number;
|
|
49
|
+
passRate: number;
|
|
50
|
+
scenarios: ScenarioInsight[];
|
|
51
|
+
locators: LocatorInsight[];
|
|
52
|
+
envs: EnvInsight[];
|
|
53
|
+
suiteHealth: number;
|
|
54
|
+
suiteHealthTrend: number[];
|
|
55
|
+
computedAt: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Deterministic metrics over the last `window` runs of a project. Pure reads; results are
|
|
59
|
+
* returned (and written by the CLI into `flaky_stats` / `locator_stats` when asked).
|
|
60
|
+
*/
|
|
61
|
+
export declare function computeInsights(db: Kysely<Database>, opts: InsightsOptions): Promise<Insights>;
|
|
62
|
+
//# sourceMappingURL=insights.d.ts.map
|
package/dist/insights.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import { readBool } from './col.js';
|
|
3
|
+
const ENV_ERROR = /(ECONNREFUSED|ENOTFOUND|ETIMEDOUT|net::ERR_|502|503|504|Timeout .* exceeded|navigation timeout)/i;
|
|
4
|
+
/**
|
|
5
|
+
* Deterministic metrics over the last `window` runs of a project. Pure reads; results are
|
|
6
|
+
* returned (and written by the CLI into `flaky_stats` / `locator_stats` when asked).
|
|
7
|
+
*/
|
|
8
|
+
export async function computeInsights(db, opts) {
|
|
9
|
+
const window = opts.window ?? 30;
|
|
10
|
+
const flakyThreshold = opts.flakyThreshold ?? 0.2;
|
|
11
|
+
const fragilityThreshold = opts.fragilityThreshold ?? 0.1;
|
|
12
|
+
const minRuns = opts.minRuns ?? 10;
|
|
13
|
+
const project = await db
|
|
14
|
+
.selectFrom('projects')
|
|
15
|
+
.select(['id'])
|
|
16
|
+
.where('slug', '=', opts.projectSlug)
|
|
17
|
+
.executeTakeFirst();
|
|
18
|
+
if (!project)
|
|
19
|
+
throw new Error(`Unknown project ${opts.projectSlug}`);
|
|
20
|
+
const runs = await db
|
|
21
|
+
.selectFrom('runs')
|
|
22
|
+
.select(['id', 'env_name', 'status', 'totals_json', 'created_at', 'started_at'])
|
|
23
|
+
.where('project_id', '=', project.id)
|
|
24
|
+
.where('status', 'in', ['passed', 'failed'])
|
|
25
|
+
.orderBy(sql `coalesce(started_at, created_at)`, 'desc')
|
|
26
|
+
.limit(window)
|
|
27
|
+
.execute();
|
|
28
|
+
const runIds = runs.map((r) => r.id);
|
|
29
|
+
const runOrder = new Map(runIds.map((id, i) => [id, i]));
|
|
30
|
+
const scenarios = runIds.length
|
|
31
|
+
? await db
|
|
32
|
+
.selectFrom('scenarios')
|
|
33
|
+
.select([
|
|
34
|
+
'run_id',
|
|
35
|
+
'fingerprint',
|
|
36
|
+
'runner_project',
|
|
37
|
+
'feature_uri',
|
|
38
|
+
'scenario_name',
|
|
39
|
+
'module',
|
|
40
|
+
'status',
|
|
41
|
+
'flaky',
|
|
42
|
+
'duration_ms',
|
|
43
|
+
'error_message',
|
|
44
|
+
])
|
|
45
|
+
.where('run_id', 'in', runIds)
|
|
46
|
+
.execute()
|
|
47
|
+
: [];
|
|
48
|
+
// ── scenario flakiness ────────────────────────────────────────────────
|
|
49
|
+
const groups = new Map();
|
|
50
|
+
for (const s of scenarios) {
|
|
51
|
+
const key = `${s.fingerprint}|${s.runner_project}`;
|
|
52
|
+
const list = groups.get(key) ?? [];
|
|
53
|
+
list.push(s);
|
|
54
|
+
groups.set(key, list);
|
|
55
|
+
}
|
|
56
|
+
const scenarioInsights = [];
|
|
57
|
+
for (const list of groups.values()) {
|
|
58
|
+
const ordered = [...list]
|
|
59
|
+
.sort((a, b) => (runOrder.get(a.run_id) ?? 0) - (runOrder.get(b.run_id) ?? 0))
|
|
60
|
+
.reverse(); // oldest → newest
|
|
61
|
+
const runsN = ordered.length;
|
|
62
|
+
const passed = ordered.filter((s) => s.status === 'passed' && !readBool(s.flaky)).length;
|
|
63
|
+
const failed = ordered.filter((s) => s.status === 'failed' || s.status === 'timedOut').length;
|
|
64
|
+
const flakyRetries = ordered.filter((s) => readBool(s.flaky)).length;
|
|
65
|
+
let flips = 0;
|
|
66
|
+
for (let i = 1; i < ordered.length; i++) {
|
|
67
|
+
const prev = ordered[i - 1].status === 'passed';
|
|
68
|
+
const cur = ordered[i].status === 'passed';
|
|
69
|
+
if (prev !== cur)
|
|
70
|
+
flips++;
|
|
71
|
+
}
|
|
72
|
+
const flakinessScore = runsN ? round((flakyRetries + flips) / runsN) : 0;
|
|
73
|
+
const durations = ordered
|
|
74
|
+
.map((s) => s.duration_ms)
|
|
75
|
+
.filter((d) => typeof d === 'number' && d > 0)
|
|
76
|
+
.sort((a, b) => a - b);
|
|
77
|
+
const median = durations.length ? durations[Math.floor(durations.length / 2)] : null;
|
|
78
|
+
const p95 = durations.length
|
|
79
|
+
? durations[Math.min(durations.length - 1, Math.floor(durations.length * 0.95))]
|
|
80
|
+
: null;
|
|
81
|
+
const last = ordered[ordered.length - 1];
|
|
82
|
+
scenarioInsights.push({
|
|
83
|
+
fingerprint: last.fingerprint,
|
|
84
|
+
runnerProject: last.runner_project,
|
|
85
|
+
featureUri: last.feature_uri,
|
|
86
|
+
scenarioName: last.scenario_name,
|
|
87
|
+
module: last.module ?? null,
|
|
88
|
+
runs: runsN,
|
|
89
|
+
passed,
|
|
90
|
+
failed,
|
|
91
|
+
flaky: flakyRetries,
|
|
92
|
+
flakinessScore,
|
|
93
|
+
p95DurationMs: p95,
|
|
94
|
+
medianDurationMs: median,
|
|
95
|
+
instability: median && p95 ? round(p95 / median) : null,
|
|
96
|
+
quarantineCandidate: runsN >= minRuns && flakinessScore >= flakyThreshold,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
scenarioInsights.sort((a, b) => b.flakinessScore - a.flakinessScore || b.failed - a.failed);
|
|
100
|
+
// ── locator fragility ─────────────────────────────────────────────────
|
|
101
|
+
const locatorRows = await db
|
|
102
|
+
.selectFrom('locator_stats')
|
|
103
|
+
.selectAll()
|
|
104
|
+
.where('project_id', '=', project.id)
|
|
105
|
+
.execute();
|
|
106
|
+
const locators = locatorRows
|
|
107
|
+
.map((l) => {
|
|
108
|
+
const uses = Math.max(l.use_count, l.fail_count + l.heal_count, 1);
|
|
109
|
+
const fragility = round((l.fail_count + 0.5 * l.heal_count) / uses);
|
|
110
|
+
return {
|
|
111
|
+
selector: l.selector,
|
|
112
|
+
pageHint: l.page_hint,
|
|
113
|
+
fails: l.fail_count,
|
|
114
|
+
heals: l.heal_count,
|
|
115
|
+
uses: l.use_count,
|
|
116
|
+
fragility,
|
|
117
|
+
hot: fragility >= fragilityThreshold && l.heal_count >= 3,
|
|
118
|
+
suggestedSelector: l.suggested_selector,
|
|
119
|
+
};
|
|
120
|
+
})
|
|
121
|
+
.sort((a, b) => b.fragility - a.fragility);
|
|
122
|
+
// ── env stability ─────────────────────────────────────────────────────
|
|
123
|
+
const envMap = new Map();
|
|
124
|
+
for (const r of runs) {
|
|
125
|
+
const e = envMap.get(r.env_name) ?? { runs: 0, envFails: 0 };
|
|
126
|
+
e.runs++;
|
|
127
|
+
const failedHere = scenarios.filter((s) => s.run_id === r.id && (s.status === 'failed' || s.status === 'timedOut'));
|
|
128
|
+
const envLike = failedHere.filter((s) => s.error_message && ENV_ERROR.test(s.error_message));
|
|
129
|
+
const distinct = new Set(envLike.map((s) => s.fingerprint)).size;
|
|
130
|
+
if (distinct >= 3)
|
|
131
|
+
e.envFails++;
|
|
132
|
+
envMap.set(r.env_name, e);
|
|
133
|
+
}
|
|
134
|
+
const envs = [...envMap.entries()].map(([env, e]) => ({
|
|
135
|
+
env,
|
|
136
|
+
runs: e.runs,
|
|
137
|
+
envAttributedFailures: e.envFails,
|
|
138
|
+
stability: e.runs ? round(1 - e.envFails / e.runs) : 1,
|
|
139
|
+
}));
|
|
140
|
+
// ── suite health ──────────────────────────────────────────────────────
|
|
141
|
+
const perRunPass = runs.map((r) => {
|
|
142
|
+
const list = scenarios.filter((s) => s.run_id === r.id);
|
|
143
|
+
if (!list.length)
|
|
144
|
+
return null;
|
|
145
|
+
return list.filter((s) => s.status === 'passed').length / list.length;
|
|
146
|
+
});
|
|
147
|
+
const validPass = perRunPass.filter((p) => p !== null);
|
|
148
|
+
const passRate = validPass.length
|
|
149
|
+
? round(validPass.reduce((a, b) => a + b, 0) / validPass.length)
|
|
150
|
+
: 0;
|
|
151
|
+
const meanFlaky = scenarioInsights.length
|
|
152
|
+
? scenarioInsights.reduce((a, s) => a + s.flakinessScore, 0) / scenarioInsights.length
|
|
153
|
+
: 0;
|
|
154
|
+
const meanFragility = locators.length
|
|
155
|
+
? locators.reduce((a, l) => a + l.fragility, 0) / locators.length
|
|
156
|
+
: 0;
|
|
157
|
+
const durationCompliance = 1; // duration budgets are evaluated by the runtime; placeholder keeps the formula stable
|
|
158
|
+
const suiteHealth = round(0.5 * passRate +
|
|
159
|
+
0.2 * (1 - meanFlaky) +
|
|
160
|
+
0.2 * (1 - Math.min(1, meanFragility)) +
|
|
161
|
+
0.1 * durationCompliance);
|
|
162
|
+
const trend = [];
|
|
163
|
+
const chronological = [...validPass].reverse();
|
|
164
|
+
for (let i = 0; i < chronological.length; i++) {
|
|
165
|
+
const slice = chronological.slice(Math.max(0, i - 6), i + 1);
|
|
166
|
+
trend.push(round(slice.reduce((a, b) => a + b, 0) / slice.length));
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
projectSlug: opts.projectSlug,
|
|
170
|
+
window,
|
|
171
|
+
runsConsidered: runs.length,
|
|
172
|
+
passRate,
|
|
173
|
+
scenarios: scenarioInsights,
|
|
174
|
+
locators,
|
|
175
|
+
envs,
|
|
176
|
+
suiteHealth,
|
|
177
|
+
suiteHealthTrend: trend,
|
|
178
|
+
computedAt: new Date().toISOString(),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function round(n) {
|
|
182
|
+
return Math.round(n * 10000) / 10000;
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=insights.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Kysely } from 'kysely';
|
|
2
|
+
import { Migrator } from 'kysely/migration';
|
|
3
|
+
import { type Col } from './col.js';
|
|
4
|
+
import type { Driver } from './driver.js';
|
|
5
|
+
import type { SdodsDb } from './create-db.js';
|
|
6
|
+
export interface SdodsMigration {
|
|
7
|
+
up(db: Kysely<any>, c: Col, driver: Driver): Promise<void>;
|
|
8
|
+
down?(db: Kysely<any>, c: Col, driver: Driver): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Move a pre-rename ledger into place before the migrator reads it. Safe to call on every
|
|
12
|
+
* start: it does nothing unless the old ledger exists and actually records migrations.
|
|
13
|
+
*
|
|
14
|
+
* A start that already failed this way leaves an empty ledger under the current name behind,
|
|
15
|
+
* so an empty one is dropped rather than treated as authoritative. An empty ledger records
|
|
16
|
+
* nothing, so nothing is lost; a non-empty one is left untouched and adoption is declined.
|
|
17
|
+
*/
|
|
18
|
+
export declare function adoptLegacyLedger({ db }: SdodsDb): Promise<string[]>;
|
|
19
|
+
export declare function createMigrator({ db, driver }: SdodsDb): Migrator;
|
|
20
|
+
export declare function migrateToLatest(adb: SdodsDb): Promise<import("kysely/migration").MigrationResult[]>;
|
|
21
|
+
export declare function migrateDown(adb: SdodsDb): Promise<import("kysely/migration").MigrationResult[]>;
|
|
22
|
+
export declare function migrateTo(adb: SdodsDb, name: string): Promise<import("kysely/migration").MigrationResult[]>;
|
|
23
|
+
export declare function migrationStatus(adb: SdodsDb): Promise<{
|
|
24
|
+
name: string;
|
|
25
|
+
executedAt: string | null;
|
|
26
|
+
}[]>;
|
|
27
|
+
/** Drop every SDODS table (sqlite only, guarded by the CLI `--yes`). */
|
|
28
|
+
export declare function resetDatabase(adb: SdodsDb): Promise<void>;
|
|
29
|
+
export declare const MIGRATION_NAMES: string[];
|
|
30
|
+
//# sourceMappingURL=migrate.d.ts.map
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import { Migrator, } from 'kysely/migration';
|
|
3
|
+
import { col } from './col.js';
|
|
4
|
+
import { MIGRATIONS } from './migrations/index.js';
|
|
5
|
+
import { MIGRATION_LOCK_TABLE, MIGRATION_TABLE } from './schema.js';
|
|
6
|
+
/** Binds the driver into every migration so one file serves both dialects. */
|
|
7
|
+
class DriverBoundProvider {
|
|
8
|
+
driver;
|
|
9
|
+
constructor(driver) {
|
|
10
|
+
this.driver = driver;
|
|
11
|
+
}
|
|
12
|
+
async getMigrations() {
|
|
13
|
+
const c = col(this.driver);
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const [name, m] of Object.entries(MIGRATIONS)) {
|
|
16
|
+
out[name] = {
|
|
17
|
+
up: (db) => m.up(db, c, this.driver),
|
|
18
|
+
down: m.down ? (db) => m.down(db, c, this.driver) : undefined,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Ledger tables the platform used before it was renamed. A database created under the old
|
|
26
|
+
* name is fully migrated, but the migrator cannot see its ledger, so it would replay every
|
|
27
|
+
* migration and fail on the first CREATE TABLE. Renaming the ledger adopts the database.
|
|
28
|
+
*/
|
|
29
|
+
const LEGACY_MIGRATION_TABLE = 'automax_migrations';
|
|
30
|
+
const LEGACY_LOCK_TABLE = 'automax_migrations_lock';
|
|
31
|
+
async function tableExists(db, name) {
|
|
32
|
+
const tables = await db.introspection.getTables();
|
|
33
|
+
return tables.some((t) => t.name === name);
|
|
34
|
+
}
|
|
35
|
+
async function rowCount(db, name) {
|
|
36
|
+
const rows = await sql `select count(*) as n from ${sql.ref(name)}`.execute(db);
|
|
37
|
+
return Number(rows.rows[0]?.n ?? 0);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Move a pre-rename ledger into place before the migrator reads it. Safe to call on every
|
|
41
|
+
* start: it does nothing unless the old ledger exists and actually records migrations.
|
|
42
|
+
*
|
|
43
|
+
* A start that already failed this way leaves an empty ledger under the current name behind,
|
|
44
|
+
* so an empty one is dropped rather than treated as authoritative. An empty ledger records
|
|
45
|
+
* nothing, so nothing is lost; a non-empty one is left untouched and adoption is declined.
|
|
46
|
+
*/
|
|
47
|
+
export async function adoptLegacyLedger({ db }) {
|
|
48
|
+
if (!(await tableExists(db, LEGACY_MIGRATION_TABLE)))
|
|
49
|
+
return [];
|
|
50
|
+
if ((await rowCount(db, LEGACY_MIGRATION_TABLE)) === 0)
|
|
51
|
+
return [];
|
|
52
|
+
if (await tableExists(db, MIGRATION_TABLE)) {
|
|
53
|
+
if ((await rowCount(db, MIGRATION_TABLE)) > 0)
|
|
54
|
+
return [];
|
|
55
|
+
await sql `drop table ${sql.ref(MIGRATION_TABLE)}`.execute(db);
|
|
56
|
+
}
|
|
57
|
+
await sql `alter table ${sql.ref(LEGACY_MIGRATION_TABLE)} rename to ${sql.ref(MIGRATION_TABLE)}`.execute(db);
|
|
58
|
+
const renamed = [`${LEGACY_MIGRATION_TABLE} -> ${MIGRATION_TABLE}`];
|
|
59
|
+
// The lock table holds no history, so whichever one is in place will do.
|
|
60
|
+
if (await tableExists(db, LEGACY_LOCK_TABLE)) {
|
|
61
|
+
if (await tableExists(db, MIGRATION_LOCK_TABLE)) {
|
|
62
|
+
await sql `drop table ${sql.ref(LEGACY_LOCK_TABLE)}`.execute(db);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await sql `alter table ${sql.ref(LEGACY_LOCK_TABLE)} rename to ${sql.ref(MIGRATION_LOCK_TABLE)}`.execute(db);
|
|
66
|
+
renamed.push(`${LEGACY_LOCK_TABLE} -> ${MIGRATION_LOCK_TABLE}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return renamed;
|
|
70
|
+
}
|
|
71
|
+
export function createMigrator({ db, driver }) {
|
|
72
|
+
return new Migrator({
|
|
73
|
+
db,
|
|
74
|
+
provider: new DriverBoundProvider(driver),
|
|
75
|
+
migrationTableName: MIGRATION_TABLE,
|
|
76
|
+
migrationLockTableName: MIGRATION_LOCK_TABLE,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function assertOk(result) {
|
|
80
|
+
if (result.error)
|
|
81
|
+
throw result.error instanceof Error ? result.error : new Error(String(result.error));
|
|
82
|
+
return result.results ?? [];
|
|
83
|
+
}
|
|
84
|
+
export async function migrateToLatest(adb) {
|
|
85
|
+
await adoptLegacyLedger(adb);
|
|
86
|
+
return assertOk(await createMigrator(adb).migrateToLatest());
|
|
87
|
+
}
|
|
88
|
+
export async function migrateDown(adb) {
|
|
89
|
+
await adoptLegacyLedger(adb);
|
|
90
|
+
return assertOk(await createMigrator(adb).migrateDown());
|
|
91
|
+
}
|
|
92
|
+
export async function migrateTo(adb, name) {
|
|
93
|
+
await adoptLegacyLedger(adb);
|
|
94
|
+
return assertOk(await createMigrator(adb).migrateTo(name));
|
|
95
|
+
}
|
|
96
|
+
export async function migrationStatus(adb) {
|
|
97
|
+
await adoptLegacyLedger(adb);
|
|
98
|
+
const list = await createMigrator(adb).getMigrations();
|
|
99
|
+
return list.map((m) => ({
|
|
100
|
+
name: m.name,
|
|
101
|
+
executedAt: m.executedAt ? m.executedAt.toISOString() : null,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
/** Drop every SDODS table (sqlite only, guarded by the CLI `--yes`). */
|
|
105
|
+
export async function resetDatabase(adb) {
|
|
106
|
+
if (adb.driver !== 'sqlite')
|
|
107
|
+
throw new Error('reset is only supported on sqlite; drop the Postgres database yourself.');
|
|
108
|
+
const migrator = createMigrator(adb);
|
|
109
|
+
const applied = (await migrator.getMigrations()).filter((m) => m.executedAt);
|
|
110
|
+
for (let i = 0; i < applied.length; i++)
|
|
111
|
+
assertOk(await migrator.migrateDown());
|
|
112
|
+
}
|
|
113
|
+
export const MIGRATION_NAMES = Object.keys(MIGRATIONS);
|
|
114
|
+
//# sourceMappingURL=migrate.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { dropTables, fk, index, stamps, table } from './helpers.js';
|
|
2
|
+
export async function up(db, c) {
|
|
3
|
+
await stamps(table(db, 'roles', c)
|
|
4
|
+
.addColumn('name', 'text', (cb) => cb.notNull().unique())
|
|
5
|
+
.addColumn('permissions_json', c.json, (cb) => cb.notNull()), c, false).execute();
|
|
6
|
+
await stamps(fk(table(db, 'users', c)
|
|
7
|
+
.addColumn('username', 'text', (cb) => cb.notNull().unique())
|
|
8
|
+
.addColumn('email', 'text')
|
|
9
|
+
.addColumn('password_hash', 'text', (cb) => cb.notNull()), 'role_id', 'roles', c, { onDelete: 'restrict' })
|
|
10
|
+
.addColumn('active', c.bool, (cb) => cb.notNull().defaultTo(c.pg ? true : 1))
|
|
11
|
+
.addColumn('auth_provider', 'text', (cb) => cb.notNull().defaultTo('local'))
|
|
12
|
+
.addColumn('external_id', 'text')
|
|
13
|
+
.addColumn('last_login_at', c.ts), c).execute();
|
|
14
|
+
await fk(table(db, 'sessions', c), 'user_id', 'users', c)
|
|
15
|
+
.addColumn('csrf_token', 'text', (cb) => cb.notNull())
|
|
16
|
+
.addColumn('ip', 'text')
|
|
17
|
+
.addColumn('user_agent', 'text')
|
|
18
|
+
.addColumn('created_at', c.ts, (cb) => cb.notNull().defaultTo(c.now))
|
|
19
|
+
.addColumn('expires_at', c.ts, (cb) => cb.notNull())
|
|
20
|
+
.addColumn('last_seen_at', c.ts, (cb) => cb.notNull().defaultTo(c.now))
|
|
21
|
+
.execute();
|
|
22
|
+
await index(db, 'idx_sessions_user', 'sessions', ['user_id']);
|
|
23
|
+
await index(db, 'idx_sessions_expires', 'sessions', ['expires_at']);
|
|
24
|
+
await stamps(table(db, 'projects', c)
|
|
25
|
+
.addColumn('slug', 'text', (cb) => cb.notNull().unique())
|
|
26
|
+
.addColumn('name', 'text', (cb) => cb.notNull())
|
|
27
|
+
.addColumn('description', 'text')
|
|
28
|
+
.addColumn('root_path', 'text')
|
|
29
|
+
.addColumn('config_json', c.json, (cb) => cb.notNull())
|
|
30
|
+
.addColumn('config_hash', 'text')
|
|
31
|
+
.addColumn('layers_json', c.json, (cb) => cb.notNull())
|
|
32
|
+
.addColumn('browsers_json', c.json, (cb) => cb.notNull())
|
|
33
|
+
.addColumn('tags_policy_json', c.json, (cb) => cb.notNull())
|
|
34
|
+
.addColumn('screenshot_policy_json', c.json, (cb) => cb.notNull())
|
|
35
|
+
.addColumn('archived', c.bool, (cb) => cb.notNull().defaultTo(c.pg ? false : 0)), c).execute();
|
|
36
|
+
await stamps(fk(table(db, 'environments', c), 'project_id', 'projects', c)
|
|
37
|
+
.addColumn('name', 'text', (cb) => cb.notNull())
|
|
38
|
+
.addColumn('base_url', 'text')
|
|
39
|
+
.addColumn('api_base_url', 'text')
|
|
40
|
+
.addColumn('config_json', c.json, (cb) => cb.notNull())
|
|
41
|
+
.addColumn('secret_keys_json', c.json, (cb) => cb.notNull())
|
|
42
|
+
.addColumn('is_default', c.bool, (cb) => cb.notNull().defaultTo(c.pg ? false : 0)), c).execute();
|
|
43
|
+
await index(db, 'uq_environments_project_name', 'environments', ['project_id', 'name'], true);
|
|
44
|
+
}
|
|
45
|
+
export async function down(db) {
|
|
46
|
+
await dropTables(db, ['environments', 'projects', 'sessions', 'users', 'roles']);
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=0001_platform_init.js.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { dropTables, fk, index, stamps, table } from './helpers.js';
|
|
2
|
+
export async function up(db, c) {
|
|
3
|
+
await stamps(fk(fk(table(db, 'runs', c), 'project_id', 'projects', c), 'environment_id', 'environments', c, {
|
|
4
|
+
nullable: true,
|
|
5
|
+
onDelete: 'set null',
|
|
6
|
+
})
|
|
7
|
+
.addColumn('env_name', 'text', (cb) => cb.notNull())
|
|
8
|
+
.addColumn('trigger', 'text', (cb) => cb.notNull().defaultTo('cli'))
|
|
9
|
+
.addColumn('status', 'text', (cb) => cb.notNull().defaultTo('queued'))
|
|
10
|
+
.addColumn('suite_tag', 'text')
|
|
11
|
+
.addColumn('tags_expr', 'text')
|
|
12
|
+
.addColumn('layers_json', c.json, (cb) => cb.notNull())
|
|
13
|
+
.addColumn('browsers_json', c.json, (cb) => cb.notNull())
|
|
14
|
+
.addColumn('shard_total', c.int)
|
|
15
|
+
.addColumn('shards_ingested', c.int, (cb) => cb.notNull().defaultTo(0))
|
|
16
|
+
.addColumn('git_sha', 'text')
|
|
17
|
+
.addColumn('git_branch', 'text')
|
|
18
|
+
.addColumn('ci_provider', 'text')
|
|
19
|
+
.addColumn('ci_run_id', 'text')
|
|
20
|
+
.addColumn('ci_url', 'text')
|
|
21
|
+
.addColumn('started_by', 'text')
|
|
22
|
+
.addColumn('command', 'text')
|
|
23
|
+
.addColumn('started_at', c.ts)
|
|
24
|
+
.addColumn('finished_at', c.ts)
|
|
25
|
+
.addColumn('duration_ms', c.int)
|
|
26
|
+
.addColumn('totals_json', c.json, (cb) => cb.notNull())
|
|
27
|
+
.addColumn('artifacts_dir', 'text')
|
|
28
|
+
.addColumn('pw_report_rel', 'text')
|
|
29
|
+
.addColumn('exit_code', c.int)
|
|
30
|
+
.addColumn('error_text', 'text'), c).execute();
|
|
31
|
+
await index(db, 'idx_runs_project_started', 'runs', ['project_id', 'started_at']);
|
|
32
|
+
await index(db, 'idx_runs_status', 'runs', ['status']);
|
|
33
|
+
await index(db, 'idx_runs_ci_run', 'runs', ['ci_run_id']);
|
|
34
|
+
await stamps(fk(fk(table(db, 'scenarios', c), 'run_id', 'runs', c), 'project_id', 'projects', c)
|
|
35
|
+
.addColumn('natural_key', 'text', (cb) => cb.notNull())
|
|
36
|
+
.addColumn('fingerprint', 'text', (cb) => cb.notNull())
|
|
37
|
+
.addColumn('source', 'text', (cb) => cb.notNull().defaultTo('gherkin'))
|
|
38
|
+
.addColumn('feature_uri', 'text', (cb) => cb.notNull())
|
|
39
|
+
.addColumn('feature_name', 'text')
|
|
40
|
+
.addColumn('scenario_name', 'text', (cb) => cb.notNull())
|
|
41
|
+
.addColumn('examples_row', c.int)
|
|
42
|
+
.addColumn('pw_project', 'text', (cb) => cb.notNull())
|
|
43
|
+
.addColumn('layer', 'text', (cb) => cb.notNull())
|
|
44
|
+
.addColumn('browser', 'text')
|
|
45
|
+
.addColumn('suite_tag', 'text')
|
|
46
|
+
.addColumn('tags_json', c.json, (cb) => cb.notNull())
|
|
47
|
+
.addColumn('jira_keys_json', c.json, (cb) => cb.notNull())
|
|
48
|
+
.addColumn('status', 'text', (cb) => cb.notNull().defaultTo('unknown'))
|
|
49
|
+
.addColumn('attempts_count', c.int, (cb) => cb.notNull().defaultTo(0))
|
|
50
|
+
.addColumn('flaky', c.bool, (cb) => cb.notNull().defaultTo(c.pg ? false : 0))
|
|
51
|
+
.addColumn('duration_ms', c.int)
|
|
52
|
+
.addColumn('error_message', 'text')
|
|
53
|
+
.addColumn('error_stack', 'text')
|
|
54
|
+
.addColumn('started_at', c.ts)
|
|
55
|
+
.addColumn('finished_at', c.ts), c).execute();
|
|
56
|
+
await index(db, 'uq_scenarios_run_natural', 'scenarios', ['run_id', 'natural_key'], true);
|
|
57
|
+
await index(db, 'idx_scenarios_fingerprint', 'scenarios', ['fingerprint']);
|
|
58
|
+
await index(db, 'idx_scenarios_run_status', 'scenarios', ['run_id', 'status']);
|
|
59
|
+
await index(db, 'idx_scenarios_project_fp', 'scenarios', ['project_id', 'fingerprint']);
|
|
60
|
+
await fk(fk(table(db, 'scenario_attempts', c), 'scenario_id', 'scenarios', c), 'run_id', 'runs', c)
|
|
61
|
+
.addColumn('attempt', c.int, (cb) => cb.notNull().defaultTo(0))
|
|
62
|
+
.addColumn('test_case_started_id', 'text', (cb) => cb.notNull())
|
|
63
|
+
.addColumn('status', 'text', (cb) => cb.notNull().defaultTo('unknown'))
|
|
64
|
+
.addColumn('duration_ms', c.int)
|
|
65
|
+
.addColumn('error_message', 'text')
|
|
66
|
+
.addColumn('error_stack', 'text')
|
|
67
|
+
.addColumn('will_be_retried', c.bool, (cb) => cb.notNull().defaultTo(c.pg ? false : 0))
|
|
68
|
+
.addColumn('worker_index', c.int)
|
|
69
|
+
.addColumn('started_at', c.ts)
|
|
70
|
+
.addColumn('finished_at', c.ts)
|
|
71
|
+
.addColumn('created_at', c.ts, (cb) => cb.notNull().defaultTo(c.now))
|
|
72
|
+
.execute();
|
|
73
|
+
await index(db, 'uq_attempts_run_tcs', 'scenario_attempts', ['run_id', 'test_case_started_id'], true);
|
|
74
|
+
await index(db, 'idx_attempts_scenario', 'scenario_attempts', ['scenario_id', 'attempt']);
|
|
75
|
+
await fk(fk(fk(table(db, 'steps', c), 'attempt_id', 'scenario_attempts', c), 'scenario_id', 'scenarios', c), 'run_id', 'runs', c)
|
|
76
|
+
.addColumn('step_index', c.int, (cb) => cb.notNull())
|
|
77
|
+
.addColumn('kind', 'text', (cb) => cb.notNull().defaultTo('step'))
|
|
78
|
+
.addColumn('hook_type', 'text')
|
|
79
|
+
.addColumn('keyword', 'text')
|
|
80
|
+
.addColumn('text', 'text', (cb) => cb.notNull())
|
|
81
|
+
.addColumn('argument_json', c.json)
|
|
82
|
+
.addColumn('status', 'text', (cb) => cb.notNull().defaultTo('unknown'))
|
|
83
|
+
.addColumn('duration_ms', c.int)
|
|
84
|
+
.addColumn('error_message', 'text')
|
|
85
|
+
.addColumn('error_stack', 'text')
|
|
86
|
+
.addColumn('definition_location', 'text')
|
|
87
|
+
.addColumn('test_step_id', 'text', (cb) => cb.notNull())
|
|
88
|
+
.addColumn('pickle_step_id', 'text')
|
|
89
|
+
.addColumn('layer_hint', 'text')
|
|
90
|
+
.addColumn('api_snapshot_json', c.json)
|
|
91
|
+
.addColumn('perf_json', c.json)
|
|
92
|
+
.addColumn('started_at', c.ts)
|
|
93
|
+
.addColumn('finished_at', c.ts)
|
|
94
|
+
.addColumn('created_at', c.ts, (cb) => cb.notNull().defaultTo(c.now))
|
|
95
|
+
.execute();
|
|
96
|
+
await index(db, 'uq_steps_attempt_test_step', 'steps', ['attempt_id', 'test_step_id'], true);
|
|
97
|
+
await index(db, 'idx_steps_scenario_index', 'steps', ['scenario_id', 'step_index']);
|
|
98
|
+
await fk(fk(fk(fk(table(db, 'artifacts', c), 'run_id', 'runs', c), 'scenario_id', 'scenarios', c, {
|
|
99
|
+
nullable: true,
|
|
100
|
+
}), 'attempt_id', 'scenario_attempts', c, { nullable: true }), 'step_id', 'steps', c, { nullable: true, onDelete: 'set null' })
|
|
101
|
+
.addColumn('step_index', c.int)
|
|
102
|
+
.addColumn('kind', 'text', (cb) => cb.notNull())
|
|
103
|
+
.addColumn('phase', 'text')
|
|
104
|
+
.addColumn('media_type', 'text', (cb) => cb.notNull().defaultTo('application/octet-stream'))
|
|
105
|
+
.addColumn('file_name', 'text', (cb) => cb.notNull())
|
|
106
|
+
.addColumn('rel_path', 'text', (cb) => cb.notNull().unique())
|
|
107
|
+
.addColumn('size_bytes', c.int, (cb) => cb.notNull().defaultTo(0))
|
|
108
|
+
.addColumn('sha256', 'text')
|
|
109
|
+
.addColumn('width', c.int)
|
|
110
|
+
.addColumn('height', c.int)
|
|
111
|
+
.addColumn('meta_json', c.json)
|
|
112
|
+
.addColumn('created_at', c.ts, (cb) => cb.notNull().defaultTo(c.now))
|
|
113
|
+
.execute();
|
|
114
|
+
await index(db, 'idx_artifacts_attempt_step_phase', 'artifacts', [
|
|
115
|
+
'attempt_id',
|
|
116
|
+
'step_index',
|
|
117
|
+
'phase',
|
|
118
|
+
]);
|
|
119
|
+
await index(db, 'idx_artifacts_scenario_kind', 'artifacts', ['scenario_id', 'kind']);
|
|
120
|
+
}
|
|
121
|
+
export async function down(db) {
|
|
122
|
+
await dropTables(db, ['artifacts', 'steps', 'scenario_attempts', 'scenarios', 'runs']);
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=0002_results.js.map
|